拆字节开源的 Midscene.js:GUI Agent 做 UI 测试,为什么可以不写选择器
先摆一个反直觉的事实: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 看网页截图」的工具,官方立场恰恰是不看截图。

那它凭什么能帮 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 图上一眼定位遮罩范围时 |

关于「抗锯齿怎么处理」,这里要给一个明确结论: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 工件,更新权留给人。

八、在 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 列表需要你逐条读一遍再签字。
我们在整理视觉回归在真实业务里的落地细节,如果你手上有一套跑不稳的基线,留言区说说卡在哪一层。
- 点赞
- 收藏
- 关注作者
评论(0)