关于 winui-ui-testing
使用 WinApp CLI 和 Windows UI Automation 检查并操作桌面应用的测试技能,涵盖本机或 Windows Sandbox 目标、脚本断言及捕获结果的视觉复核。
主要功能
- 01检查并查询 UI Automation 树
- 02通过输入和控件操作交互
- 03在本机或 Windows Sandbox 测试
- 04断言数值与应用状态
- 05捕获屏幕截图和录制
- 06复核 UIA 之外的视觉状态
适用场景
- 01用脚本断言测试桌面工作流
- 02检查对话框、文件选择器和弹出菜单
- 03捕获界面状态以供视觉复核
- 04在 Windows Sandbox 中运行 UI 测试
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ microsoft/ win-dev-skills --skill winui-ui-testing安装后在 Claude Code 中输入/winui-ui-testing确认已加载。
详细介绍
范围和执行边界
winapp ui 使用 Windows UI Automation(UIA),因此基于 AutomationId 的工作流适用于任何 Windows 桌面框架,无论应用是否打包。对于其他框架,请跳过 WinUI 专用的绑定/对话框建议。
Windows Sandbox 可避免合成输入影响用户的桌面;目标选择步骤见第 1 步。使用 winapp run --help、winapp target --help 和 winapp ui <verb> --help 查明已安装版本支持的命令。使用 --on sandbox,不要使用 --sandbox 或顶层 sandbox 命令。未经任务授权,不要启用 Windows 功能或启动应用。
- 先决条件和启用方法:参见
winui-setup。 - 项目构建/发布在主机上执行;部署、应用启动和
ui --on sandbox在来宾中执行。请在主机上运行下方批处理脚本,而不是在target exec中运行(否则会重复路由)。 - 真实输入和屏幕捕获要求主机处于解锁状态,并且 Sandbox 客户端已连接且未最小化。树检查可能正常,但输入可能不可用;树可读并不能证明输入已就绪。
winapp target snapshot sandbox --json是只读就绪状态查询:它既不会启动来宾,也不会重新连接来宾。请用它诊断就绪状态,不要通过探测用户的桌面来诊断。- 持久来宾由多方共享,并不意味着互不信任的工作流彼此隔离。请与其他用户协调;不支持
--unique-identity选项。对于互不信任的工作,请使用不同的计算机。 - 来宾的
--debug-output仅支持已打包应用。
方法和命令发现
优先使用单个脚本批处理,而不是进行一长串交互式调用。如果应用由你编写,请直接使用其 XAML/源代码中的 AutomationId;否则,请检查当前树,并阅读源代码以查找隐藏或延迟加载的弹出面板和对话框。无论采用哪种方式,在通过无障碍审计前,都要先验证真实窗口/树。
核心动词: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。请使用各动词的 --help 查看选择器和选项,不要根据这份简短参考猜测。
第 1 步:选择目标,然后始终将 PID 与目标配套使用
如果 Windows Sandbox 可用,优先选择 sandbox;否则,请告知用户并选择 local。如果用户明确要求使用 Windows Sandbox,而它不可用,请停止,并指引用户参阅 winui-setup 中的启用步骤。来宾已停止并不能证明 Sandbox 不可用:即使功能已启用,target snapshot 也可能报告没有正在运行的目标。应用构建/测试失败不代表 Sandbox 不可用。同一策略也适用于诊断:未打包应用无法使用来宾的 --debug-output,因此除非用户明确要求使用 Sandbox,否则应在本地诊断。
将选定的目标传递给模板:在来宾中运行时,模板使用 winapp run . --on sandbox --detach --json 启动;在本地执行时,则省略 --on sandbox。仅当已捕获的目标与选定目标一致,且来宾未重新创建时,才复用已运行的应用。绝不要将来宾 PID 传给默认面向主机的 winapp ui。如果选定目标之后变得不可用,请报告情况,并按照同一策略重新选择;脚本本身不会在另一个目标中重试。
运行 JSON 包含 ProcessId、ProcessScope 和 UiTargetArgs(PascalCase)。在 Windows Sandbox 中,UiTargetArgs 为 --on sandbox -a <guestPID>:必须保留两部分,而不只是数字。对于明确要求在来宾中运行的未打包应用,启动结果不含应用 PID:请单独启动应用,通过目标范围的 winapp ui list-windows --on sandbox --json 按进程/标题查找目标应用,然后使用 -Target sandbox -AppPid <PID> -AppScope sandbox 传入其 PID。绝不要使用容器 PID。新建来宾会使旧 PID/HWND 失效。
步骤 2:编写宿主端批处理脚本
创建 ui-tests.ps1,将示例中的 AutomationId/预期值替换为应用的要求,并将下方相关示例添加到外层 try 中、finally 之前。这两个小型命令辅助函数只负责路由和检查 CLI 调用;它们不会实现目标管理器。所有新增命令都必须使用它们,以免后续成功掩盖先前的原生命令失败。
# 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
在测试中使用 throw,不要使用 exit;脚本会在 CLI 错误、检查结果为空或证据未送达时失败。截图以已知的主 HWND 为目标,因此每张截图都会保存到唯一确定的宿主路径;需要包含弹出窗口时,使用 --capture-screen。
步骤 3:运行、读取并进行视觉验证
# 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
对于已捕获且仍在运行的来宾 PID,使用 .\ui-tests.ps1 -Target sandbox -AppPid 1234 -AppScope sandbox。对于本地运行(参见步骤 1),使用 .\ui-tests.ps1 -Target local,也可以选择指定本地 PID 和 -AppScope local。切勿在本地重用来宾 PID,也不要切换目标来让失败的测试通过。
使用图像查看工具查看 test-results.json 中列出的每张宿主 PNG。UIA PASS 无法检测裁切、重叠、主题错误或内容溢出容器。若出现非预期滚动条、非预期省略号、主视觉内容/右侧控件被裁切、行重叠、留白不均衡、间距过于紧凑或宽松、Light/Dark/High Contrast 显示错误,或缺少焦点/悬停/错误状态,则视觉检查应判定失败。在交互后立即捕获有意义的状态,不要只在最后截图。
如果要求修复问题,请批量修复失败项,使用相同目标通过模板重新构建/启动(省略 -AppPid 以便再次启动),并捕获新的 PID。最多进行两次修复并重新运行的循环;之后报告仍未解决的失败项。只有获得结构化结果并完成视觉检查后,才能宣布完成。
断言与覆盖范围
针对每项要求编写测试。对 TextBox/NumberBox 的值、RichEditBox 文本、ComboBox 选中项、切换开关/复选框的 On/Off 状态,以及 TextBlock/标签文本,使用 wait-for --value。--contains 用于匹配子字符串;--gone 用于等待元素消失。仅在需要检查特定属性时使用 -p IsEnabled --value True。调用按钮/导航项后,等待页面专属元素出现,并测试默认/错误/空状态。
以下示例是添加到模板中的内容,不是独立的 Shell。Invoke-Ui 始终会传入所选作用域以及 -a $AppPid,或传入作用域以及 -w $Window。读取原始数据时,传入 --json 并进行解析;标准输出中可能包含提示性消息。断言折叠区域下方的虚拟化项目之前,先使用 scroll-into-view。
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.' }
}
文件选择器、弹出层和 ContentDialogs
文件选择器在单独的 PickerHost 进程中运行。在同一目标中查找其 HWND;-w 更改的是窗口选择器,绝不会更改目标。Sandbox 测试时,请将文件名替换为来宾中实际存在的文件。
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')
}
对于 Save/Cancel 文件选择器,请替换为相应的操作和断言。菜单栏标题和弹出层项目也同样使用 invoke 和 wait-for。ContentDialogs 位于应用窗口中;Primary、Secondary 和 Close 是常见选择器,但请检查实际打开的对话框,因为自定义 AutomationId 并不一定存在。
键盘、悬停、拖动、触控和触控笔
对于 WinUI/WPF 文本控件中的快捷键和可靠的逐字符输入,必须使用 send-keys --via send-input。post-message 以 HWND 为目标,但不会逐字符生成 KeyDown。--target 会先将焦点置于目标;--verbatim 会按字面输入键名。系统快捷键需要使用 CLI 明确提供的权限标志;不要用它们来绕过桌面就绪错误。
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.
}
使用 winapp ui drag --help、winapp ui touch --help 和 winapp ui pen --help 查看坐标、长按/停留、捏合/拉伸、压力/倾斜/橡皮擦支持情况。在来宾树中读取坐标;宿主坐标不等于来宾坐标。
录制与工作流协调
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"
}
}
沙盒截图/录制的 --output 路径是主机路径,捕获完成后会自动传送;不要从猜测的来宾目录中提取文件。使用新的输出路径,并检查退出状态和实际主机文件。--capture-screen 可能会捕获应用窗口外的弹出窗口。录制中断时,请保留 stopReason、partialOutput、recoveryHint 以及报告的部分输出/恢复路径;非空的部分文件不一定可以播放。
WinApp CLI 会自动仲裁会更改桌面的命令。未提供工作流 ID 时,每条命令都会立即释放执行权;提供工作流 ID 时,4 秒宽限期适用于紧凑的脚本命令批次,而非 Agent 思考。协作命令(包括并发录制和交互)应使用相同的 WINAPP_UI_WORKFLOW_ID;独立流程需要使用不同的 ID。模板为此接受 -WorkflowId,会在自身进程中设置该 ID、恢复之前的值,并在 finally 中调用 winapp ui yield --on sandbox。新的 Shell 工具调用不会继承之前 Shell 的更改:每次协作调用都必须设置相同的 ID。单独的并发录制进程也必须检查其退出代码/传送情况、使用相同的作用域 PID,并在测试运行器让出执行权前结束。不要启动使用独立 ID 的录制,以免阻塞其本应捕获的交互。暂停后,请再次检查,并重新打开其他工作流可能已更改的菜单/对话框。
来宾持久化与任意文件传输
不要检查主机 $env:LOCALAPPDATA 来证明来宾中的持久化。请检查所选目标中的实际应用数据文件。将示例程序包系列/路径替换为应用的实际存储位置;未打包的应用通常使用应用专属的 LocalAppData 目录。
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.' }
}
对于任意设置/结果文件,请使用 target exec / push / pull,不要猜测主机文件系统路径。传输的来宾路径是相对于快照 workRoot 的相对路径,通常为 C:\WinApp\work,而非绝对来宾路径。以下可选补充内容假设主机上有一个 setup.ps1,用于在来宾工作根目录下创建 Results:
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'))
}
恢复失败的传送期间,请让来宾保持运行。在任何经用户同意的关闭操作前,保留用于诊断的证据/恢复路径和应用数据;不要把停止所有沙盒作为常规清理操作。
绑定与选择器注意事项
set-value不会提交默认的 WinUIx:Bind TwoWayTextBox 绑定,除非焦点发生变化。优先在应用中使用UpdateSourceTrigger=PropertyChanged;否则,请invokeSave 或将焦点移到其他控件后,再检查持久化状态。- RichEditBox/RichTextBox 通常需要使用
send-keys --via send-input,而非 UIAset-value。 - 使用
$AppPid,不要使用 PowerShell 只读的自动变量$Pid。 - 选择器 HWND 需要
-w和相同的目标作用域。新的来宾会使两个 HWND 和 PID 都失效;不要重复使用其中任何一个。