拆字节开源的 Midscene.js:GUI Agent 做 UI 测试,为什么可以不写选择器

简介: Playwright MCP 不依赖截图,而是基于无障碍树解析页面结构,让 Copilot 自动生成精准视觉回归脚本。它自动识别动态区域(时间、价格等)并生成 mask 列表,结合 `maxDiffPixelRatio` 与 `maxDiffPixels` 分层容差,确保版式变更可检、数据变动不误报。

先摆一个反直觉的事实:Playwright MCP 的官方 README 里写着,它用的是无障碍树,不是像素输入。

三条 Key Features 原文是这么说的——Fast and lightweight,Uses Playwright's accessibility tree, not pixel-based input;LLM-friendly,No vision models needed;Deterministic tool application,Avoids ambiguity common with screenshot-based approaches。

换句话说,这个被大量文章描述成「让 AI 看网页截图」的工具,官方立场恰恰是不看截图。

image.png

那它凭什么能帮 Copilot 写出可用的视觉回归脚本?

因为写差异检测脚本最难的部分,从来不是「怎么比两张图」。Playwright 一行 toHaveScreenshot() 就把比对做完了。真正难的是另一件事:哪些区域天生就不该参与比对。

这件事看图片看不出来,看结构才看得出来。而结构,正是 MCP 递给 Copilot 的东西。

一、业务场景:一个每两周改一次版式的会场页

先把场景钉死,不然后面全是空谈。

我们接的是一个电商大促会场页:顶部 Banner 轮播、中部楼层式商品卡、底部倒计时和优惠券领取区。特点有三个——版式每两周随活动调一次;页面里嵌了大量动态数据(库存、价格、倒计时、用户昵称);前端团队只有两个人,视觉回归脚本一直没人愿意维护,靠人工点一遍。

诉求很具体:让脚本能自动跟上版式变化,同时不被动态数据淹没。

这两件事天然冲突。版式变了要能发现,数据变了不能报。手写脚本在这个矛盾里撑不过三个月,所以决定把生成环节交给 Copilot,把结构探查环节交给 Playwright MCP。

二、把 MCP 接到 Copilot:两处配置

Node.js 需要 18 或更新。Copilot CLI 走 ~/.copilot/mcp-config.json,也可以直接在会话里敲 /mcp add 交互式添加:

{
   
  "mcpServers": {
   
    "playwright": {
   
      "type": "local",
      "command": "npx",
      "tools": ["*"],
      "args": ["@playwright/mcp@latest"]
    }
  }
}

如果你更习惯在 VS Code 里用 Copilot Chat,配置写进 .vscode/mcp.json,结构是标准形态:

{
   
  "servers": {
   
    "playwright": {
   
      "type": "stdio",
      "command": "npx",
      "args": [
        "@playwright/mcp@latest",
        "--isolated",
        "--viewport-size=1280x720",
        "--output-dir=./.mcp-artifacts",
        "--snapshot-boxes",
        "--timeout-settle=1200"
      ]
    }
  }
}

四个参数不是随手加的,每个都对应视觉回归的一个真实痛点:

--isolated 让浏览器 profile 只留在内存里,不落盘。视觉回归最怕上一次跑剩的登录态、缓存、A/B 分桶污染这一次,隔离上下文是最省事的解法。需要登录态时用 --storage-state 显式喂一份受控的 state 文件,而不是靠 profile 攒。

--viewport-size 写死 1280x720。基线图和实际截图必须同视口,这是像素比对的前提。

--output-dir 把 MCP 产出的截图和快照统一落到一个目录,方便归档,也方便加进 .gitignore。

--timeout-settle 默认只有 500ms,指每次动作后等待被触发的工作稳定下来的时间。会场页有大量异步接口和懒加载图片,500ms 明显不够,调到 1200ms 能显著减少「截到一半」的情况。

三、让 MCP 先摸一遍页面,再让 Copilot 动笔

这一步是整条链路的关键,也是大多数人跳过去的一步。

不要直接跟 Copilot 说「帮我写个视觉回归脚本」。要让它先通过 MCP 把页面探一遍,拿到结构,再基于结构生成脚本。实际会话是这样组织的:

