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 @@
stdcpplatestfalse
- _SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING;_UNICODE;UNICODE;%(PreprocessorDefinitions)
+
+ _SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING;_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS;_UNICODE;UNICODE;%(PreprocessorDefinitions)GuardProgramDatabase
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