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

举报
霍格沃兹测试学社 发表于 2026/09/11 16:34:40 2026/09/11
【摘要】 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_screenshottoHaveScreenshot 都有 scale,取值 cssdevicecss 按 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 列表需要你逐条读一遍再签字。

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

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。