我给一个仓库系统写了个"会自己点界面"的 AI 测试 Agent,踩平了 WPF 自动化的所有坑

WPF 桌面端自动化测试有多难做,做过的人都懂。这次我把 DeepSeek 的 tool-calling 塞进了 UIAutomation,让 AI 自己看界面、自己点按钮、自己填表格,最后还让多模态模型截图给整个测试"判卷"。这篇把架构和最痛的几个坑全摊开讲。

先说结论:它现在能干什么

被测对象是一套仓库管理系统 (WPF 桌面端)。我做的这个平台能做到:

  • 你用自然语言写测试目标,比如"新建,选择、填信息、提交,验证发行成功";
  • AI Agent 自己 scan_ui 看界面 → click / set_text / select_item 一步步操作 → 每点一下 check_dialog 处理弹窗;
  • 跑完之后,再截一张结果界面的图,丢给多模态模型做二次验证,Agent 自报成功 + 截图验证通过,才算真的通过;
  • 全程通过 SignalR 把每一步的"思考 / 工具 / 参数 / 结果"实时推给前端,像看直播一样看 AI 测试。

技术栈:ASP.NET Core 9(net9.0-windows)+ Windows UIAutomation + Vue 3 + Element Plus + SignalR + DeepSeek

下面进正题,全是干货。


一、为什么不用 Selenium / Playwright 那一套?

因为被测的是 WPF 原生桌面应用 ,不是网页。浏览器自动化那套 DOM 选择器在这里完全不适用,桌面端能用的只有 Windows 的 UIAutomation API。而 UIAutomation 的体验......只能说"能用,但处处是坑"。

我设计了三种执行模式,落在一个 RunService 里调度:

  • structured(结构化回放) :先用录制器录下鼠标键盘操作,回放时按 AutomationId 精确重放,快且稳;
  • ai(AI 推理):没有录制脚本时,让 LLM 通过 tool-calling 逐步推理操作;
  • auto(默认):场景有录制步骤就走结构化,否则自动降级到 AI。

录制和 AI 回放共用同一套定位基础设施 ElementFinder,这点很关键------录的时候怎么找元素,AI 跑的时候就怎么找,行为一致。


二、核心:让 LLM 通过 tool-calling 操作 WPF

AI 模式的本质是一个 tool-calling 循环 :LLM 返回它想调用的工具,我执行后把结果回传,它再决定下一步。直到它调用 done 报告结果,或者超过最大步数。

把工具集设计得贴合业务,是 Agent 好不好用的分水岭。我没有只给 click/type 这种原子操作,而是把仓库系统里高频又易错的动作封装成了语义化工具

csharp 复制代码
switch (call.Name)
{
    case "scan_ui":        return OpResult.Ok(_driver.ScanDescription());
    case "click":          return _driver.Click(a["element_id"]);
    case "set_text":       return _driver.SetText(a["element_id"], a["text"]);
    case "select_item":    return _driver.SelectItem(a["element_id"], a["item_text"]);
    case "click_cell":     return _driver.ClickCell("DgVoucherInItem", row, column); // 表格单元格
    case "set_unit_price": return _driver.SetUnitPrice(row, a["unit_price"]);        // 填単価
    case "select_product": return _driver.SelectProduct(a["product_code"]);          // 商品弹窗选品
    case "check_dialog":   return OpResult.Ok(_driver.TryGetDialog()?.ToString() ?? "无弹窗");
    case "done":           /* 报告 success + summary */
    // ...
}

System Prompt 里我做了两个关键优化,直接决定了 token 成本和成功率:

1. 把已知的 AutomationId 直接喂给模型 ,让它别动不动就 scan_ui(全树扫描既慢又烧 token):

text 复制代码
## 已知 AutomationId
voucher_branch, voucher_voucherType, voucher_customer,
btn_add, btn_voucherIssue, btn_measurement,
detail_{N}_preWeight, detail_{N}_afterWeight, detail_{N}_product, detail_{N}_unitPrice,
dialog_message, dialog_btn_yes, dialog_btn_no,
product_search, product_list, unitprice_settingPrice ...

## 规则
1. 已知 AutomationId 直接用,不必 scan_ui
2. 表格:get_row_count → click_cell 激活 → set_text 填值
3. 点击后必须 check_dialog 处理弹窗
4. 商品选完后调用 set_unit_price 填単価

2. 一轮可能返回多个 tool_call,必须全部执行完、逐个回传结果,再请求下一轮------这是 tool-calling 协议很容易写错的地方:

csharp 复制代码
foreach (var call in response.ToolCalls)
{
    var result = ExecuteTool(call, testName, step);
    await Notify(step, call.Name, call.RawArgs, result.Message, result.Success, response.Text);
    _llm.AddToolResult(call.Id, result.Message); // 每个 tool_call 都要回传对应结果
}
response = await _llm.ContinueAsync(SystemPrompt);

