首页
/ PowerToys UITestAutomation.Next 框架对齐实施全解析:从 WinAppDriver 迁移到 winappcli 的 Parity 与加固路线图

PowerToys UITestAutomation.Next 框架对齐实施全解析:从 WinAppDriver 迁移到 winappcli 的 Parity 与加固路线图

2026-09-06 18:29:58作者:尤辰城Agatha

本指南以 Microsoft PowerToys 仓库内的 FRAMEWORK-PARITY-PLAN.md 为骨架,系统拆解新版 UI 自动化测试框架 UITestAutomation.Next(基于 winappcli 命令行引擎)与旧版 WinAppDriver/Selenium 框架 UITestAutomation 之间的六大能力差距及其最终落地实现。读完本文,你将掌握 .Next 框架的完整初始化/清理生命周期、窗口尺寸控制、模块开关预配置、作用域(Scope)启停、CI 流水线诊断等机制的底层设计,以及如何在既有 UI 测试上迁移与复用这些能力。


一、背景:为什么需要一份 Parity 计划文档

PowerToys 在 src/common 下同时维护着两套 UI 自动化测试基座:

  • 旧版框架 Microsoft.PowerToys.UITestUITestBase.cs):基于 WinAppDriver 与 Selenium,由窗口驱动(Window Driver)代理 UIA 请求;
  • 新版框架 Microsoft.PowerToys.UITest.NextUITestBase.cs):引擎改为 winappcli,所有 UI 调用都通过外部进程 winapp.exe 的 CLI 子命令完成——不再依赖 WinAppDriver、Selenium 或任何第三方 NuGet 包(这一点在 UITestBase.cs 的类型注释中明确声明)。

参考点(文档原话给出的三个基准文件):

  • 旧版基座:src/common/UITestAutomation/UITestBase.cs
  • 新版基座:src/common/UITestAutomation.Next/UITestBase.cs
  • 新版启动逻辑:src/common/UITestAutomation.Next/SessionHelper.cs

由于两套框架是"drop-in shape replacement"(继承基座、传入模块枚举、使用 Session/Find<T>),新版基座在孵化初期缺少旧版积累的一系列健壮性行为。FRAMEWORK-PARITY-PLAN.md 正是这份"差距清单 + 加固计划 + 完成记录",逐条对照两套框架在 桌面卫生、窗口尺寸、模块预配置、作用域生命周期、流水线诊断、编辑器作用域启动模型 六个维度上的差异,并全部标记为已完成(✅ Done)。

一个重要的事实基调:这是一次纯测试框架/测试代码层面的改造,不触碰任何产品代码;改造完成后测试夹具(harness)与两个 .Next 消费者(ColorPicker.UITestsSettings.UITests)均构建通过(exit 0)。


二、总览:六个 Gap 的实施状态

原文档开篇的"Status — implemented"表总结了全部六项差距的处理结果,此处完整保留并标注对应源码落点:

Gap 状态 落点
1 — Clean-slate 桌面卫生 ✅ Done UITestBase.PreTestHygiene() + virtual StaleProcessNames 属性;WindowControl.TryKillProcessByName
2 — WindowSize 接入基座 ✅ Done UITestBase 构造函数 size 参数 + ApplyWindowSize()
3 — 模块开关预配置 ✅ Done UITestBase 构造函数 enableModules 参数 → 启动前调用 ConfigureGlobalModuleSettings
4 — 作用域销毁 / 重启 ✅ Done SessionHelper.launchedByUs / StopIfStarted() / Restart()UITestBase.RestartScope(...)
5 — 流水线诊断 ✅ Done(仅 CI 生效) 新增 ScreenCapture.csScreenRecording.csDisplayHelper.cs,在 UITestBase 中接线
6 — 编辑器作用域启动审计 ✅ 文档化 每种作用域的启动模型记录在 ModuleConfigData.csPowerToysModule 枚举文档中

原文将各 Gap 的详细章节保留为"实施理由/决策记录",下文将逐一结合源码展开。


三、.Next 当前初始化流程(基线行为)