1. browser_navigate   → https://staging.example.com/campaign/618
2. browser_wait_for   → text: "立即领取"          # 等到关键文案出现,说明主接口回来了
3. browser_snapshot   → boxes: true, filename: "campaign-618.snapshot.md"
4. browser_take_screenshot → scale: "css", fullPage: true,
                             filename: "campaign-618.probe.png"

第 3 步的 boxes: true 值得单独说。它会在快照里给每个元素附上 [box=x,y,width,height],坐标是视口相对的 CSS 像素,来自 getBoundingClientRect。有了包围盒,Copilot 就不只是知道「这里有个倒计时」,而是知道「倒计时在视口 (980, 142) 位置、占 120×28」。

第 4 步的 scale 必须显式写 css。官方文档说得很清楚:css 产出按 CSS 像素计尺寸的截图,更小、跨设备一致;device 产出按设备像素计的高分辨率截图,会受 device pixel ratio 影响。默认虽然是 css,但视觉回归这种事,默认值要写成显式声明——你不会希望某台高分屏开发机悄悄产出一套 2 倍基线。

探完之后,把 campaign-618.snapshot.md 留在工作区,再对 Copilot 下这个指令:

基于 .mcp-artifacts/campaign-618.snapshot.md 里的页面结构,
为这个会场页生成 Playwright 视觉回归用例。要求:
- 结构里所有含时间、库存、价格、用户昵称的节点,全部进 mask
- 动画区域用 animations: 'disable' 而不是 mask
- 整页截图用 maxDiffPixelRatio,楼层组件单独截图用 maxDiffPixels
- 不要写死任何具体数值阈值,全部提到 config 里

四、生成的差异检测脚本长什么样

Copilot 基于快照交回来的东西,大致是这个形态。它比裸写的版本多了两处关键结构:mask 列表是从快照节点里推出来的,组件级和整页级用了不同的容忍策略。

// tests/visual/campaign.spec.ts
import {
    test, expect } from '@playwright/test';

const DYNAMIC_SELECTORS = [
  '[data-testid="countdown"]',        // 倒计时,每秒都在变
  '[data-testid="stock-badge"]',      // 库存角标
  '.price-current',                   // 实时价格
  '.user-nickname',                   // 登录态昵称
  '.coupon-remain',                   // 券余量
];

test.describe('618 会场页视觉回归', () => {
   
  test('整页版式', async ({
    page }) => {
   
    await page.goto('/campaign/618');
    // 等主接口 idle,不要用固定 sleep
    await page.waitForLoadState('networkidle');
    await expect(page.getByText('立即领取')).toBeVisible();

    await expect(page).toHaveScreenshot('campaign-618-full.png', {
   
      fullPage: true,
      animations: 'disable',
      mask: DYNAMIC_SELECTORS.map((s) => page.locator(s)),
      maxDiffPixelRatio: 0.002,
    });
  });

  test('楼层商品卡组件', async ({
    page }) => {
   
    await page.goto('/campaign/618');
    await page.waitForLoadState('networkidle');
    const card = page.locator('[data-testid="floor-card"]').first();

    await expect(card).toHaveScreenshot('floor-card.png', {
   
      animations: 'disable',
      mask: [page.locator('.price-current'), page.locator('.stock-badge')],
      maxDiffPixels: 60,          // 固定尺寸组件,用绝对像素数
    });
  });
});

配套的 config 把阈值集中收口,避免散落在各条用例里:

// playwright.visual.config.ts
import {
    defineConfig, devices } from '@playwright/test';

export default defineConfig({
   
  testDir: './tests/visual',
  // 基线路径带平台维度,避免 mac/linux 互相覆盖
  snapshotPathTemplate: '{testDir}/__screenshots__/{testFilePath}/{projectName}/{platform}/{arg}{ext}',
  fullyParallel: true,
  retries: 0,                       // 视觉回归不要重试,重试会把不稳定藏起来
  use: {
   
    viewport: {
    width: 1280, height: 720 },
    deviceScaleFactor: 1,           // 与 MCP 的 scale:'css' 对齐
    animations: 'disabled',
  },
  expect: {
   
    toHaveScreenshot: {
   
      threshold: 0.2,               // YIQ 感知色差,官方默认值,先不动
      animations: 'disable',
      maxDiffPixelRatio: 0.002,
    },
  },
  projects: [{
    name: 'chromium', use: {
    ...devices['Desktop Chrome'] } }],
});

