winui-ui-testing
Automated UI testing for Windows desktop apps — generate a batch test script with the `winapp ui` UI Automation harness (WinApp CLI 0.7+), run all tests in one pass, read results. Covers assertions, interactions, keyboard/touch/pen input, file pickers, dialogs, persistence, accessibility, and screenshot/video capture for Win32, WPF, WinForms, and WinUI 3, in Windows Sandbox or locally.
How do I install this agent skill?
npx skills add https://github.com/microsoft/win-dev-skills --skill winui-ui-testingIs this agent skill safe to install?
- Gen Agent Trust Hubpass
This skill provides a framework for automated UI testing of Windows applications. It uses a specialized command-line utility to interact with UI elements and generate structured test reports. The analysis found no security risks, as the skill follows standard automation and testing practices.
- Socketpass
No alerts
- Snykpass
Risk: LOW · No issues
What does this agent skill do?
Scope and execution boundary
winapp ui uses Windows UI Automation (UIA), so the AutomationId-based workflow works with any Windows desktop framework, packaged or unpackaged. Skip WinUI-specific binding/dialog advice for other frameworks.
Windows Sandbox keeps synthetic input off the user's desktop; target selection is in Step 1. Discover the installed contract with winapp run --help, winapp target --help, and winapp ui <verb> --help. Use --on sandbox, not --sandbox or a top-level sandbox command. Do not enable Windows features or launch an app without the task's permission.
- Prerequisites and enablement: see
winui-setup. - Project builds/publishes execute on the host; deployment, app launch, and
ui --on sandboxexecute in the guest. Run the batch script below on the host, not insidetarget exec(which would double-route). - Real input and capture require an unlocked host and a connected, nonminimized Sandbox client. Tree inspection may work while input cannot; a readable tree is not an input-readiness check.
winapp target snapshot sandbox --jsonis a read-only readiness query: it neither starts nor reconnects a guest. Use it to diagnose readiness rather than probing the user's desktop.- The persistent guest is shared, not isolation between mutually untrusted workflows. Coordinate with other users; there is no supported
--unique-identityoption. Use separate machines for mutually untrusted work. - Guest
--debug-outputsupports packaged apps only.
Approach and command discovery
Prefer a single scripted batch over a long series of interactive calls. If you wrote the app, use its XAML/source AutomationIds directly; otherwise inspect its current tree and read source for hidden/lazy flyouts and dialogs. Either way, validate a real window/tree before passing an accessibility audit.
Core verbs: list-windows, inspect, search, get-property, get-value, wait-for; invoke, click, set-value, focus, scroll-into-view; send-keys, hover, drag, touch, pen; screenshot, record. Use each verb's --help for selectors and options, rather than guessing from this short reference.
Step 1: Select a target, then keep the PID and target together
Prefer sandbox when Windows Sandbox is available; otherwise tell the user and choose local. If the user explicitly requested Windows Sandbox and it is unavailable, stop and point them to the enablement steps in winui-setup. A stopped guest does not prove unavailability: target snapshot can report no running target while the feature is enabled. App build/test failures are not Sandbox unavailability. The same policy applies to diagnostics: unpackaged apps can't use guest --debug-output, so diagnose them locally unless Sandbox was explicitly requested.
Pass the selected target to the template: it launches with winapp run . --on sandbox --detach --json for a guest, or omits --on sandbox for local execution. Reuse an already-running app only when its captured target matches the selected target and the guest has not been recreated. Never pass a guest PID to default-host winapp ui. If a target becomes unavailable after selection, report it and select again under the same policy; the script itself never retries in another target.
The run JSON includes ProcessId, ProcessScope, and UiTargetArgs (PascalCase). In Windows Sandbox, UiTargetArgs is --on sandbox -a <guestPID>: preserve both parts, not just the number. For an explicitly requested unpackaged guest run, the launch result has no app PID: launch separately, find the intended app by process/title with target-wide winapp ui list-windows --on sandbox --json, then pass its PID with -Target sandbox -AppPid <PID> -AppScope sandbox. Never use the containment PID. A fresh guest invalidates old PIDs/HWNDs.
Step 2: Write the host-side batch
Create ui-tests.ps1, replace the sample AutomationIds/expected values with the app's requirements, and add the relevant examples below inside the outer try, before its finally. The two small command helpers only route and check CLI calls; they do not implement a target manager. Use them for every added command so an earlier native failure cannot be hidden by a later success.
# ui-tests.ps1
param(
[Parameter(Mandatory)][ValidateSet('sandbox', 'local')][string]$Target,
[int]$AppPid = 0,
[ValidateSet('sandbox', 'local')][string]$AppScope,
[ValidateNotNullOrEmpty()][string]$WorkflowId = [guid]::NewGuid().ToString('N'),
[string]$ArtifactDirectory = (Join-Path '.\ui-test-artifacts' ([guid]::NewGuid().ToString('N')))
)
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $false
$ScopeArgs = @(if ($Target -eq 'sandbox') { '--on'; 'sandbox' })
$previousWorkflowId = $env:WINAPP_UI_WORKFLOW_ID
$env:WINAPP_UI_WORKFLOW_ID = $WorkflowId
$results = [Collections.Generic.List[object]]::new()
$screenshots = [Collections.Generic.List[string]]::new()
$uiStarted = $false
function Invoke-WinAppChecked {
param([string[]]$Arguments)
$global:LASTEXITCODE = $null
$output = @(& winapp @Arguments)
$code = $global:LASTEXITCODE
if ($null -eq $code -or $code -ne 0) {
throw "winapp $($Arguments -join ' ') failed (exit $code). $($output -join "`n")"
}
$output
}
function Invoke-Ui {
param([string[]]$Arguments, [string]$Window)
$selector = if ($Window) { @('-w', $Window) } else { @('-a', "$AppPid") }
Invoke-WinAppChecked (@('ui') + $Arguments + $ScopeArgs + $selector)
}
function Test-UI {
param([string]$Name, [scriptblock]$Script)
try {
& $Script | Out-Null
$results.Add(@{ name = $Name; status = 'PASS' })
} catch {
$results.Add(@{ name = $Name; status = 'FAIL'; detail = "$_" })
}
}
function Save-Screenshot {
param([string]$Name)
$path = Join-Path $ArtifactDirectory $Name
if (Test-Path -LiteralPath $path) { throw "Choose a new evidence path: $path" }
Invoke-Ui @('screenshot', '--output', $path) -Window $hwnd | Out-Null
if (-not (Test-Path -LiteralPath $path -PathType Leaf) -or (Get-Item -LiteralPath $path).Length -eq 0) {
throw "Screenshot was not delivered to the host: $path"
}
$screenshots.Add($path)
}
try {
$ArtifactDirectory = [IO.Path]::GetFullPath($ArtifactDirectory)
New-Item -ItemType Directory -Force -Path $ArtifactDirectory | Out-Null
if ($AppPid -lt 0) { throw 'AppPid must be positive, or zero to launch.' }
if ($AppPid -gt 0) {
if ($AppScope -ne $Target) {
throw 'Reusing a PID requires matching -AppScope and -Target from the same live target.'
}
} else {
$launch = Invoke-WinAppChecked (@('run', '.') + $ScopeArgs + @('--detach', '--json')) |
ConvertFrom-Json
if ($launch.Error -or -not $launch.ProcessId -or [int]$launch.ProcessId -le 0) {
throw "No app ProcessId. For unpackaged guest runs, discover the app in that target and rerun with -AppPid and -AppScope. $($launch.Error)"
}
$AppPid = [int]$launch.ProcessId
if ($Target -eq 'sandbox' -and
($launch.ProcessScope -ne 'sandbox' -or $launch.UiTargetArgs -ne "--on sandbox -a $AppPid")) {
throw 'Launch response is missing the expected Sandbox ProcessScope / UiTargetArgs pair.'
}
if ($Target -eq 'local' -and $launch.ProcessScope -and $launch.ProcessScope -ne 'local') {
throw 'Launch response belongs to a different target.'
}
}
$uiStarted = $true
$windows = @(Invoke-Ui @('list-windows', '--json') | ConvertFrom-Json)
$main = $windows | Where-Object { $_.hwnd -and $_.hwnd -ne '0' -and $_.title -ne 'PopupHost' } |
Select-Object -First 1
if (-not $main) { throw 'No app window found in the selected target; rediscover after a fresh guest.' }
$hwnd = [string]$main.hwnd
Test-UI 'Initial screenshot delivered' { Save-Screenshot '01-initial.png' }
Test-UI 'Home exists' { Invoke-Ui @('wait-for', 'NavHome', '-t', '3000') }
Test-UI 'Navigate to Settings' {
Invoke-Ui @('invoke', 'NavSettings')
Invoke-Ui @('wait-for', 'TxtUserName', '-t', '3000')
}
Test-UI 'Save username' {
Invoke-Ui @('set-value', 'TxtUserName', 'TestUser')
Invoke-Ui @('invoke', 'BtnSave')
Invoke-Ui @('wait-for', 'TxtUserName', '--value', 'TestUser', '-t', '2000')
}
Test-UI 'Default values' {
Invoke-Ui @('wait-for', 'CmbTheme', '--value', 'System default', '-t', '2000')
Invoke-Ui @('wait-for', 'TglLogging', '--value', 'Off', '-t', '2000')
}
Test-UI 'App controls have AutomationIds' {
$inspection = Invoke-Ui @('inspect', '--interactive', '--json') -Window $hwnd | ConvertFrom-Json
function Get-Elements($nodes) {
foreach ($node in $nodes) {
if ($null -ne $node) { $node; Get-Elements $node.children }
}
}
$allElements = @(Get-Elements @($inspection.windows | ForEach-Object { $_.elements }))
if (-not $allElements.Count) { throw 'Inspection returned no elements; not an accessibility PASS.' }
$appElements = @($allElements | Where-Object {
$_.type -match 'Button|TextBox|ComboBox|CheckBox|ToggleSwitch|TabItem|Edit' -and
$_.name -notmatch 'Minimize|Maximize|Close|System' -and
$_.className -notmatch 'PickerHost|#32770|CabinetWClass'
})
if (-not $appElements.Count) { throw 'No app controls were audited; adjust selectors rather than passing.' }
$missingId = @($appElements | Where-Object { -not $_.automationId })
if ($missingId.Count) {
throw "Missing AutomationIds: $(($missingId | ForEach-Object { "$($_.type) '$($_.name)'" }) -join ', ')"
}
}
Test-UI 'Final screenshot delivered' { Save-Screenshot '02-settings.png' }
} catch {
$results.Add(@{ name = 'Target setup / test execution'; status = 'FAIL'; detail = "$_" })
} finally {
try {
if ($uiStarted) { Invoke-WinAppChecked (@('ui', 'yield') + $ScopeArgs) | Out-Null }
} catch {
$results.Add(@{ name = 'Release UI workflow'; status = 'FAIL'; detail = "$_" })
} finally {
$env:WINAPP_UI_WORKFLOW_ID = $previousWorkflowId
}
}
$failed = @($results | Where-Object { $_.status -eq 'FAIL' }).Count
$report = [ordered]@{
target = $Target; appPid = $AppPid; workflowId = $WorkflowId
passed = $results.Count - $failed; failed = $failed
results = @($results.ToArray()); screenshots = @($screenshots.ToArray())
}
$report | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath '.\test-results.json' -Encoding utf8
Write-Host "Passed: $($report.passed) | Failed: $failed | Results: .\test-results.json"
if ($failed) { exit 1 }
exit 0
Within a test, throw, not exit; the script fails on CLI errors, empty inspections, and undelivered evidence. Screenshots target the known main HWND so each lands at one exact host path; use --capture-screen when popups must be included.
Step 3: Run, read, and visually verify
# Preferred when Windows Sandbox is available:
.\ui-tests.ps1 -Target sandbox
if ($LASTEXITCODE -ne 0) { throw 'UI batch failed; read test-results.json.' }
$report = Get-Content -LiteralPath '.\test-results.json' -Raw | ConvertFrom-Json
$report.screenshots
For a captured, still-live guest PID, use .\ui-tests.ps1 -Target sandbox -AppPid 1234 -AppScope sandbox. For a local run (see Step 1), use .\ui-tests.ps1 -Target local, optionally with a local PID and -AppScope local. Never reuse a guest PID locally or switch targets to make failing tests pass.
View each host PNG listed in test-results.json with the image-viewing tool. UIA PASS cannot detect clipping, overlap, incorrect theming, or content bleeding past its container. Fail visual review for unintended scrollbars, unintended ellipses, clipped hero/right-edge controls, overlapping rows, unbalanced whitespace, cramped/vast spacing, incorrect Light/Dark/High Contrast, or missing focus/hover/error states. Capture meaningful states immediately after their interactions, not just at the end.
If fixes are requested, batch-fix failures, rebuild/relaunch with the same target using the template (omit -AppPid to launch again), and capture the new PID. Maximum two fix-and-rerun cycles; then report remaining failures. Do not declare completion without the structured results and visual review.
Assertions and coverage
Write tests for every requested requirement. Use wait-for --value for TextBox/NumberBox values, RichEditBox text, ComboBox selection, toggle/checkbox On/Off, and TextBlock/label text. --contains matches a substring; --gone waits for disappearance. Use -p IsEnabled --value True only for a specific property. Invoke buttons/nav items, wait for page-specific elements, and test default/error/empty states.
The following examples are additions to the template, not independent shells. Invoke-Ui always supplies the chosen scope plus -a $AppPid, or scope plus -w $Window. For raw reads, pass --json and parse it; plain stdout can include advisory messages. Use scroll-into-view before asserting a virtualized item below the fold.
Test-UI 'Status and enabled state' {
Invoke-Ui @('wait-for', 'StatusBar', '--value', 'saved', '--contains', '-t', '3000')
Invoke-Ui @('wait-for', 'BtnSave', '-p', 'IsEnabled', '--value', 'True', '-t', '3000')
$value = Invoke-Ui @('get-value', 'TxtUserName', '--json') | ConvertFrom-Json
if ($value.text -ne 'TestUser') { throw 'Wrong username.' }
}
File pickers, flyouts, and ContentDialogs
Pickers run in a separate PickerHost process. Discover their HWND in the same target; -w changes the window selector, never the target. Replace the filename with a file that exists in the guest for Sandbox testing.
Test-UI 'Open file picker' {
Invoke-Ui @('invoke', 'BtnOpenFile')
Start-Sleep -Seconds 1
$allWindows = @(Invoke-WinAppChecked (@('ui', 'list-windows', '--json') + $ScopeArgs) | ConvertFrom-Json)
$pickers = @($allWindows | Where-Object { $_.hwnd -and [string]$_.ownerHwnd -eq $hwnd -and $_.title -match 'Open|Save' })
if ($pickers.Count -ne 1) { throw 'Expected one picker; inspect the selected target again.' }
$pickerHwnd = [string]$pickers[0].hwnd
Invoke-Ui @('inspect', '--interactive', '--json') -Window $pickerHwnd
Invoke-Ui @('set-value', 'FileNameControlHost', 'test.txt') -Window $pickerHwnd
Invoke-Ui @('invoke', 'Open') -Window $pickerHwnd
Invoke-Ui @('wait-for', 'StatusBar', '--value', 'opened', '--contains', '-t', '3000')
}
Test-UI 'Copy context menu' {
Invoke-Ui @('click', 'LstItems', '--right')
Invoke-Ui @('wait-for', 'MnuCopy', '-t', '2000')
Invoke-Ui @('invoke', 'MnuCopy')
Invoke-Ui @('wait-for', 'StatusText', '--value', 'Copied', '-t', '2000')
}
Test-UI 'Confirm deletion' {
Invoke-Ui @('invoke', 'BtnDelete')
Invoke-Ui @('wait-for', 'Primary', '-t', '2000')
Invoke-Ui @('invoke', 'Primary')
Invoke-Ui @('wait-for', 'Primary', '--gone', '-t', '3000')
}
For Save/Cancel pickers, substitute the appropriate action and assertion. MenuBar headers and flyout items use invoke and wait-for similarly. ContentDialogs live in the app window; Primary, Secondary, and Close are common selectors, but inspect the actual open dialog because custom AutomationIds are not guaranteed.
Keyboard, hover, drag, touch, and pen
send-keys --via send-input is required for accelerators and reliable per-character input in WinUI/WPF text controls. post-message is HWND-targeted but does not produce per-character KeyDown. --target focuses first; --verbatim types literal key names. System shortcuts require the CLI's explicit permission flags; do not use them as a workaround for desktop-readiness errors.
Test-UI 'Keyboard save' {
Invoke-Ui @('send-keys', 'hello world', '--target', 'TxtName', '--via', 'send-input')
Invoke-Ui @('send-keys', 'ctrl+s', '--via', 'send-input')
Invoke-Ui @('wait-for', 'StatusBar', '--value', 'saved', '--contains', '-t', '2000')
}
Test-UI 'Tooltip' {
Invoke-Ui @('hover', 'BtnInfo')
Invoke-Ui @('wait-for', 'InfoTooltip', '-t', '2000')
}
Test-UI 'Reorder items' {
Invoke-Ui @('drag', 'ItemA', 'ItemB')
Invoke-Ui @('wait-for', 'StatusBar', '--value', 'reordered', '--contains', '-t', '2000')
}
Test-UI 'Touch and ink input' {
Invoke-Ui @('touch', 'LstFeed', '-g', 'swipe', '--direction', 'up', '--distance', '400')
Invoke-Ui @('pen', 'InkCanvas', '--path', '50,50 120,80 200,60', '--pressure', '0.8')
Save-Screenshot '03-ink.png'
# Add the app-specific state assertion; successful injection alone is not functional proof.
}
Use winapp ui drag --help, winapp ui touch --help, and winapp ui pen --help for coordinates, long-press/dwell, pinch/stretch, pressure/tilt/eraser support. Read coordinates in the guest tree; host coordinates are not guest coordinates.
Recordings and workflow coordination
Test-UI 'Video delivered' {
$video = Join-Path $ArtifactDirectory 'flow.mp4'
if (Test-Path -LiteralPath $video) { throw 'Choose a new video evidence path.' }
Invoke-Ui @('record', '--duration-sec', '6', '--fps', '30', '--output', $video)
if (-not (Test-Path -LiteralPath $video -PathType Leaf) -or (Get-Item -LiteralPath $video).Length -eq 0) {
throw "Recording was not delivered to the host: $video"
}
}
Sandbox screenshot/record --output paths are host paths, automatically delivered when capture completes; do not pull them from guessed guest directories. Use fresh output paths and check the exit status and actual host files. --capture-screen can include popups outside the app window. For an interrupted recording, preserve stopReason, partialOutput, recoveryHint, and the reported partial/recovery paths; a nonempty partial file is not necessarily playable.
WinApp CLI arbitrates desktop-changing commands automatically. Without a workflow ID, each command releases its turn immediately; with one, the four-second grace covers tight script bursts, not agent reasoning. Use the same WINAPP_UI_WORKFLOW_ID for cooperating commands, including concurrent recording and interaction; independent flows need distinct IDs. The template accepts -WorkflowId for this purpose, sets it in its own process, restores the previous value, and calls winapp ui yield --on sandbox in finally. Fresh shell tool calls do not inherit changes from an earlier shell: set the same ID on every cooperating invocation. A separate concurrent recording process must also check its exit code/delivery, use the same scoped PID, and finish before the test runner yields. Do not start an independent-ID recording that blocks the interactions it is meant to capture. After a pause, inspect again and reopen menus/dialogs another workflow may have changed.
Guest persistence and arbitrary file transfer
Do not inspect host $env:LOCALAPPDATA to prove guest persistence. Check the actual app data file in the selected target. Replace the sample package family/path with the app's real storage location; unpackaged apps usually use an app-specific LocalAppData directory.
Test-UI 'Username persisted in selected target' {
$readSettings = @'
$ErrorActionPreference = 'Stop'
$file = Join-Path $env:LOCALAPPDATA 'Packages\YourPackageFamily\LocalState\settings.json'
if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { throw "Missing settings: $file" }
Get-Content -LiteralPath $file -Raw
'@
$json = if ($Target -eq 'sandbox') {
Invoke-WinAppChecked @('target', 'exec', 'sandbox', '--', 'powershell.exe', '-NoProfile', '-NonInteractive', '-Command', $readSettings)
} else {
& ([scriptblock]::Create($readSettings))
}
$settings = $json | ConvertFrom-Json
if ($settings.UserName -ne 'TestUser') { throw 'Saved username does not match.' }
}
For arbitrary setup/results files, use target exec / push / pull, not host filesystem guesses. Transfer guest paths are relative to snapshot workRoot, normally C:\WinApp\work, not absolute guest paths. The following optional additions assume a host setup.ps1 that creates Results under the guest work root:
Test-UI 'Guest setup and result transfer' {
if ($Target -ne 'sandbox') { throw 'This transfer test requires Sandbox.' }
$snapshot = Invoke-WinAppChecked @('target', 'snapshot', 'sandbox', '--json') | ConvertFrom-Json
if (-not $snapshot.workRoot) { throw 'No live guest workRoot; fix readiness first.' }
Invoke-WinAppChecked @('target', 'push', 'sandbox', '.\setup.ps1', 'Setup\setup.ps1')
$guestSetup = $snapshot.workRoot.TrimEnd('\') + '\Setup\setup.ps1'
Invoke-WinAppChecked @('target', 'exec', 'sandbox', '--', 'powershell.exe', '-NoProfile', '-File', $guestSetup)
Invoke-WinAppChecked @('target', 'pull', 'sandbox', 'Results', (Join-Path $ArtifactDirectory 'results'))
}
Keep the guest alive while recovering failed delivery. Preserve evidence/recovery paths and app data needed for diagnosis before any user-consented shutdown; never stop all Sandboxes as routine cleanup.
Binding and selector gotchas
set-valuedoes not commit default WinUIx:Bind TwoWayTextBox bindings until focus changes. PreferUpdateSourceTrigger=PropertyChangedin the app; otherwiseinvokeSave orfocusanother control before checking persistence.- RichEditBox/RichTextBox generally need
send-keys --via send-input, not UIAset-value. - Use
$AppPid, never PowerShell's read-only automatic$Pid. - A picker HWND needs
-wand the same target scope. A fresh guest invalidates both HWNDs and PIDs; do not recycle either.
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/microsoft/win-dev-skills/winui-ui-testing">View winui-ui-testing on skillZs</a>