要理解六个 Gap 解决了什么,先看补强之前的基线。按原文档,TestInit 只做两件事:

  1. 探测 winapp.exe 可用性(fail fast,并给出安装提示)。对应源码中静态的 Lazy<bool> CliAvailable = new(WinappCli.IsAvailable)UITestBase.cs),不可用时 Assert.Fail(WinappCli.InstallHint)——整个进程只额外承担一次 winapp --version 探测成本;
  2. new SessionHelper(scope)Init():必要时拉起目标进程(Settings 作用域会以 runner 参数 --open-settings 启动),并等待第一个 UIA 可见窗口出现。

TestCleanup 在失败时抓取单张截图,随后调用实际上什么都不做的 Session.Cleanup()Session.Cleanup() 是一个无状态 no-op,见 Session.cs)。

也就是说,在 Parity 计划落地前,.Next 基座缺少旧版具备的:已知桌面起点、可注入的窗口尺寸、确定性的模块开/关状态、作用域进程回收、以及 CI 失败产物(截图/录像/日志)等能力。这正是六个 Gap 的由来。


四、Gap 1 — Clean-slate 桌面卫生(HIGH,低风险)

旧版每次 TestInit 都从"已知桌面状态"出发;.Next 最初什么都不做。差异对照如下:

行为 旧版 .Next(补齐前) 已有底层设施
最小化所有窗口(Win+M KeyboardHelper.SendKeys(Key.Win, Key.M) SendKeys(Key.LWin, Key.M)
杀掉陈旧进程(PowerToysPowerToys.SettingsPowerToys.FancyZonesEditor CloseOtherApplications() WindowControl.TryKillProcess
启动前按 ESC 关闭遗留弹窗 KeyboardHelper

计划与最终实现:在 TestInit 最顶部(早于 SessionHelper.Init)加入 PreTestHygiene(),按 Win+M → Esc → 逐个 kill 顺序执行(UITestBase.cs),并新增一条:SettingsConfigHelper.SuppressFirstRunExperience()——在全新配置文件(如 CI 代理)上,runner 会弹出居中置顶的"欢迎使用 PowerToys"(OOBE)/“更新后新增功能”(SCOOBE)窗口,抢占屏幕中央的鼠标手势坐标。抑制手段是写 oobe_settings.jsonopenedAtFirstLaunch=true,并把 settings.jsonshow_whats_new_after_updates 置为 falseSettingsConfigHelper.cs)。整段卫生逻辑是 best-effort——任何失败都只吞掉异常,绝不阻塞测试启动。

关键设计决策——精确名称匹配:清理列表被设计为 protected virtual IReadOnlyList<string> StaleProcessNames,默认值为 PowerToysPowerToys.SettingsPowerToys.FancyZonesEditor,子类可覆写追加模块自身的辅助进程。杀进程统一走新增的 WindowControl.TryKillProcessByName精确名称匹配,即 Process.GetProcessesByName 的语义,见 WindowControl.cs),而不是旧版 Contains 子串匹配的 TryKillProcess——否则名为 "PowerToys" 的条目会把正在运行的 PowerToys.*.UITests 测试宿主一并误杀。


五、Gap 2 — WindowSize 真正接入测试基座(HIGH,低风险)

旧版构造函数形如 UITestBase(PowerToysModule scope, WindowSize size, string[]? commandLineArgs),并在构造 Session 时应用尺寸。.Next 一侧其实早已具备三件套:

  • WindowHelper.SetWindowSize(hwnd, size)
  • WindowSize 枚举;
  • Session.Attach(size)

UITestBase 没有 size 参数、也从不调用尺寸设置,导致每个 .Next 测试都跑在窗口默认尺寸下,阻塞了依赖固定尺寸的旧测试迁移,例如 src/settings-ui/UITest-Settings/SettingsTests.csWindowSize.Large)、Hosts/Workspaces(WindowSize.Medium)、Peek(Small_Vertical)。