五、三个旋钮各管什么:阈值与抗锯齿

很多人调视觉回归的方式是「一直调大阈值直到不报错」,这等于把功能关掉。正确的做法是先搞清楚三个旋钮分别管什么层级。

选项 管什么 默认值 什么时候动它
threshold 单个像素算不算变了。YIQ 色彩空间里的感知色差,0 严格、1 宽松 0.2 字体抗锯齿、渐变边缘出现细碎噪点时,小幅上调
maxDiffPixels 允许多少个像素变了,绝对个数 未设置 固定尺寸组件(按钮、卡片、图标),给绝对容忍量
maxDiffPixelRatio 变化像素占总像素的比例,0—1 未设置 整页截图必须用比例,否则大图天然吃亏
animations allow 保留动画/disable 把 CSS 动画冻结到终态 allow 有轮播、骨架屏、数字滚动时,必须设 disable
mask 用 Locator 指定整块遮罩掉的区域 无 时间戳、库存、价格、A/B 文案、头像
maskColor 遮罩填充色,v1.35 起接受 CSS 颜色 粉红 需要在 diff 图上一眼定位遮罩范围时

image.png

关于「抗锯齿怎么处理」,这里要给一个明确结论:Playwright 的抗锯齿容忍不是靠某个 anti-aliasing 开关,而是靠 threshold 在 YIQ 色彩空间里做感知色差判定。

YIQ 是按人眼亮度敏感度加权的色彩空间,用它算差值意味着「肉眼看不出的色差」天然被判为相同。字体边缘那种半透明过渡像素,绝大多数会被 0.2 的默认阈值吃掉。所以抗锯齿问题的第一反应应该是别动 threshold,先把 animations 关掉、把动态节点 mask 掉——真正制造大面积噪声的从来不是抗锯齿,是没冻住的动画和没遮住的数据。

只有在确认动画已冻结、动态区已遮罩之后,diff 图里剩下的仍是字体边缘的零星噪点,才轮到微调 threshold。

六、一个被忽略的参数:device 还是 css

browser_take_screenshot 和 toHaveScreenshot 都有 scale,取值 css 或 device。css 按 CSS 像素出图,跨设备一致;device 按设备像素出图,会吃 device pixel ratio。

雷区在于:MCP 探查时用了 device,而 config 里 deviceScaleFactor 是 1,两边产出的图尺寸根本不一样。 Copilot 基于探查截图推断出的包围盒和 mask 坐标,全会错位。

规矩定成两条就不会出事:探查阶段显式 scale: "css",config 里显式 deviceScaleFactor: 1,两边都写死,不依赖任何默认值。

--device "iPhone 15" 这类移动端仿真同理——要测移动端就单开一个 project、各自一套基线,别想用一套基线覆盖两种 DPR。

七、基线图怎么版本化

基线图是二进制资产,进了 git 就会带来两类麻烦:仓库膨胀,以及「谁更新的、为什么更新」查不到。

路径模板先解决第一层。snapshotPathTemplate 里带上 {projectName} 和 {platform}:

snapshotPathTemplate:
  '{testDir}/__screenshots__/{testFilePath}/{projectName}/{platform}/{arg}{ext}'

这个模板的默认形态是 {testDir}/__screenshots__/{testFilePath}/{arg}{ext},不带平台和 project 维度。多平台团队不显式加,就会在合并时才发现仓库里多了一套谁也说不清来路的基线图。

第二层是更新纪律,这条比路径更重要:

# 本地/CI 都只允许「生成 diff」,不允许就地覆盖基线
npx playwright test tests/visual --config=playwright.visual.config.ts --project=chromium

