From 74e6c3ad795bb1b3937607888e6557aeae61591c Mon Sep 17 00:00:00 2001 From: Gleb Khmyznikov Date: Tue, 7 Jul 2026 22:58:08 -0700 Subject: [PATCH] [UITests] New framework around WinApp CLI, no WinAppDriver or Selenium. (#48467) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Add winappcli-based UI test harness (no WinAppDriver / Selenium) ## Summary Introduces a new UI test harness — `Microsoft.PowerToys.UITest.Next` — that drives PowerToys modules through Microsoft's [winappcli](https://github.com/microsoft/WinAppCli) (UI Automation CLI) instead of WinAppDriver + Selenium. Engine is a single executable shelled out from C#; no third-party NuGet packages, no driver process, no Appium server. Adds two real consumers: a full ColorPicker end-to-end scenario and a Settings shell navigation smoke test. This is opt-in and additive — the existing `UITestAutomation` library and the WinAppDriver-based test projects are untouched. Both can coexist while we evaluate the new harness. Inspired in part by [#48414](https://github.com/microsoft/PowerToys/pull/48414), which lands the same architectural bet (winappcli, AutomationId selectors, no WinAppDriver) at a smaller scope. This PR generalizes it into a reusable library. ## Why WinAppDriver + Selenium is a legacy pre-agentic solution that is no longer actively maintained. It's unreliable, heavyweight, and slow. To achieve 100% UI test coverage, we should leverage modern, reliable solutions, and WinApp CLI is a strong candidate. ## What's in this PR ### Harness library — [`src/common/UITestAutomation.Next/`](src/common/UITestAutomation.Next/) | File | Purpose | |---|---| | [`WinappCli.cs`](src/common/UITestAutomation.Next/WinappCli.cs) | Process wrapper around `winapp.exe`. `Invoke` / `InvokeAssertSuccess` / `InvokeJson` / `IsAvailable` / `TryResolveExecutable`. `Result` carries the args and emits `DescribeFailure()` like `winapp ui invoke X -w 12345 -> exit 1; stderr: ...` | | [`Session.cs`](src/common/UITestAutomation.Next/Session.cs) | Test session, scoped by either HWND (`-w`) or process (`-a`) via `TargetScope`. `Find` / `FindAll` / `Inspect` / `Screenshot` / `SendKeys`. `Session.FromProcess(...)` factory for the single-window-per-process case | | [`SessionHelper.cs`](src/common/UITestAutomation.Next/SessionHelper.cs) | Owns the launch + window-readiness flow. Static `EnsureRunning(scope, timeout)` returns whether the call had to launch (so cleanup only kills what we started). Uses `UseShellExecute=true` so child handles don't keep MSTest hanging | | [`UITestBase.cs`](src/common/UITestAutomation.Next/UITestBase.cs) | MSTest base class. Pre-flights `WinappCli.IsAvailable()` once per process and fails fast with the install hint if `winapp.exe` isn't on PATH | | [`Element/*.cs`](src/common/UITestAutomation.Next/Element/) | `Element`, `Button`, `ToggleSwitch`, `TextBox`, `NavigationViewItem`, `Window`. `Click` / `MouseClick` / `Focus` / `GetProperty` / `GetValue` / `HelpText` / `WaitForProperty` / `WaitForGone` plus coords (`X`/`Y`/`Width`/`Height`) | | [`By.cs`](src/common/UITestAutomation.Next/By.cs) | `By.Name` / `By.AccessibilityId` / `By.Id` / `By.Slug` | | [`Windows.cs`](src/common/UITestAutomation.Next/Windows.cs) | `WindowsFinder.ListAll` / `ListByApp` / `WaitForWindowByApp` / `WaitForWindowByProcess`. Notes the winappcli bug where unfiltered `list-windows` drops untitled windows | | [`WindowControl.cs`](src/common/UITestAutomation.Next/WindowControl.cs) | Tolerant Win32 helpers — `TryCloseByApp` / `TryFocusByApp` / `SafeCloseAndFocus` / `TryKillProcess` — for `finally` blocks | | [`KeyboardHelper.cs`](src/common/UITestAutomation.Next/KeyboardHelper.cs) | Hybrid `keybd_event` + `SendKeys.SendWait` chord sender — required for global PowerToys hotkeys | | [`MouseHelper.cs`](src/common/UITestAutomation.Next/MouseHelper.cs) | `MoveTo` / `LeftClick` / `RightClick` / `LeftClickAt` Win32 wrappers | | [`ClipboardHelper.cs`](src/common/UITestAutomation.Next/ClipboardHelper.cs) | STA-thread `Clipboard` access with `WaitForText` | | [`ModuleConfigData.cs`](src/common/UITestAutomation.Next/ModuleConfigData.cs) | `PowerToysModule` enum + path/process-name resolution | ### Tests **[`src/modules/colorPicker/ColorPicker.UITests/`](src/modules/colorPicker/ColorPicker.UITests/)** — replaces the previous empty `UITest-ColorPicker` stub. One test, [`ColorPickerEndToEndTests.NavigateReadShortcutActivateAndCapture`](src/modules/colorPicker/ColorPicker.UITests/ColorPickerEndToEndTests.cs), drives the full E2E: 1. Navigate to the Color Picker page via the dashboard utilities stack 2. Toggle the module OFF, verify `PowerToys.ColorPickerUI` exits; toggle ON, verify it respawns 3. Read the activation shortcut from the page's `ShortcutControl` (`EditButton.HelpText`) 4. Clear clipboard, park cursor, send the chord 5. Wait for the picker overlay window 6. Read the displayed HEX from a hidden XAML automation peer (see below) 7. Left-click to capture; assert the clipboard value matches the peer's HEX 8. Wait for the editor window and assert the captured color appears in its tree **[`src/settings-ui/Settings.UITests/`](src/settings-ui/Settings.UITests/)** — `SettingsNavigationSmokeTests.NavigationItem_NavigatesWithoutCrashing` is one `[TestMethod]` parameterized with `[DynamicData]`, producing 31 discrete results — one per `NavigationViewItem` in [`ShellPage.xaml`](src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml). For each item: navigate, settle 250ms, assert `PowerToys.Settings` is still alive. Catches FailFast regressions in `ShellViewModel.Frame_NavigationFailed` that pure-logic unit tests can't reach (the failure path needs a `NavigationFailedEventArgs` which is a sealed WinRT projection). ### Product change **[`src/modules/colorPicker/ColorPickerUI/Views/MainView.xaml`](src/modules/colorPicker/ColorPickerUI/Views/MainView.xaml)** — adds a hidden `TextBlock` automation peer: ```xml ``` The visible `ColorTextBlock` has `AutomationProperties.Name="{Binding ColorName}"`, which masks the HEX value in the UIA tree (you see "White" instead of `#FFFFFF`). This zero-impact peer mirrors `ColorText` so tests can read the actually-displayed HEX. `Opacity=0` + `IsHitTestVisible=False` keep it out of the visual layout and out of accessibility focus. ### Project wiring - [`PowerToys.slnx`](PowerToys.slnx) — registers `UITestAutomation.Next` under `/common/`, `ColorPicker.UITests` under `/modules/colorpicker/Tests/`, and `Settings.UITests` under `/settings-ui/Tests/`. Original `UITest-ColorPicker` stub csproj removed. - [`.github/actions/spell-check/expect.txt`](.github/actions/spell-check/expect.txt) — adds `winapp` / `winappcli`. ### Not in this PR - No pipeline changes. `winapp.exe` is expected to be pre-staged on the test agent image. If it's missing, `UITestBase` fails the first test with the install hint (`winget install Microsoft.winappcli`) rather than producing 30 opaque per-test errors. - No changes to the legacy `UITestAutomation` library or any of the existing `*.UITests` projects. ## Validation - All three projects build clean on `x64|Debug` (empty `build...errors.log`): - `src/common/UITestAutomation.Next/` - `src/modules/colorPicker/ColorPicker.UITests/` - `src/settings-ui/Settings.UITests/` - Both tests run in Test Explorer / `dotnet test` via Microsoft.Testing.Platform (already enabled repo-wide in `Directory.Build.props`). - Local runs: ColorPicker E2E green; Settings smoke green across all 31 nav items. - `winapp 0.3.2` from `winget install Microsoft.winappcli`. ## Notes for reviewers - **`UseShellExecute = true`** in `SessionHelper.EnsureRunning` is intentional — `false` makes child processes inherit the test host's stdin/stdout/stderr handles, which keeps MTP/Test Explorer marking the run as "in progress" until the spawned PowerToys exits. - **Process-scope (`-a`) targeting** in the Settings smoke test handles single-instance handoff: the EXE you launch may exit with code 0 immediately after signalling an existing owner, so the alive check uses `Process.GetProcessesByName` rather than the launcher PID. - **AutomationId-only selectors** in the Settings smoke list keep the test localization-independent. Parent groups have `SelectsOnInvoked="False"` and only expand on click — `Element.Click` tries `InvokePattern → TogglePattern → SelectionItemPattern → ExpandCollapsePattern` so the same call works for both leaves and groups. - **Untitled-window discovery**: filtered `winapp ui list-windows -a ` returns windows that the unfiltered call drops (e.g. ColorPicker editor). `WindowsFinder.ListByApp` uses the filtered form. Reported upstream. ## Before Merge - Add the `winappcli` install step to the UI-test pipeline. --- .github/actions/spell-check/expect.txt | 2 + .pipelines/InstallWinAppCli.ps1 | 71 +++ .pipelines/v2/templates/job-build-project.yml | 10 +- .pipelines/v2/templates/job-test-project.yml | 132 ++++- AGENTS.md | 2 +- Cpp.Build.props | 5 +- PowerToys.slnx | 30 +- doc/devdocs/tools/fuzzingtesting.md | 6 +- src/Common.Dotnet.FuzzTest.props | 11 +- src/common/UITestAutomation.Next/By.cs | 49 ++ .../UITestAutomation.Next/ClipboardHelper.cs | 75 +++ .../UITestAutomation.Next/DisplayHelper.cs | 131 +++++ .../UITestAutomation.Next/Element/Button.cs | 13 + .../UITestAutomation.Next/Element/CheckBox.cs | 31 + .../UITestAutomation.Next/Element/ComboBox.cs | 52 ++ .../UITestAutomation.Next/Element/Custom.cs | 17 + .../UITestAutomation.Next/Element/Element.cs | 390 +++++++++++++ .../Element/NavigationViewItem.cs | 14 + .../UITestAutomation.Next/Element/Pane.cs | 14 + .../Element/RadioButton.cs | 31 + .../UITestAutomation.Next/Element/Slider.cs | 41 ++ .../UITestAutomation.Next/Element/Tab.cs | 17 + .../Element/TextBlock.cs | 20 + .../UITestAutomation.Next/Element/TextBox.cs | 46 ++ .../UITestAutomation.Next/Element/Thumb.cs | 17 + .../Element/ToggleSwitch.cs | 32 ++ .../UITestAutomation.Next/Element/Window.cs | 13 + .../UITestAutomation.Next/ElevationHelper.cs | 71 +++ .../EnvironmentConfig.cs | 40 ++ .../FRAMEWORK-PARITY-PLAN.md | 162 ++++++ .../UITestAutomation.Next/KeyboardHelper.cs | 204 +++++++ .../UITestAutomation.Next/ModuleConfigData.cs | 207 +++++++ .../UITestAutomation.Next/MonitorInfo.cs | 104 ++++ .../UITestAutomation.Next/MouseHelper.cs | 152 +++++ .../UITestAutomation.Next/ScreenCapture.cs | 128 +++++ .../UITestAutomation.Next/ScreenRecording.cs | 340 +++++++++++ src/common/UITestAutomation.Next/Session.cs | 421 ++++++++++++++ .../UITestAutomation.Next/SessionHelper.cs | 380 ++++++++++++ .../SettingsConfigHelper.cs | 105 ++++ .../UITestAutomation.Next.csproj | 28 + .../UITestAutomation.Next/UITestBase.cs | 544 ++++++++++++++++++ src/common/UITestAutomation.Next/WinappCli.cs | 314 ++++++++++ .../UITestAutomation.Next/WindowControl.cs | 263 +++++++++ .../UITestAutomation.Next/WindowHelper.cs | 171 ++++++ src/common/UITestAutomation.Next/Windows.cs | 155 +++++ src/common/UITestAutomation/ModuleInfo.cs | 22 +- src/common/UITestAutomation/SessionHelper.cs | 77 ++- .../ColorPicker.UITests/AssemblyInfo.cs | 10 + .../ColorPicker.UITests.csproj | 42 ++ .../ColorPickerEndToEndTests.cs | 446 ++++++++++++++ .../ColorPickerUITest.md | 0 .../ColorPickerUI/Views/MainView.xaml | 14 + .../UITest-ColorPicker/ColorPickerUITest.cs | 16 - .../UITest-ColorPicker.csproj | 29 - .../Settings.UITests/Settings.UITests.csproj | 42 ++ .../SettingsNavigationSmokeTests.cs | 153 +++++ .../SettingsTests.md | 0 57 files changed, 5837 insertions(+), 75 deletions(-) create mode 100644 .pipelines/InstallWinAppCli.ps1 create mode 100644 src/common/UITestAutomation.Next/By.cs create mode 100644 src/common/UITestAutomation.Next/ClipboardHelper.cs create mode 100644 src/common/UITestAutomation.Next/DisplayHelper.cs create mode 100644 src/common/UITestAutomation.Next/Element/Button.cs create mode 100644 src/common/UITestAutomation.Next/Element/CheckBox.cs create mode 100644 src/common/UITestAutomation.Next/Element/ComboBox.cs create mode 100644 src/common/UITestAutomation.Next/Element/Custom.cs create mode 100644 src/common/UITestAutomation.Next/Element/Element.cs create mode 100644 src/common/UITestAutomation.Next/Element/NavigationViewItem.cs create mode 100644 src/common/UITestAutomation.Next/Element/Pane.cs create mode 100644 src/common/UITestAutomation.Next/Element/RadioButton.cs create mode 100644 src/common/UITestAutomation.Next/Element/Slider.cs create mode 100644 src/common/UITestAutomation.Next/Element/Tab.cs create mode 100644 src/common/UITestAutomation.Next/Element/TextBlock.cs create mode 100644 src/common/UITestAutomation.Next/Element/TextBox.cs create mode 100644 src/common/UITestAutomation.Next/Element/Thumb.cs create mode 100644 src/common/UITestAutomation.Next/Element/ToggleSwitch.cs create mode 100644 src/common/UITestAutomation.Next/Element/Window.cs create mode 100644 src/common/UITestAutomation.Next/ElevationHelper.cs create mode 100644 src/common/UITestAutomation.Next/EnvironmentConfig.cs create mode 100644 src/common/UITestAutomation.Next/FRAMEWORK-PARITY-PLAN.md create mode 100644 src/common/UITestAutomation.Next/KeyboardHelper.cs create mode 100644 src/common/UITestAutomation.Next/ModuleConfigData.cs create mode 100644 src/common/UITestAutomation.Next/MonitorInfo.cs create mode 100644 src/common/UITestAutomation.Next/MouseHelper.cs create mode 100644 src/common/UITestAutomation.Next/ScreenCapture.cs create mode 100644 src/common/UITestAutomation.Next/ScreenRecording.cs create mode 100644 src/common/UITestAutomation.Next/Session.cs create mode 100644 src/common/UITestAutomation.Next/SessionHelper.cs create mode 100644 src/common/UITestAutomation.Next/SettingsConfigHelper.cs create mode 100644 src/common/UITestAutomation.Next/UITestAutomation.Next.csproj create mode 100644 src/common/UITestAutomation.Next/UITestBase.cs create mode 100644 src/common/UITestAutomation.Next/WinappCli.cs create mode 100644 src/common/UITestAutomation.Next/WindowControl.cs create mode 100644 src/common/UITestAutomation.Next/WindowHelper.cs create mode 100644 src/common/UITestAutomation.Next/Windows.cs create mode 100644 src/modules/colorPicker/ColorPicker.UITests/AssemblyInfo.cs create mode 100644 src/modules/colorPicker/ColorPicker.UITests/ColorPicker.UITests.csproj create mode 100644 src/modules/colorPicker/ColorPicker.UITests/ColorPickerEndToEndTests.cs rename src/modules/colorPicker/{UITest-ColorPicker => ColorPicker.UITests}/ColorPickerUITest.md (100%) delete mode 100644 src/modules/colorPicker/UITest-ColorPicker/ColorPickerUITest.cs delete mode 100644 src/modules/colorPicker/UITest-ColorPicker/UITest-ColorPicker.csproj create mode 100644 src/settings-ui/Settings.UITests/Settings.UITests.csproj create mode 100644 src/settings-ui/Settings.UITests/SettingsNavigationSmokeTests.cs rename src/settings-ui/{UITest-Settings => Settings.UITests}/SettingsTests.md (100%) diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 038cc94539..b265378f1d 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -2085,6 +2085,8 @@ wifi wikimedia wikipedia winapi +winapp +winappcli winappsdk windir WINDOWCREATED diff --git a/.pipelines/InstallWinAppCli.ps1 b/.pipelines/InstallWinAppCli.ps1 new file mode 100644 index 0000000000..e9a931a6e0 --- /dev/null +++ b/.pipelines/InstallWinAppCli.ps1 @@ -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)." +} diff --git a/.pipelines/v2/templates/job-build-project.yml b/.pipelines/v2/templates/job-build-project.yml index 8ca7a4ef50..3707cd662c 100644 --- a/.pipelines/v2/templates/job-build-project.yml +++ b/.pipelines/v2/templates/job-build-project.yml @@ -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)' diff --git a/.pipelines/v2/templates/job-test-project.yml b/.pipelines/v2/templates/job-test-project.yml index 0112738499..e657868f70 100644 --- a/.pipelines/v2/templates/job-test-project.yml +++ b/.pipelines/v2/templates/job-test-project.yml @@ -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() diff --git a/AGENTS.md b/AGENTS.md index df5a43265f..c1afb34edc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/Cpp.Build.props b/Cpp.Build.props index 48974c794e..1839a2aaf2 100644 --- a/Cpp.Build.props +++ b/Cpp.Build.props @@ -66,7 +66,10 @@ stdcpplatest false - _SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING;_UNICODE;UNICODE;%(PreprocessorDefinitions) + + _SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING;_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS;_UNICODE;UNICODE;%(PreprocessorDefinitions) Guard ProgramDatabase diff --git a/PowerToys.slnx b/PowerToys.slnx index 3d9897d924..aaf942d528 100644 --- a/PowerToys.slnx +++ b/PowerToys.slnx @@ -9,11 +9,11 @@ - + - + @@ -54,10 +54,14 @@ + + + + - + @@ -190,6 +194,10 @@ + + + + @@ -200,11 +208,11 @@ - + - + @@ -718,11 +726,11 @@ - + - + @@ -1099,6 +1107,10 @@ + + + + @@ -1130,14 +1142,14 @@ + + - - diff --git a/doc/devdocs/tools/fuzzingtesting.md b/doc/devdocs/tools/fuzzingtesting.md index 668be2e689..4af97be358 100644 --- a/doc/devdocs/tools/fuzzingtesting.md +++ b/doc/devdocs/tools/fuzzingtesting.md @@ -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\\**" ] } } diff --git a/src/Common.Dotnet.FuzzTest.props b/src/Common.Dotnet.FuzzTest.props index c9d94c7fff..a88776418a 100644 --- a/src/Common.Dotnet.FuzzTest.props +++ b/src/Common.Dotnet.FuzzTest.props @@ -1,11 +1,14 @@ - + - net8.0-windows10.0.26100.0 + net10.0-windows10.0.26100.0 + + + + Library + net10.0-windows10.0.26100.0 + enable + enable + + true + false + Microsoft.PowerToys.UITest.Next + Microsoft.PowerToys.UITest.Next + + + + + + + diff --git a/src/common/UITestAutomation.Next/UITestBase.cs b/src/common/UITestAutomation.Next/UITestBase.cs new file mode 100644 index 0000000000..6e1c1db4cb --- /dev/null +++ b/src/common/UITestAutomation.Next/UITestBase.cs @@ -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; + +/// +/// Base class for the next-generation PowerToys UI tests. Engine is winappcli — every UI call +/// shells out to winapp.exe. No WinAppDriver, no Selenium, no third-party NuGet packages. +/// +/// +/// +/// Drop-in shape replacement for the existing Microsoft.PowerToys.UITest.UITestBase: +/// inherit, pass a , and use Session / Find<T> in tests. +/// +/// +/// Test Explorer integration is automatic — MSTest's [TestClass] / [TestInitialize] / +/// [TestCleanup] plus the Microsoft.Testing.Platform runner (enabled repo-wide in +/// Directory.Build.props) are everything Test Explorer and dotnet test need. +/// +/// +[TestClass] +public class UITestBase : IDisposable +{ + /// + /// Lazy one-shot probe for winapp.exe. Runs the first time any UITest in the + /// process initializes — the cost is one extra winapp --version call per test run. + /// + private static readonly Lazy 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!; + + /// + /// PowerToys processes killed before every test so each run starts from a clean desktop state + /// (mirrors the legacy harness's CloseOtherApplications). 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. + /// + protected virtual IReadOnlyList StaleProcessNames { get; } = new[] + { + "PowerToys", + "PowerToys.Settings", + "PowerToys.FancyZonesEditor", + }; + + /// + /// When a derived class overrides this to true, the module is launched once for the whole + /// class and the same window is reused across every test method (no per-test relaunch or + /// desktop hygiene). The framework still captures failure media per test and stops the scope once + /// the class finishes. Default false — each test gets an isolated launch + teardown. + /// + protected virtual bool ReuseScopeAcrossTests => false; + + /// Module whose window the test drives. + /// Optional fixed window size applied once the window appears. + /// + /// When non-null, exactly these modules are enabled (and every other listed module disabled) in + /// the global settings.json before the runner launches — a deterministic module baseline. + /// Leave null to launch against whatever state settings.json already holds. + /// + 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(); + } + + /// + /// Stop a class-shared scope (see ) 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. + /// + [ClassCleanup(InheritanceBehavior.BeforeEachDerivedClass)] + public static void StopSharedScope() + { + try + { + keepAliveHelper?.StopIfStarted(); + } + catch + { + } + + keepAliveHelper = null; + keepAliveOwner = null; + } + + /// + /// 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 + /// failure path (where [TestCleanup] won't fire) and . + /// + 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 + { + } + } + } + + /// + /// 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 + /// finally) or never appeared (an init failure) — unlike winappcli's --capture-screen, + /// which requires a live target window. When the session window is still live, a winapp + /// window/overlay shot is added too. Best-effort. + /// + 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 + { + } + } + } + + /// + /// Bring the desktop to a known state before launching: minimize every window, dismiss any + /// lingering popup with Esc, and kill the stale PowerToys processes in + /// . Best-effort — never blocks a test from starting. + /// + 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. + } + } + + /// Apply the constructor's to the resolved window, if any. + 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); + } + + /// + /// Force a clean restart of the scope (kill + relaunch + rebind to the fresh window), re-seeding + /// the module baseline first. Equivalent to the legacy RestartScopeExe; assigns and returns + /// the new . + /// + /// + /// Modules to enable before relaunch. When null, the baseline passed to the constructor (if any) + /// is re-applied so the restart stays deterministic. + /// + 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) --------------------------------------------------- + + /// Start the 1s screenshot timer and FFmpeg screen recording. Best-effort. + 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. + } + } + + /// Stop the screenshot timer and finalize the recording. Best-effort. + 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 + { + } + } + } + + /// + /// Copy PowerToys *.log files (from both %LocalAppData% and %LocalAppDataLow%) + /// into the test results so a failed CI run carries the module logs. + /// + 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); + } + } + + /// Find an element on the session's window. Shortcut for Session.Find<T>. + protected T Find(By by, int timeoutMS = 5000) + where T : Element, new() => Session.Find(by, timeoutMS); + + /// Find an element by Name. Shortcut for Session.Find<T>(By.Name(name)). + protected T Find(string name, int timeoutMS = 5000) + where T : Element, new() => Session.Find(By.Name(name), timeoutMS); + + public void Dispose() + { + if (disposed) + { + return; + } + + disposed = true; + screenshotTimer?.Dispose(); + screenRecording?.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/common/UITestAutomation.Next/WinappCli.cs b/src/common/UITestAutomation.Next/WinappCli.cs new file mode 100644 index 0000000000..04b768928b --- /dev/null +++ b/src/common/UITestAutomation.Next/WinappCli.cs @@ -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; + +/// +/// Thin wrapper around the winappcli executable. Every public method shells out to +/// winapp.exe, captures stdout/stderr/exit-code, and (where requested) parses the +/// --json envelope using . +/// +/// +/// +/// Engine prerequisites: install once with winget install Microsoft.winappcli. The CLI +/// lands on PATH at %LOCALAPPDATA%\Microsoft\WindowsApps\winapp.exe. +/// +/// +/// All invocations set WINAPP_CLI_TELEMETRY_OPTOUT=1 and disable update checks via +/// WINAPP_CLI_UPDATE_CHECK=0 so the CLI never injects extra lines into stdout. +/// +/// +public static class WinappCli +{ + /// Stable hint surfaced when the CLI is missing or fails — used in all error paths. + 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 ExecutablePath = new(ResolveExecutable); + + /// + /// Per-invocation guard. A hung winapp.exe 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 -t wait extend this; see . + /// + private static readonly TimeSpan DefaultInvokeTimeout = TimeSpan.FromSeconds(60); + + public sealed record Result(int ExitCode, string StdOut, string StdErr, IReadOnlyList Args) + { + public bool Success => ExitCode == 0; + + /// + /// One-line, assertion-friendly description of a failed invocation. Format: + /// "winapp ui invoke X -w 12345 -> exit 1; stderr: not found". Falls back to + /// stdout if stderr is empty. + /// + 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); + } + } + } + + /// + /// Returns true when winapp.exe resolves to a real file AND responds to + /// --version. Use from [ClassInitialize] / [AssemblyInitialize] / + /// to fail the entire suite once with a clear install hint, + /// instead of letting every test produce its own opaque process-launch failure. + /// + public static bool IsAvailable() + { + if (!TryResolveExecutable(out _)) + { + return false; + } + + try + { + return Invoke("--version").Success; + } + catch + { + return false; + } + } + + /// Run winapp.exe with the given arguments. Returns exit code and captured streams. + 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); + } + + /// + /// Process-guard budget for one invocation. Defaults to ; when the + /// command carries its own -t/--timeout wait in milliseconds (e.g. wait-for), the + /// guard is extended past that wait plus a grace margin so a legitimate long wait isn't killed early. + /// + 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; + } + + /// Run and throw if the exit code is non-zero. Use for fire-and-forget commands. + public static Result InvokeAssertSuccess(params string[] args) + { + var r = Invoke(args); + Assert.AreEqual(0, r.ExitCode, r.DescribeFailure()); + return r; + } + + /// Run a --json command and return the parsed root . + 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(); + } + + /// + /// Locate winapp.exe without throwing or asserting. uses + /// this to probe quietly; the lazy wraps it for the + /// first real call. + /// + 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; + } + + /// + /// Start winapp.exe, retrying the transient launch failure that affects Windows App + /// Execution Aliases. The winapp.exe found on PATH is the reparse-point stub under + /// %LOCALAPPDATA%\Microsoft\WindowsApps; launching an alias through CreateProcess + /// (UseShellExecute = false) intermittently throws with + /// ERROR_INVALID_PARAMETER (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. + /// + 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); + } +} diff --git a/src/common/UITestAutomation.Next/WindowControl.cs b/src/common/UITestAutomation.Next/WindowControl.cs new file mode 100644 index 0000000000..bafa740a15 --- /dev/null +++ b/src/common/UITestAutomation.Next/WindowControl.cs @@ -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; + +/// +/// Fault-tolerant window cleanup helpers. Every method swallows exceptions and returns a +/// boolean — they're designed for test finally blocks where a cleanup failure must +/// never mask the real test failure. +/// +/// +/// winappcli has no close verb, so closing goes through Win32 WM_CLOSE +/// (graceful) with an optional process-kill fallback. Focus uses SetForegroundWindow +/// against the HWND that already discovers. +/// +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; + + /// + /// Send WM_CLOSE to every window owned by and wait + /// up to for them to disappear. Tolerant: returns false on + /// any failure instead of throwing. + /// + 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; + } + } + + /// + /// Send WM_CLOSE to every window matching 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). + /// + public static bool TryCloseByApp(string appNameOrPid, Func 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; + } + } + + /// + /// Bring the first window owned by to the foreground. + /// If the window is minimized it's first restored. Tolerant. + /// + 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; + } + } + + /// + /// Cleanup convenience: close every window of (if any) and + /// bring to the foreground. Mirrors the pattern in the legacy + /// TestHelper.CleanupTest (close target window → re-attach to Settings) but does + /// not throw, so it's safe to call from a test finally. + /// + public static void SafeCloseAndFocus(string closeApp, string focusApp, int closeTimeoutMS = 5_000) + { + TryCloseByApp(closeApp, closeTimeoutMS); + TryFocusByApp(focusApp); + } + + /// + /// Force-terminate every process whose name contains . + /// Use only as a last resort when failed and the + /// module's window must be gone before the next test starts. + /// + 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; + } + } + + /// + /// Force-terminate every process whose name exactly equals + /// (no extension, case-insensitive — the form accepts). + /// Prefer this over 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. + /// + 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. + } + } +} diff --git a/src/common/UITestAutomation.Next/WindowHelper.cs b/src/common/UITestAutomation.Next/WindowHelper.cs new file mode 100644 index 0000000000..86e20e629a --- /dev/null +++ b/src/common/UITestAutomation.Next/WindowHelper.cs @@ -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; + +/// Preset window sizes for . +public enum WindowSize +{ + /// No size change. + UnSpecified, + + /// 640 x 480. + Small, + + /// 480 x 640. + Small_Vertical, + + /// 1024 x 768. + Medium, + + /// 768 x 1024. + Medium_Vertical, + + /// 1920 x 1080. + Large, + + /// 1080 x 1920. + Large_Vertical, +} + +/// +/// 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 (; ). +/// +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); + + /// True when any UIA-visible window's title contains (CLI-based). + public static bool IsWindowOpen(string titleContains) => + WindowsFinder.ListAll().Any(w => w.Title.Contains(titleContains, StringComparison.OrdinalIgnoreCase)); + + /// Resize a window to a preset (keeps its current position). + public static void SetWindowSize(IntPtr hWnd, WindowSize size) + { + var (w, h) = Dimensions(size); + if (w > 0 && h > 0) + { + SetMainWindowSize(hWnd, w, h); + } + } + + /// Resize a window to explicit width/height (keeps its current position). + public static void SetMainWindowSize(IntPtr hWnd, int width, int height) => + SetWindowPos(hWnd, IntPtr.Zero, 0, 0, width, height, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE); + + /// + /// 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. + /// + public static void MaximizeWindow(IntPtr hWnd) => ShowWindow(hWnd, SW_MAXIMIZE); + + /// (Left, Top, Right, Bottom) of the window in screen pixels. + 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); + } + + /// Center point of the window in screen pixels. + 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)); + } + + /// Primary display size in pixels. + public static (int Width, int Height) GetDisplaySize() => + (GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN)); + + /// Center of the primary display in pixels. + public static (int CenterX, int CenterY) GetScreenCenter() + { + var (w, h) = GetDisplaySize(); + return (w / 2, h / 2); + } + + /// Color of the on-screen pixel at (, ) via GDI. + 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); + } + } + + /// On-screen pixel color at (, ) as #RRGGBB. + 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), + }; +} diff --git a/src/common/UITestAutomation.Next/Windows.cs b/src/common/UITestAutomation.Next/Windows.cs new file mode 100644 index 0000000000..be4c0adaff --- /dev/null +++ b/src/common/UITestAutomation.Next/Windows.cs @@ -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; + +/// +/// Static helpers for discovering and attaching to windows that aren't the test's primary scope. +/// +/// +/// Most tests target one module's main window (handled by + ). +/// 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 +/// winapp ui list-windows --json to find/wait for those windows by process or title. +/// +public static class WindowsFinder +{ + public sealed record WindowInfo(long Hwnd, string Title, string ProcessName, int ProcessId, string ClassName, int Width, int Height); + + /// List all UIA-visible windows. + /// + /// NOTE: winappcli's unfiltered list-windows --json 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 with a process/PID filter when you need to see + /// those — winappcli returns them in the filtered form. + /// + public static IReadOnlyList ListAll() => Parse(WinappCli.Invoke("ui", "list-windows", "--json")); + + /// + /// List UIA-visible windows belonging to (process name substring or PID). + /// Uses winappcli's -a filter, which works around the bug where unfiltered + /// list-windows drops windows without a Win32 title. + /// + public static IReadOnlyList ListByApp(string appNameOrPid) => + Parse(WinappCli.Invoke("ui", "list-windows", "-a", appNameOrPid, "--json")); + + private static IReadOnlyList Parse(WinappCli.Result r) + { + if (!r.Success || string.IsNullOrEmpty(r.StdOut)) + { + return Array.Empty(); + } + + try + { + using var doc = JsonDocument.Parse(r.StdOut); + if (doc.RootElement.ValueKind != JsonValueKind.Array) + { + return Array.Empty(); + } + + var list = new List(); + 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(); + } + } + + /// + /// Poll until a window matching appears, or + /// elapses. Returns the window's wrapper on success. + /// + public static Session? WaitForWindow(Func 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; + } + + /// Convenience wrapper: wait for a window with the given title substring. + public static Session? WaitForWindowByTitle(string titleContains, int timeoutMS = 10_000) + => WaitForWindow(w => w.Title.Contains(titleContains, StringComparison.OrdinalIgnoreCase), timeoutMS: timeoutMS); + + /// + /// Wait for any window owned by a process whose name contains . + /// Uses winappcli's -a filter under the hood so untitled windows (e.g. the ColorPicker + /// editor) are discoverable — the unfiltered list-windows drops those. + /// + 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; + } + + /// + /// Same as but filters with . + /// Use when the same process owns multiple windows (e.g. ColorPickerUI exposes both the + /// small picker overlay and the larger editor window). + /// + public static Session? WaitForWindowByApp( + string appNameOrPid, + Func 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; + } +} diff --git a/src/common/UITestAutomation/ModuleInfo.cs b/src/common/UITestAutomation/ModuleInfo.cs index 35add0e0d2..42be03da5c 100644 --- a/src/common/UITestAutomation/ModuleInfo.cs +++ b/src/common/UITestAutomation/ModuleInfo.cs @@ -30,12 +30,30 @@ namespace Microsoft.PowerToys.UITest /// public string GetDevelopmentPath() { + // The test assembly normally lives in \tests\\\, 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 (\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. ...\\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); } /// diff --git a/src/common/UITestAutomation/SessionHelper.cs b/src/common/UITestAutomation/SessionHelper.cs index cfd0249067..6e718203d3 100644 --- a/src/common/UITestAutomation/SessionHelper.cs +++ b/src/common/UITestAutomation/SessionHelper.cs @@ -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() diff --git a/src/modules/colorPicker/ColorPicker.UITests/AssemblyInfo.cs b/src/modules/colorPicker/ColorPicker.UITests/AssemblyInfo.cs new file mode 100644 index 0000000000..63be32da80 --- /dev/null +++ b/src/modules/colorPicker/ColorPicker.UITests/AssemblyInfo.cs @@ -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] diff --git a/src/modules/colorPicker/ColorPicker.UITests/ColorPicker.UITests.csproj b/src/modules/colorPicker/ColorPicker.UITests/ColorPicker.UITests.csproj new file mode 100644 index 0000000000..6059931ad4 --- /dev/null +++ b/src/modules/colorPicker/ColorPicker.UITests/ColorPicker.UITests.csproj @@ -0,0 +1,42 @@ + + + + + + Exe + net10.0-windows10.0.26100.0 + enable + enable + false + false + Microsoft.ColorPicker.UITests + ColorPicker.UITests + + + true + true + false + + + false + + + + + $(RepoRoot)$(Platform)\$(Configuration)\tests\ColorPicker.UITests\ + + + + + + + + + + diff --git a/src/modules/colorPicker/ColorPicker.UITests/ColorPickerEndToEndTests.cs b/src/modules/colorPicker/ColorPicker.UITests/ColorPickerEndToEndTests.cs new file mode 100644 index 0000000000..1faf0c18d2 --- /dev/null +++ b/src/modules/colorPicker/ColorPicker.UITests/ColorPickerEndToEndTests.cs @@ -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; + +/// +/// 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 PowerToys.ColorPickerUI exits. +/// 3. Toggle it back ON and verify PowerToys.ColorPickerUI respawns. +/// 4. Read the activation shortcut from the page's ShortcutControl (the EditButton +/// exposes HotkeySettings.ToString() via AutomationProperties.HelpText). +/// 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. +/// +/// +/// The overlay's visible ColorTextBlock has AutomationProperties.Name="{Binding ColorName}" +/// 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 ColorText with +/// AutomationId="ColorHexAutomationPeer" — a test-only UIA hook that lets us read the +/// actually-displayed HEX value without affecting the visual layout or accessibility UX. +/// +[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(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(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