实现UITestBase 构造函数新增带默认值的 WindowSize size = WindowSize.UnSpecifiedUITestBase.cs)。Init() 解析出窗口后(以及每次 RestartScope() 之后)执行 ApplyWindowSize():当尺寸为 UnSpecified 时,默认最大化窗口(而非不动),理由是 PowerToys 会恢复模块上次的窗口矩形——CI 代理上往往是偏小的、甚至被推到屏幕外的矩形;对 Settings 来说这会让 NavigationView 侧栏塌缩、导致导航项(如 SystemToolsNavItem)查找失败,最大化是确定性默认值;当显式指定了尺寸时才走 WindowHelper.SetWindowSize(new IntPtr(Session.WindowHandle), size)UITestBase.cs)。

WindowSize 枚举的完整取值与像素尺寸(WindowHelper.cs):

取值 像素 语义
UnSpecified 不改变尺寸(基座默认 → 最大化)
Small 640 × 480 小横窗
Small_Vertical 480 × 640 小竖窗
Medium 1024 × 768 中横窗
Medium_Vertical 768 × 1024 中竖窗
Large 1920 × 1080 大横窗
Large_Vertical 1080 × 1920 大竖窗

SetWindowSize 还有一个容易被忽视的边界处理:预设尺寸会先**钳制到屏幕约 90%**再居中放置,避免在等尺寸(如 1920×1080)屏幕上把 1920×1080 的窗口放偏到不可见区域——这正是历史上"Settings 窗口向右下偏移、部分出屏"问题的根因。


六、Gap 3 — 模块开关预配置:确定性模块基线(HIGH,低风险)

旧版的 StartExe(enableModules) 会走 SettingsConfigHelper.ConfigureGlobalModuleSettings(...),在启动前settings.json 种好,使每个测试都从已知的模块开/关状态开始。.Next 虽然自带 SettingsConfigHelper.ConfigureGlobalModuleSettings,但没有任何调用方——原文档将其定位为"测试假定模块已开启(test assumes module is ON)"这一类脆弱测试的根源。

实现UITestBase 构造函数新增可选参数 string[]? enableModules = null。当非空时,TestInit 会在 SessionHelper.Init() 之前调用 SettingsConfigHelper.ConfigureGlobalModuleSettings(enableModules)UITestBase.cs),且在 RestartScope() 中会再次套用该基线(除非调用者显式传入新列表覆盖)。

底层机制(SettingsConfigHelper.cs)值得展开:

  • 配置对象位于 %LocalAppData%\Microsoft\PowerToys\settings.json,模块开关是 JSON 顶层 "enabled" 对象,键为模块名(如 "FancyZones""ColorPicker""Peek");
  • ConfigureGlobalModuleSettings 的语义是精确白名单:把请求列表中命中的模块置 true,把所有其它“已知模块”与文件中已列出的模块置 false
  • "已知模块"来自硬编码的 KnownModuleNames 数组——覆盖 AdvancedPaste、AlwaysOnTop、Awake、CmdNotFound、CmdPal、ColorPicker、FancyZones、Hosts、Keyboard Manager、PowerToys Run、Peek、Workspaces、ZoomIt 等全部模块名,因此即使目标 settings.json 里还没有某个键,也会被补上并归位;
  • 读写全程使用 System.Text.JsonJsonNode),使测试夹具对产品程序集保持零依赖——这与旧版 helper 引用 Settings.UI.Library 形成对比。

配套能力:PreserveFirstRunSettings()/PreserveModuleSettings(moduleName)IDisposable 快照形式在测试前备份 settings.json/oobe_settings.json 的原始字节,Dispose 时按原字节恢复(测试自建的文件则删除),配合 TestInit 异常路径与 ClassCleanup 使用,保证任何失败都不会把用户/CI 代理上的真实配置改坏。


七、Gap 4 — 作用域销毁与重启(MEDIUM,需设计决策)

旧版清理链是 TestCleanup → sessionHelper.Cleanup() → ExitScopeExe(),负责停止自己拉起的东西;而 .NextSession.Cleanup() 是 no-op,EnsureRunning 返回的"是否由我拉起"布尔值被丢弃,基座从不回收自己启动的进程(个别测试如 ColorPicker 只能靠各自的 finally 自行兜底)。