经验:scan_ui 一定要标注"谨慎使用,仅首次/未知界面/失败时调用"。否则模型会每一步都先扫一遍界面,几千 token 哗哗地烧。


三、最硬的坑:UIAutomation 的坐标命中

这部分是整个项目最折磨人、也最有价值的地方。WPF 自定义窗体 + 悬浮层的场景下,AutomationElement.FromPoint(x, y) 经常返回一个覆盖整个窗口的空 Window/Pane 层,根本不是你点的那个按钮。

我用两个手段解决:向下钻 + 向上提

1. 命中空容器 → 按坐标手动下钻

每一层都挑"包含该点、面积最小"的子元素,一直钻到最深的真实控件,同时跳过那些没名字没 id 的悬浮层壳:

csharp 复制代码
public static AutomationElement? DescendToPoint(AutomationElement root, int x, int y)
{
    var point  = new Point(x, y);
    var walker = TreeWalker.ControlViewWalker;
    var cur    = root;

    for (int depth = 0; depth < 25; depth++)
    {
        AutomationElement? best = null;
        double bestArea = double.MaxValue;

        var child = walker.GetFirstChild(cur);
        while (child != null)
        {
            var cc = child.Current;
            bool isOverlayShell = cc.ControlType == ControlType.Window
                                  && string.IsNullOrEmpty(cc.Name)
                                  && string.IsNullOrEmpty(cc.AutomationId);
            var r = cc.BoundingRectangle;
            if (!isOverlayShell && !r.IsEmpty && r.Contains(point))
            {
                var area = r.Width * r.Height;
                if (area < bestArea) { bestArea = area; best = child; } // 选最小面积
            }
            child = walker.GetNextSibling(child);
        }
        if (best == null) break;
        cur = best;
    }
    return cur;
}

2. 命中按钮内部文字 → 向上提升到可交互祖先

你点按钮,FromPoint 命中的往往是按钮里那个 TextBlock,拿不到 btn_xxx 这个 id。所以要向上爬最多 4 层,找到最近的带 AutomationId 的交互祖先:

csharp 复制代码
public static AutomationElement? LiftToInteractive(AutomationElement el)
{
    var walker = TreeWalker.ControlViewWalker;
    var cur = walker.GetParent(el);
    for (int i = 0; i < 4 && cur != null; i++)
    {
        var c = cur.Current;
        if (c.ControlType == ControlType.Window) return null;
        if (!string.IsNullOrEmpty(c.AutomationId)) return cur; // 找到 id,提升成功
        if (c.ControlType == ControlType.Button) return cur;
        cur = walker.GetParent(cur);
    }
    return null;
}

3. 用"轮询等待"取代散落的 Thread.Sleep

界面切换、弹窗弹出都有延迟。早期代码里到处是 Sleep(500),又慢又不稳。后来统一改成在 ById 里内建轮询:

csharp 复制代码
public AutomationElement? ById(string automationId, int timeoutMs = 0)
{
    var deadline = Environment.TickCount64 + timeoutMs;
    while (true)
    {
        try
        {
            var el = Window.FindFirst(TreeScope.Descendants,
                new PropertyCondition(AutomationElement.AutomationIdProperty, automationId));
            if (el != null) return el;
        }
        catch { /* 窗口切换中,重试 */ }

        if (Environment.TickCount64 >= deadline) return null;
        Thread.Sleep(150);
    }
}

还有个隐蔽坑:录制时挂了全局鼠标钩子,执行测试前必须先卸载钩子,否则回放的鼠标事件会被自己的录制器再次捕获,形成死循环。


四、画龙点睛:让多模态模型给测试"判卷"

Agent 自己报"测试通过"是不够可信的------它可能漏看了一个错误弹窗。所以我加了一层 AI 截图验证:测试结束后对结果界面截图,连同测试目标一起发给多模态模型,让它判断到底成没成。

判定逻辑很"较真":Agent 自报成功 + 截图验证通过,二者都满足才算通过

csharp 复制代码
// 综合判定:Agent 自报成功 AND(未启用截图验证 OR 截图验证通过)
bool finalOk = ok && (!aiChecked || aiPass);

更妙的是反过来也成立------如果 Agent 因为超步数没显式完成,但结果界面截图验证是通过的,可以据此翻盘判通过

csharp 复制代码
if (endChecked && endPass)
    return new AgentResult { Success = true,
        Summary = "AI 截图验证通过(Agent 未显式完成,按结果界面判定)" };

为了让结论可解析,我强制模型首行只输出结论