# 基线更新必须是一次显式、独立、可回溯的操作
git checkout -b chore/visual-baseline-618-restyle
npx playwright test tests/visual --config=playwright.visual.config.ts --update-snapshots
git add tests/visual/__screenshots__
git commit -m "chore(visual): 更新 618 会场页基线(版式改版 v3)

变更范围:楼层卡圆角 8px→12px、Banner 高度 -40px
判读人:@frontend-owner @qa-owner
关联设计稿:FIG-618-v3"

三条硬规矩:

基线更新永远走独立 PR,不和功能代码混在一起提交。 混提之后,review 的人看不出这次 diff 到底是代码改了还是基线改了,视觉回归就失去了作为门禁的意义。

commit message 里写清楚变更范围和判读人。 三个月后线上视觉出问题时,这是唯一能回答「这张基线当时是谁认可的」的东西。

不要让 CI 自动跑 --update-snapshots。 一旦 CI 能自己更新基线,这套门禁就永久绿了,而且绿得毫无信息量。CI 只负责产出 diff 工件,更新权留给人。

image.png

八、在 CI 里跑:渲染环境必须锁死

视觉回归在 CI 里挂掉,八成不是代码问题,是渲染环境不一致。

# .github/workflows/visual-regression.yml
name: visual-regression
on:
  pull_request:
    paths: ['src/**', 'tests/visual/**', 'playwright.visual.config.ts']

jobs:
  visual:
    runs-on: ubuntu-latest
    container:
      # 锁死渲染环境:字体、fontconfig、显卡驱动栈全在镜像里
      image: mcr.microsoft.com/playwright:v1.55.0-noble
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - name: 生成 diff(绝不更新基线)
        run: npx playwright test tests/visual --config=playwright.visual.config.ts --project=chromium
      - name: 上传三件套工件
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: visual-diff
          path: test-results/**/*-diff.png
          retention-days: 14

工件一定要传三件套:actual(实际)、expected(基线)、diff(差异叠加)。只传 diff 的话,review 的人看不出是「多了一块」还是「错位了」,判读成本会翻几倍。

本地和 CI 的差异来源,逐条对一遍:

差异来源 本地表现 CI 表现 锁法
字体渲染 系统字体,带 hinting 容器缺字体,回退默认字族 镜像内装齐 fontconfig 与业务字体,基线只在容器里生成
设备像素比 高分屏可能是 2 通常为 1 统一 scale: 'css' + deviceScaleFactor: 1
视口尺寸 各人显示器不同 无头默认值 config 里显式声明 viewport
动画与懒加载 手动跑,节奏慢,恰好渲染完 机器快,截图早于渲染 animations: 'disable' + networkidle,禁用固定 sleep
时区与本地化 本机时区 容器 UTC 镜像统一 TZ,动态时间一律进 mask

最容易忽略的是第一行。基线图必须在容器里生成,不能在开发机上生成后提交。 开发机生成的基线拿到 CI 上跑,第一次就会全红,然后团队就会开始调大阈值——整个门禁从这一刻起就废了。

九、写在最后

这条链路里,Copilot 负责把结构翻译成脚本,MCP 负责把活页面翻译成结构,而判断「什么不该被比对」这件事,仍然在人手上。

mask 列表是这套东西里唯一真正的资产。它记录的不是选择器,是这个业务里「哪些东西天生会变」的领域知识。这份知识 Copilot 能帮你从快照里捞出来,但不能替你确认。

阈值可以让工具定,基线可以让流程管,只有 mask 列表需要你逐条读一遍再签字。

我们在整理视觉回归在真实业务里的落地细节,如果你手上有一套跑不稳的基线,留言区说说卡在哪一层。