原文档明确提出了一个设计决策:每个测试单独销毁(kill 作用域进程)vs. 在一个测试类内复用长生命周期 runner。推荐方案是:在 SessionHelper 中记录 launchedByUs,暴露 StopIfStarted(),仅当进程确由基座启动时才在 TestCleanup 调用;并提供与旧版 RestartScopeExe 等价的 RestartScope 便捷方法。

最终实现SessionHelper.cs):

  • Init()EnsureRunning 的结果记入 launchedByUs,随后 ResolveMainWindowOrFail() 解析窗口;
  • StopIfStarted():若 !launchedByUs 直接 no-op——绝不回收测试没创建的状态;否则杀死作用域进程,对 Settings 作用域还连同杀掉 runner(runner 退出会连带停掉它拉起的模块),全部走精确名称匹配并短暂等待进程消失;
  • Restart():kill → relaunch → rebind 到新窗口,并重新标记 launchedByUs = true
  • 基座侧 UITestBase.RestartScope(string[]? enableModules = null)UITestBase.cs):先重种模块基线(参数为 null 时回退到构造时的基线),重启作用域,重设窗口尺寸,返回新的 Session——正是旧版 RestartScopeExe 的等价物。

类级作用域复用:ReuseScopeAcrossTests

StopSharedScopeUITestBase.cs)与 keepAliveHelper 静态字段的支撑下,UITestBase 还支持类级共享作用域:派生类把 protected virtual bool ReuseScopeAcrossTests 覆写为 true 后,整个类只启动一次模块、同一个窗口跨所有测试方法复用(不做逐测试的重启与桌面卫生),类结束后由继承的 [ClassCleanup] 统一停止,同时每个失败测试仍可获得独立的失败媒体采集。仓库内真实的 .Next 消费者 SettingsNavigationSmokeTests.cs 正是以此模式逐项点击 Settings 导航栏并断言进程存活。

150 秒启动预算与"耐心等待"哲学

值得一提的实现细节是 SessionHelperLaunchTimeout = 150sSessionHelper.cs)。原因写得很具体:冷/繁忙 CI 代理上,runner 需要几十秒逐个启用模块,Settings 的 WinUI 进程冷启动后才出窗口;若整个任务以管理员身份运行(旧 WinAppDriver 绑定 :4723 需要),runner 启动更慢——慢平台上曾观测到约 100 秒才出现首个 Settings 窗口。因此 EnsureWindow 采用"等待单一宽限期,仅在确实无进程存活时才重发启动(nudge,间隔 25s)"的策略,刻意避免"每 20 秒 kill+relaunch"的自毁循环;只有"孤儿 Settings 窗口(Settings 活着但 runner 已死)"和"交接给已退出实例的竞态"两种情形才清理重启。

另一个有据可查的坑:LaunchViaShell 必须使用 UseShellExecute = true,否则子进程继承测试宿主的 stdin/stdout/stderr 句柄,Microsoft.Testing.Platform/MSTest 会一直等管道排空——在目标进程退出前测试永不结束。经 ShellExecute 启动后子进程拥有独立控制台、句柄被切断,且单实例进程(runner/Settings/ColorPicker)常让启动 PID 立即以 0 退出、把工作交给既有实例,所以就绪与否只以 UIA 窗口是否可见为准


八、Gap 5 — 流水线诊断(MEDIUM/LARGE,仅 CI 生效)

旧版把以下行为全部用 EnvironmentConfig.IsInPipeline 门控。.Next 的判定逻辑在 EnvironmentConfig.cs:设置了 platform 环境变量, TF_BUILD=true(Azure DevOps 代理上的标准信号,不可关闭)即视为处于流水线。

行为 旧版 .Next(补齐前) 备注
分辨率归一化为 1920×1080 ChangeDisplayResolution 移植到 MonitorInfo/原生 helper
显示器信息快照 GetMonitorInfo() ⚠️ 有 MonitorInfo 但初始化未调用
截图定时器(1s 节奏) ScreenCapture.TimerCallback 需移植
屏幕录像(FFmpeg) ScreenRecording 需移植
失败时附带截图 + 录像 + 日志文件 ⚠️ 仅单张截图 需补日志与录像附件

