mcpField
W
winui-ui-testing

winui-ui-testing

github.com/microsoft测试与质量490201 次安装3 周前更新
代码仓库

关于 winui-ui-testing

使用 WinApp CLI 和 Windows UI Automation 检查并操作桌面应用的测试技能,涵盖本机或 Windows Sandbox 目标、脚本断言及捕获结果的视觉复核。

主要功能

  1. 01检查并查询 UI Automation 树
  2. 02通过输入和控件操作交互
  3. 03在本机或 Windows Sandbox 测试
  4. 04断言数值与应用状态
  5. 05捕获屏幕截图和录制
  6. 06复核 UIA 之外的视觉状态

适用场景

  1. 01用脚本断言测试桌面工作流
  2. 02检查对话框、文件选择器和弹出菜单
  3. 03捕获界面状态以供视觉复核
  4. 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 不会提交默认的 WinUI x:Bind TwoWay TextBox 绑定,除非焦点发生变化。优先在应用中使用 UpdateSourceTrigger=PropertyChanged;否则,请 invoke Save 或将焦点移到其他控件后,再检查持久化状态。
  • RichEditBox/RichTextBox 通常需要使用 send-keys --via send-input,而非 UIA set-value。
  • 使用 $AppPid,不要使用 PowerShell 只读的自动变量 $Pid。
  • 选择器 HWND 需要 -w 和相同的目标作用域。新的来宾会使两个 HWND 和 PID 都失效;不要重复使用其中任何一个。

评论

支持 Markdown