相关文章
|
2月前
|
人工智能 测试技术 调度
UI自动化测试提效必备Skill!一套CI流水线编排 Skill 可以直接抄了...
本文介绍 `ui-pipeline-scheduler`——UI自动化测试全链路编排技能。它将执行、诊断、重试、合并、报告五阶段自动串联,实现“一键启动、条件触发、熔断兜底、结果不丢”,解决人工串调、时机难判、死循环、数据失真等痛点,让测试人员从“操盘手”升级为“决策者”。
227 6
UI自动化测试提效必备Skill!一套CI流水线编排 Skill 可以直接抄了...
|
7月前
|
人工智能 开发者
阿里巴巴AI Coding 分享会—— Qoder Together 西安站来啦!
3月20日14:00-17:00,西安阿里丝路总部C1栋19层华山论剑培训室,Qoder开发者线下聚会!与技术团队面对面交流产品解析,听资深用户分享AI编程实战技巧。新手&高手皆欢迎!速报名→https://huodongxing.com/event/7851719226711
541 2
|
Linux 开发工具 Docker
Docker——配置国内源
云栖号资讯:【点击查看更多行业资讯】在这里您可以找到不同行业的第一手的上云资讯,还在等什么,快来! 1、创建或修改daemon.json daemon.json 文件路径为:/etc/docker/daemon.
Docker——配置国内源
|
9月前
|
人工智能 JSON 自然语言处理
【2026最新最全】一篇文章带你学会Qoder编辑器
Qoder是一款面向程序员的AI编程助手,集智能补全、对话式编程、项目级理解、任务模式与规则驱动于一体,支持模型分级选择与CLI命令行操作,可自动生成文档、优化提示词,提升开发效率。
14262 10
【2026最新最全】一篇文章带你学会Qoder编辑器
|
10月前
|
人工智能 自然语言处理 物联网
AI 智能化测试平台:支持手工测试用例自动化执行的企业级解决方案
测吧推出AI智能化测试平台,基于大模型与智能体技术,将自然语言用例自动转化为可执行测试,无需脚本即可完成Web系统自动化测试。支持用例生成、智能执行、自动断言与缺陷提交,显著降低企业测试成本,提升效率与覆盖率,助力测试能力从“个人经验”向“平台化”升级,已服务华为、招行、军工等高复杂度行业客户。
|
6月前
|
人工智能 自然语言处理 测试技术
Agent+MCP+Skills 重构自动化测试:从脚本生成到测试闭环
本文探讨AI驱动的测试范式变革:从单点脚本生成迈向“Agent+MCP+Skills”智能体系统,强调将测试经验工程化封装为可复用能力。核心在于重构全流程——规划、生成、执行、修复、沉淀,并依托知识库与工具链实现业务适配。未来测试工程师的核心竞争力,是设计智能化测试系统的能力。
|
8月前
|
人工智能 自然语言处理 安全
智能客服上线第一天就翻车?这5个测试点你一定没做
本文复盘智能客服上线首日翻车实录,总结五大测试盲区:只测“能答”不测“该拒答”、忽视多轮上下文、忽略情绪响应、缺失异常输入压力测试、轻视人机协同流程。强调AI测试核心不是“不崩”,而是“不失控”——宁可说“不知道”,不可胡编乱造。
|
6月前
|
人工智能 运维 Serverless
从0到1:3分钟搭建你的第一个企业级AI Agent实战指南
本文分享如何用阿里云函数计算AgentRun,零运维、低成本地从零搭建企业级AI Agent:支持模板开箱即用、高代码自定义、多Agent协同,并已落地吉利、森马等真实场景。(239字)
2348 0
|
6月前
|
人工智能 JavaScript Shell
AgentRun 实践指南:Agent 的宝藏工具——All-In-One Sandbox
All-In-One Sandbox(AIO)是AgentRun推出的云上一体化沙箱,集成浏览器、代码执行(Node.js/Python)、Shell终端与统一文件系统,支持VNC可视化调试。它解决传统多沙箱间文件共享难、编排复杂、环境冲突等痛点,显著提升LLM驱动Web自动化任务的开发效率与稳定性。
|
7月前
|
人工智能 IDE 测试技术
接口文档一丢,AI自动生成测试用例和自动化脚本?
AI IDE + MCP 正重塑软件测试:需求文档→AI自动生成测试用例与自动化脚本→CI自动执行。相比传统人工编写,它大幅提升效率;区别于知识库方案,AI IDE可操作文件、调用API、构建工程。核心前提:需求需结构化、清晰。

热门文章

最新文章