实现(同样以 EnvironmentConfig.IsInPipeline 门控)

  1. 新文件ScreenCapture.cs(1 秒节奏截图定时器)、ScreenRecording.cs(FFmpeg 编码)、DisplayHelper.cs
  2. TestInit 阶段:流水线模式先 DisplayHelper.NormalizeResolution(1920, 1080) 固定主屏分辨率(坐标敏感测试确定性的前提),再 DisplayHelper.LogMonitors(TestContext) 输出显示器拓扑到测试日志与控制台,随后在启动 UI 工作前 StartPipelineCapture() 开启截图定时器与录像(保证产物覆盖全程,UITestBase.cs);
  3. TestCleanup 阶段:失败时 CaptureFailureArtifactsAsync() 触发三件套采集——桌面截图 + 截图轨迹 + 录像 + PowerToys 日志;通过时则停掉采集并删除录像目录UITestBase.cs);
  4. 日志采集 AddLogFilesToTestResults:同时递归拷贝 %LocalAppData%\Microsoft\PowerToys%LocalAppDataLow%\Microsoft\PowerToys 下的 *.log 到测试结果目录并注册为结果文件,失败的 CI 运行因此自带模块日志(UITestBase.cs);
  5. 本地(非流水线)路径保持轻量:仍只抓 winappcli ui screenshot --capture-screen 的失败快照。

两个刻意的差异(原文档明确记录)

  • NormalizeResolutionEnumDisplaySettings(ENUM_CURRENT_SETTINGS) 读取当前模式,再设置 DM_PELSWIDTH | DM_PELSHEIGHT 字段去请求分辨率——这是文档化的可靠做法;而旧版是字段全部不设的调用;
  • 录像编码器加载失败时(典型原因:干净镜像缺少 Visual C++ 运行库)不会静默放弃,而是把原因写入测试输出,避免"空录像文件夹像丢了产物"的误判。

九、Gap 6 — 编辑器作用域的启动模型审计(LOW,跟进项)

在 Settings 作用域改为 PowerToys.exe --open-settings 之后,Hosts、Workspaces、CommandPalette、FancyZonesEditor、ScreenRuler 等编辑器作用域SessionHelper.EnsureRunning 中仍是直接拉起自己的 exe。对"设计上就要独立运行"的编辑器来说这是正确的,但需要逐一与生产环境 runner 的拉起方式核对,并把各作用域的预期模式文档化。

完成形态:启动模型已完整记录在 ModuleConfigData.csPowerToysModule 枚举文档(ModuleConfigData.cs)中,可归纳为四类:

分类 作用域 启动方式
Runner 所属 PowerToysSettings PowerToys.exe --open-settings(由 runner 拥有模块开关与激活热键;测试若要"通过 Settings 界面驱动某个工具"必须用此类,因为独立模块 exe 背后没有 runner,其激活热键不会触发、Settings 里的开关也不生效)
Runner 本体 Runner 直接启动 PowerToys.exe(托盘/宿主进程)
独立编辑器作用域 FancyZonesEditorHostsWorkspacesPowerRenameCommandPaletteScreenRuler 独立启动各自 exe(自带自包含编辑器窗口,直接绑定其窗口是正确的)
覆盖层/后台模块 ColorPickerLightSwitch 测试不应独立启动;应经 PowerToysSettings 作用域开关 + 激活热键驱动。枚举项仅用于 runner 拉出后让窗口/进程发现能解析到它们

PowerToysModule 配套的 ModulePaths 元数据表(ModuleConfigData.cs)给出了每个作用域的 exe 文件名、子目录、进程名与期望窗口标题子串,例如 Settings 映射到 WinUI3Apps\PowerToys.Settings.exe / PowerToys.Settings / 标题 "PowerToys Settings"。可执行文件解析顺序同样文档化(ModuleConfigData.cs):环境变量 POWERTOYS_INSTALL_DIR 显式覆盖 → useInstallerForTest 时取已安装构建(Program Files/LocalAppData)→ 默认沿测试程序集向上查找包含 exe 的构建输出根(本地 <root>\<plat>\<cfg>、CI 为下载的构建产物)→ 兜底回退已安装路径。窗口解析(WaitForMainWindow)则轮询 winapp ui list-windows --json,优先做标题子串严格匹配以区分同进程多窗口(如 Settings 与其 PopupHost 覆盖层),无期望标题的模块才退回进程名匹配。