text 复制代码
请结合截图判断本次测试是否成功完成。重点关注:
是否有错误/失败提示弹窗、是否跳转到预期结果界面、关键数据是否正确显示。
**第一行只输出**:`结论:通过` 或 `结论:不通过`
然后另起一行简要说明理由(2-4 句)。
csharp 复制代码
var firstLine = answer.Split('\n').FirstOrDefault()?.Trim() ?? "";
bool pass = firstLine.Contains("通过") && !firstLine.Contains("不通过");

视觉验证走的是 OpenAI 兼容的 /v1/chat/completions 多模态格式,所以通义、智谱、DeepSeek 的视觉模型可以随便换。我还写了个自适应拼地址的小工具,省得每家厂商 baseUrl 结尾不一样要改代码:

csharp 复制代码
// 已含 /chat/completions 直接用;以 /vN 结尾补 /chat/completions;否则补 /v1/chat/completions
if (b.Contains("/chat/completions")) return b;
if (Regex.IsMatch(b, @"/v\d+$"))     return b + "/chat/completions";
return b + "/v1/chat/completions";

五、实时进度:SignalR 把测试跑成"直播"

每一步执行完,都通过 SignalR 推给前端,客户端按 run_{runId} 分组订阅:

  • StepLog:step / tool / args / result / success / thinking(连模型的思考过程都推)/ timestamp
  • RunFinished:runId / status / summary

前端在 /run/:runId 监控页实时滚动,AI 每点一下、每想一步你都看得见,调试体验非常爽。


六、架构全景

scss 复制代码
WPF 被测应用
        ▲
        │ Windows UIAutomation
        │
┌───────┴───────────────────────────────────┐
│  ASP.NET Core 9 (net9.0-windows)           │
│                                            │
│  RunService ── auto / structured / ai      │
│      ├─ StepPlayer   (录制回放)            │
│      └─ AiAgent      (LLM tool-calling)    │
│            ├─ DeepSeekClient   (推理)       │
│            ├─ WpfDriver/ElementFinder (操作) │
│            └─ VisionVerifier   (截图判卷)   │
│                                            │
│  TestHub (SignalR) ──► 实时推送每一步        │
└───────┬────────────────────────────────────┘
        │ REST + SignalR
        ▼
   Vue 3 + Element Plus 前端
   场景管理 / 录制 / 实时监控 / 历史 / 测试计划

数据落 PostgreSQL(SqlSugar ORM),启动自动建库建表。核心实体:Scenario(场景)、TestRun(执行记录)、RunLog(每步日志)、TestPlan(测试计划批量执行)。


七、写完这个项目,我最大的几点体会

  1. 桌面端 Agent 的难点不在 LLM,而在"手" 。模型再聪明,UIAutomation 定位不准、弹窗处理不了,全是白搭。一大半精力都花在 ElementFinder 这种脏活上。
  2. 业务语义化工具 >> 原子操作工具 。给模型 set_unit_price(row, price) 比给它 click + type 让它自己拼,成功率高一个数量级。
  3. 双重验证是必须的。Agent 自评不可信,加一层独立的视觉判卷,假阳性大幅下降。
  4. 把 token 当钱花 。已知 id 直接喂、scan_ui 严格限制、思考过程精简------成本能差好几倍。

目已开源(MIT),后端 ASP.NET Core 9 + 前端 Vue 3,仓库里有架构设计、需求设计、详细设计文档和 TODO,想了解实现细节或二次开发都比较好上手:

👉 GitHub:github.com/SmileL1/Tes...

如果这套思路对你有启发,欢迎点个 ⭐ Star,也欢迎提 Issue / PR 一起把桌面端自动化测试做得更好。

桌面端测试这块太冷了,多一个人折腾,生态就好一点。共勉。

相关推荐
谭光志40 分钟前
工具塞满上下文窗口怎么办?深度拆解 AI Agent Tool Search 按需加载实现原理
前端·后端·ai编程
她说..44 分钟前
Java 默认值设置方式
java·开发语言·后端·springboot
foggyprojects1 小时前
从0开始,一句话启动AI DataAgent
后端·数据分析·ai编程
郡杰1 小时前
一些基础和问题解决
后端
陈随易1 小时前
前端项目部署只要30秒
前端·后端·程序员
YIAN1 小时前
从零手写文件读取 MCP 服务:一文吃透 Model Context Protocol 全链路通信原理
前端·后端·mcp
Imchendiana1 小时前
《狂人日记NO.9》— 前后端一把梭,我的全栈实录
前端·后端
学渣超1 小时前
记一次分布式事务数据不一致的排查之旅:从超时到索引,层层剥茧
java·后端·架构
北冥you鱼2 小时前
Go 语言读取链上数据:从基础到实战
开发语言·后端·golang
程序员cxuan2 小时前
Claude Code 为了封禁中国用户,竟然在代码里下毒
人工智能·后端·程序员