十、实施顺序与分阶段验收标准

原文档把六项差距编排成五步落地顺序,每步都有明确的验收门槛:

  1. Phase 1(快速见效、零调用方破坏风险):Gap 1 桌面卫生;
  2. Phase 2(构造函数表面扩展):Gap 2 + 3——新增 WindowSizeenableModules 构造参数(均带默认值,既有 .Next 测试无需改动即可继续编译),解除旧版 Settings/Hosts/Workspaces 测试的移植阻塞;
  3. Phase 3(生命周期):Gap 4 的 teardown/restart 设计与实现;
  4. Phase 4(CI):Gap 5 诊断能力与 FFmpeg 录像;
  5. Phase 5(收尾):Gap 6 各作用域启动审计与文档化。

每条验收标准

  • 既有 .Next 测试保持编译通过(新增参数全部带默认值、不主动传入即零行为变化);
  • 新行为一律 opt-in 或门控(例如仅流水线开启),保证本地运行依然轻快;
  • 每个移植行为与旧版语义一致,或记录在案的刻意差异(如 NormalizeResolutiondmFields 处理);
  • 不触碰任何产品代码——纯框架/测试改动。

十一、迁移到 .Next:写给测试作者的落地要点

结合以上实现,把一条旧版测试迁移到 .Next 时实际只需继承 UITestBase.cs 并善用三个构造参数:

[TestClass]
public sealed class MyModuleTests : UITestBase
{
    // 1) 指定要驱动的模块作用域;
    // 2) 需要固定窗口尺寸时传入 WindowSize(默认 UnSpecified 会最大化窗口);
    // 3) 需要确定性模块基线时传入 enableModules(其余模块将被精确禁用)。
    public MyModuleTests()
        : base(
            PowerToysModule.PowerToysSettings,
            size: WindowSize.Large,
            enableModules: new[] { "ColorPicker", "Peek" })
    {
    }

    [TestMethod]
    public void Smoke()
    {
        var nav = Find("GeneralNavItem");   // Find<T> / Session 即全部交互入口
        // ...
    }
}

由此框架自动为你提供:PreTestHygiene() 桌面清理、首次运行窗口抑制、启动前模块开关预配置、失败时的截图/录像/日志采集(流水线内)、作用域进程的"只回收自己启动的东西"式清理;需要类内共享窗口时覆写 ReuseScopeAcrossTests => true,需要干净重启时调用 RestartScope()

几条实践提醒(均有源码依据):

  • 模块驱动必须走 Settings 作用域:覆盖层/后台型模块(ColorPicker、LightSwitch)单独启动时没有 runner,热键与开关均不生效(见 SessionHelper.EnsureRunning 注释);
  • 杀进程优先精确名称:模块子类追加 StaleProcessNames 时,任何短名称都不会误伤 PowerToys.*.UITests 这类名字包含 "PowerToys" 的宿主;
  • 就绪判据是窗口而非进程:单实例进程常让启动 PID 秒退并交接给既有实例,EnsureWindow 只认"UIA 可见窗口在预算内出现";
  • 本地开发不背 CI 包袱:分辨率归一化、截图定时器、FFmpeg 录像与日志附件全部由 EnvironmentConfig.IsInPipeline 门控,本地跑依旧只有失败时一张 --capture-screen 快照。

对整套框架的文件拓扑感兴趣的读者,可继续按以下路径深入:基座与生命周期在 UITestBase.csSessionHelper.cs;会话与元素查找在 Session.cs;窗口 Win32 能力在 WindowHelper.csWindowControl.cs;配置在 SettingsConfigHelper.cs;真实消费者示例见 SettingsNavigationSmokeTests.csColorPickerEndToEndTests.cs;更上层的编写/运行约定可参考 ui-tests.md

登录后查看全文
热门项目推荐
相关项目推荐