EUI 视觉回归测试实战:基于 Playwright 与 Storybook 的截图快照体系(eui VRT)
【免费下载链接】euiElastic UI Framework 🙌项目地址: https://gitcode.com/GitHub_Trending/eu/eui
EUI 使用 Playwright Test Runner 配合jest-image-snapshot,对 Storybook 中的每一个 story 进行像素级截图比对,并在 CI 中以多视口变体(desktop/mobile)自动守护组件的视觉一致性。本篇完整讲解 VRT 的本地运行方式、基线更新、变体与过滤器机制、自定义选择器与交互脚本编写,以及“如何写出稳定 story”这一核心方法论;读完后你可以独立在本地跑通 VRT、正确更新基线,并从源码层面理解 EUI 为保证截图确定性所做的一切工程化设计(见 packages/eui/.storybook/test-runner.ts 与 packages/eui/scripts/test-visual-regression.js)。
VRT 的整体机制与运行流程
EUI 的视觉回归测试运行在一个实时运行的 Storybook 实例上:测试器遍历 story,对其截图,并与先前审批通过的参考图(baseline)做比对。测试针对 PR 流水线中构建出的 Storybook 产物运行,并在本地 VRT agent 上托管——也就是最终会被部署为预览的同一份文件。当发现差异时,CI 会把 diff 表格以 PR 评论形式发出,同时 Buildkite 会出现一个人工审批(block)步骤,审批通过后才会更新基线。
重要提示:VRT 在CI中运行,CI 拥有基线并会自动将其提交到你的 PR。只有当你真的需要批量验证大量 story 的渲染结果时,才需要在本地运行。
源码级实现:截图与比对的完整链路
从源码结构看,截图比对的真正执行逻辑集中在 packages/eui/.storybook/test-runner.ts:
SCREENSHOT_OPTIONS = { animations: 'disabled' }:截图前暂停 CSS 动画,避免循环动画(如 spinner)导致稳定性超时;FAILURE_THRESHOLD_PIXELS = 4:允许 4 像素以内的亚像素噪声,比对采用failureThresholdType: 'pixel';postVisit钩子中依次执行waitForPageReady→ 等待所有<img>加载完成 → 等待字体就绪(document.fonts.ready)→ 等待两帧requestAnimationFrame使布局稳定 → 等待 EUI 图标完成懒加载([data-is-loading]占位消失)——这些等待步骤正是文档中“runner 会等待页面就绪”的具体实现;- 当 baseline 文件不存在时,直接写入
packages/eui/.vrt/reference/(而不是走 Jest 的 CI 拦截逻辑),这就是“CI 会自动为新 story 提交基线”的底层原因。
对比结果输出目录约定(见 test-runner.ts 中的customSnapshotsDir/customDiffDir/customReceivedDir):参考图在 packages/eui/.vrt/reference/,diff 图与失败时的实际截图分别写入.vrt/diff与.vrt/current。当前仓库中 reference 目录已积累了一千余个基线截图文件(desktop 与 mobile 各一份)。
相关的 npm scripts 定义在 packages/eui/package.json:
"test-visual-regression": "node ./scripts/test-visual-regression.js", "build-storybook": "NODE_OPTIONS=--max-old-space-size=4096 storybook build", "serve-storybook": "http-server storybook-static --port 6006 --silent", "test-storybook": "test-storybook"本地运行 VRT
先确保已安装并正在运行 Docker——它用于在与 CI 一致的 Linux 环境中截图。运行命令:
yarn workspace @elastic/eui test-visual-regression本地运行时,包装脚本会构建并托管一个静态 Storybook(与 CI 行为一致),且每次运行都会重新构建,因此无需另开 dev server,且每次运行都反映你当前的 story。若要针对某个特定 URL(例如已部署的 PR 预览)测试:
yarn workspace @elastic/eui test-visual-regression -- --url https://eui.elastic.co/pr_1234/storybook包装脚本做了什么:参数解析与 Docker 执行
packages/eui/scripts/test-visual-regression.js 使用 yargs 解析出四个开关,默认值对行为影响很大:
| 参数 | 默认行为 | 作用 |
|---|---|---|
--url | 无 | 指定 Storybook 地址;设置后--static/--build被忽略 |
--docker | 本地为true,CI 中为false | 是否在 Linux 容器中运行以生成与 CI 一致的截图 |
--static | 本地为true | 托管静态构建而非 dev server,规避networkidle挂起问题 |
--build | true | 运行前重新构建静态 Storybook;--no-build可复用已有storybook-static/ |
Docker 路径的具体实现(见 test-visual-regression.js)值得注意:
- 镜像选择
mcr.microsoft.com/playwright:v{playwrightVersion}-jammy,版本号直接读取本地@playwright/test的 package.json,保证浏览器版本与测试器一致; - 固定
--platform linux/amd64,使截图在字节层面与 CI 可比——这也是后文“Apple Silicon 上偏慢但结果一致”的根源; - 容器内先通过
n安装.nvmrc指定 Node 版本、启用 corepack、yarn安装依赖、yarn playwright install chromium,再把整个 monorepo 根目录挂载进容器以解析workspace:*依赖; - 每个变体以
VRT_VARIANT={variant} yarn test-storybook --maxWorkers=2 --testTimeout=60000的方式独立执行,--maxWorkers=2与加大的--testTimeout为模拟(emulated)环境预留余量; - 静态模式下的构建发生在容器内而非宿主机上——因为容器里的
yarn会把挂载的node_modules中的二进制替换为 Linux 版本,在宿主机上先构建会导致产物在容器内失效。
静态构建 vs. dev server
静态构建从扁平文件托管(没有 HMR),因此networkidle能瞬间就绪;而 dev server 的 HMR 会持续占用网络,在模拟环境下让waitForPageReady停滞直至 story 超时(参见文末故障排查)。构建发生在容器内(Linux,与 CI 一致),并在各变体之间复用:
# 复用已有构建以加快迭代(跳过重建) yarn workspace @elastic/eui test-visual-regression -- --no-build # 改用 dev server(需先运行 `yarn storybook --no-open`) yarn workspace @elastic/eui test-visual-regression -- --no-static--static/--build在本地默认开启,且当设置了--url时会被忽略。
更新基线截图
# 更新全部基线 yarn workspace @elastic/eui test-visual-regression update # 针对特定 URL 更新基线 yarn workspace @elastic/eui test-visual-regression update -- --url https://eui.elastic.co/pr_1234/storybook参考图存放在 packages/eui/.vrt/reference/。从源码看,update子命令会被包装脚本识别后翻译为--updateSnapshot传给test-storybook(见 test-visual-regression.js 的isUpdate判断)。
变体(Variants):多视口截图
每个 story 都会在多个变体下截图,以便捕获例如响应式布局这类回归。每个变体生成独立的基线文件,并以变体名作为后缀:
packages/eui/.vrt/reference/ navigation-euibutton--playground-desktop.png navigation-euibutton--playground-mobile.pngtest-runner 对每个变体各调用一次进程(类似 Playwright projects),因此每个变体都在自己的进程与浏览器上下文中运行,且视口在 story 渲染之前就已应用。变体定义在 packages/eui/.storybook/vrt-variants.json,当前内容如下:
{ "desktop": { "name": "desktop", "viewport": { "width": 1440, "height": 900 } }, "mobile": { "name": "mobile", "viewport": { "width": 390, "height": 844 } } }| 变体 | 视口 |
|---|---|
desktop | 1440 × 900 |
mobile | 390 × 844 |
CI 中每个变体并行占用一个作业;本地则由 scripts/test-visual-regression.js 读取该文件、按序为每次运行设置VRT_VARIANT环境变量(见脚本中的runVariants循环,它会跑完所有变体即使某个失败)。
变体机制在浏览器侧的落地同样有源码依据:test-runner.ts 的preVisit钩子先注入一段禁用所有animation/transition的<style>,再setViewportSize应用变体视口,随后把当前变体写入<html>的data-vrt-variant属性(常量VRT_VARIANT_ATTRIBUTE,定义于 packages/eui/.storybook/vrt.ts),并模拟prefers-reduced-motion: reduce,使尊重该媒体查询的 EUI 组件以静态形态渲染。
跳过特定变体
如果某个 story 在某一变体下无法正确渲染,可以只对那个变体退出——向parameters.vrt.skip传一个数组即可(见跳过 story)。该 story 在其余变体下仍会运行。判定逻辑在 vrt.ts 的isVariantSkipped:skip === true || skip.includes(variant)。
过滤 story
任何test-storybook的 flag 都可以在--之后传入。Jest 的路径过滤器需要在模式前加--:
# 按 story 名 yarn workspace @elastic/eui test-visual-regression -- -- euibutton # 按 story 文件 yarn workspace @elastic/eui test-visual-regression -- -- --testPathPattern=button.stories # 按 tag yarn workspace @elastic/eui test-visual-regression -- --includeTags vrt-only yarn workspace @elastic/eui test-visual-regression -- --excludeTags skip-vrt包装脚本会跑全部变体。若要只跑一个变体:
VRT_VARIANT=desktop yarn workspace @elastic/eui test-storybook -- euibutton注意这里绕过了包装脚本直接调用test-storybook,VRT_VARIANT未设或非法时,test-runner.ts 会回退到desktop。
跳过 story
设置parameters.vrt.skip让 story 退出 VRT,并留下注释说明原因:
skip: true跳过所有变体;skip: ['mobile']只跳过列出的变体,story 在其余变体下照常运行。
export const MyStory: Story = { parameters: { vrt: { // Skipped: this story is interaction-only, not a visual state skip: true, }, }, }; export const MobileUnsupported: Story = { parameters: { vrt: { // Skipped on mobile: the toolbar control is hidden below this breakpoint skip: ['mobile'], }, }, };跳过某个变体同时也会跳过该 story 在此变体下的play主体,例如交互操作不会在一个该 story 并非为其设计的视口下执行。这一行为由playDecorator实现:它读取context.parameters?.vrt?.skip,并结合<html>上的data-vrt-variant属性判断当前变体是否被跳过(见 vrt.ts)。
当你为之前已有基线的 story 添加vrt.skip时,需要手动从 packages/eui/.vrt/reference/ 删除受影响的快照文件——true时删所有变体,数组时只删列出的那些。
使用非默认选择器
默认情况下,test-runner 对#story-wrapper > *截图。对于渲染在 story wrapper 之外(portal、popover、下拉菜单)的组件,需要在parameters.vrt.selector中指定自定义选择器。预定义选择器从 packages/eui/.storybook/vrt.ts 导出:
import { VRT_SELECTORS } from '../../../.storybook/vrt'; export const Open: Story = { parameters: { vrt: { selector: VRT_SELECTORS.portal, }, }, };可用的选择器(源码中为VRT_SELECTORS常量,见 vrt.ts):
| 选择器 | 值 | 适用场景 |
|---|---|---|
VRT_SELECTORS.default | #story-wrapper > * | 默认(无需设置) |
VRT_SELECTORS.textOnly | #story-wrapper | 渲染为文本节点的组件 |
VRT_SELECTORS.portal | page | Portal 化元素(popover、tooltip、dropdown);对整页截图 |
从源码可以看到,selector === 'page'时走page.screenshot()全页截图,否则取locator(selector).first()的截图(test-runner.ts)。
用交互脚本达到特定状态
当play函数需要触达 story wrapper 之外的元素(例如 portal)时,用playDecorator包裹它。它向 context 注入bodyElement(即document.body),使within(bodyElement)能查询 portal 内容:
import { userEvent, waitFor, within, expect } from '@storybook/test'; import { VRT_SELECTORS, playDecorator } from '../../../.storybook/vrt'; export const OpenDropdown: Story = { parameters: { vrt: { selector: VRT_SELECTORS.portal }, }, play: playDecorator(async (context) => { const { canvasElement, bodyElement } = context; // canvasElement: story wrapper 内的内容 const canvas = within(canvasElement); // bodyElement: 用它可以触达 portal 化元素 const body = within(bodyElement); await userEvent.click(canvas.getByRole('combobox')); await waitFor(() => { expect(body.getByRole('listbox')).toBeVisible(); }); }), };playDecorator还有一个默认门控:通过navigator.webdriver判断是否由 Playwright 驱动浏览器,非 VRT 环境(即 Storybook UI 中)默认跳过 play 主体。传入false作为第二个参数可在所有环境都执行:
play: playDecorator(async (context) => { ... }, false)bodyElement的实现细节(vrt.ts)使用context.canvasElement.ownerDocument.body而非parentElement,以确保元素在任何挂载方式下都可用。
EUI 定制版within:等待 Popover 等复杂组件
如果 play 函数需要等待 EUI 自身组件(尤其是 popover),EUI 在 packages/eui/.storybook/test.ts 中封装了扩展版within,在 storybook 原生within之上叠加了:
waitForAndClick(testSubject):等待data-test-subj元素挂载后点击;waitForEuiPopoverVisible():等待[data-popover-open]可见、字体就绪、派发resize事件,并持续轮询[data-popover-panel]的getBoundingClientRect(),直到面板位置稳定(popover 会占据可用空间,锚点位置不同会导致其位移,必须等它停住);waitForEuiPopoverHidden():等待 popover 从文档中移除。
这正是下文“overlay 未渲染完就被截图”一类问题的官方解法。
编写稳定的 story
VRT 逐像素比对截图(4 像素亚像素容差),因此任何非确定性因素(网络请求、动画、随机性、时序或截错元素)都会造成误报 diff 或不稳定失败。test-runner 已在全局层面中和了多类来源(对应 test-runner.ts 中的实现):
- 截图前暂停 CSS 动画(
animations: 'disabled'),并模拟prefers-reduced-motion: reduce; - runner 等待页面就绪:所有
<img>加载完成、字体就绪、布局稳定后才截图; - 失败的截图会自动重试。
以下是常见失败模式与修复方式:
只截到第一个元素(Fragment 根节点)
默认选择器#story-wrapper > *截取的是 wrapper 的第一个子元素。因此render函数或 decorator 若返回含多个兄弟节点的 fragment,只会截到第一个并裁剪掉其余部分:
// ❌ 只会截到第一个 <EuiBanner> render: () => ( <> <EuiBanner /> <EuiBanner /> </> ), // ✅ 包在单个元素里,整组都会被截取 render: () => ( <div> <EuiBanner /> <EuiBanner /> </div> ),这对“添加兄弟内容”的 decorator(例如把 story 包进说明文字)同样适用——请包在元素里,不要用 fragment。
Portal 化内容没被截到
Popover、tooltip、modal、flyout、dropdown 渲染在 story wrapper 之外,默认选择器会漏掉它们。使用 portal 选择器做整页截图(见使用非默认选择器):
parameters: { vrt: { selector: VRT_SELECTORS.portal } },Overlay 在打开动画完成前就被截图
挂载时即打开的 overlay(通过isOpen或初始 state)可能在截图触发时尚未渲染/定位完成,产生空白或半定位的截图并导致 flaky。加一个等待其可见的play:
import { within } from '../../../.storybook/test'; import { playDecorator } from '../../../.storybook/vrt'; export const Open: Story = { parameters: { vrt: { selector: VRT_SELECTORS.portal } }, play: playDecorator(async ({ canvasElement }) => { await within(canvasElement).waitForEuiPopoverVisible(); }), };对 portal 化的面板,也可以断言bodyElement:
play: playDecorator(async ({ bodyElement }) => { await waitFor(() => expect(bodyElement.querySelector('[data-popover-open]')).toBeVisible() ); }),远程资源(图片、头像、字体)
不要引用远程 URL(placehold.co、images.unsplash.com、picsum.photos、gravatar等)。网络抓取在 CI 中慢且不可靠,会造成不稳定。改用data:URI 内联资源:
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="100%" height="100%" fill="#0B64DD"/></svg>`; <EuiAvatar imageUrl={`data:image/svg+xml,${encodeURIComponent(svg)}`} />若 story 确实需要真实位图(例如EuiImage展示),要么提交并打包本地资源,要么vrt.skip掉该 story。
随机化或时间相关数据
无 seed 的faker、Math.random()、Date.now()、new Date()每次运行都会产生不同输出。固定它们:
import { faker } from '@faker-js/faker'; faker.seed(123); // 各次运行间数据一致任何会被渲染的时间/数值都用固定值(例如date="January 1st 1970")。
超大 story
渲染数百行/项的 story 会产生非常长的截图,截图慢且可能超时(在mobile变体下尤甚,因为响应式布局会把每个单元格纵向堆叠)。保持数据集精简。注意EuiBasicTable/EuiInMemoryTable本身不会分页items,传入大数组就会渲染全部行。
纯交互/行为型 story
如果 story 只为演示行为(焦点归还、回调、键盘处理)且与另一个已截图 story 视觉上完全相同,它对 VRT 没有价值,只会引入 flake 风险——用vrt.skip和注释跳过它。
纯文本组件
裸文本节点在#story-wrapper > *下高度可能为零。使用VRT_SELECTORS.textOnly,让 wrapper 本身被截取(见使用非默认选择器)。
故障排查
所有 story 都报page.waitForLoadState: Timeout 30000ms exceeded
waitForPageReady等待networkidle,用的是 Playwright 自身的 30 秒超时(与 Jest 的--testTimeout无关)。dev server的 HMR 会让网络一直忙碌;在模拟(emulation)下它永远无法及时空闲,于是每个story 都超时,整个套件跑 10–20 分钟。
修复:本地不要传--no-static。默认的静态构建没有 HMR,networkidle能瞬间就绪。
Apple Silicon 上本地 VRT 很慢或超时
本地运行使用linux/amd64容器以匹配 CI。在 Apple Silicon(arm64)上该镜像是模拟运行的,比原生慢得多,大 story 可能挂起或超时:
- 启用 Rosetta(推荐)。在 Docker Desktop 中打开Settings → General → "Use Rosetta for x86_64/amd64 emulation on Apple Silicon",并在Settings → Resources中调高 CPU/内存。它比默认 QEMU 模拟快得多,同时仍是
linux/amd64,截图与 CI 保持字节级一致。 - 仅快速自检时运行原生镜像。去掉
--platform linux/amd64会运行原生 arm64 镜像(无模拟、快得多)。渲染结果通常一致,但架构可能带来 1–2px 的反锯齿差异,因此不要用这种方式提交基线——只用来确认 story 能跑,基线交给 CI 生成。
此外,Docker 路径在 scripts/test-visual-regression.js 中还会限制--maxWorkers并调高--testTimeout以预留余量(如前文参数表所述)。
CI 流水线架构
VRT 作为 Buildkite 的eui-deploy-docs流水线的一部分,在每个 pull request 上自动运行。整体流程如下:
CI 会把基线直接提交到 PR 分支,形成两种提交:
chore(eui): add VRT baseline screenshots—— PR 新增了 story。无论通过与否自动执行;chore(eui): update VRT baseline screenshots—— 在 Buildkite 中审批Approve visual changesblock 步骤之后。
一个同时包含新增与变更 story 的 PR 会收到两个提交,add在前。任一提交都会重新触发 CI。这套“新基线自动写入”的能力与 test-runner.ts 中“baseline 不存在则直接写文件”的实现相互印证。
在 PR 中跳过 VRT
如果 VRT 本身坏了并阻塞合并,在 GitHub PR 上添加skip-vrtlabel。VRT 步骤检测到该 label 后会不运行任何测试直接退出,notify 评论会明确说明 VRT 已被跳过。
警告:
skip-vrt不会运行 test runner,因此新 story 不会获得基线。如果你的 PR 本身引入了视觉变更,添加它时要格外小心。
label 是在构建触发时读取的。要影响已存在的构建,需触发一个全新构建:
- 在 PR 上评论
buildkite test it; - push 任何新提交;
- 关闭再重新打开 PR;
- Buildkite UI:点击New Build(不是 "Rebuild",后者会复用原有环境变量)。
小结:VRT 体系的关键文件索引
| 职责 | 文件 |
|---|---|
| 本地/CI 包装脚本(Docker、静态构建、变体循环) | packages/eui/scripts/test-visual-regression.js |
| test-runner 配置(等待策略、截图与比对、基线写入) | packages/eui/.storybook/test-runner.ts |
| 变体定义(desktop / mobile 视口) | packages/eui/.storybook/vrt-variants.json |
VRT_SELECTORS、playDecorator、skip 判定 | packages/eui/.storybook/vrt.ts |
定制版within(popover 等待等 EUI 专用查询) | packages/eui/.storybook/test.ts |
| 基线参考图目录 | packages/eui/.vrt/reference/ |
| 相关 npm scripts | packages/eui/package.json |
掌握以上文件与文档约定后,你就能在 EUI 中安全地新增 story、正确处理 portal 化组件的截图、按变体粒度跳过不稳定 story,并理解 CI 基线自动提交机制背后的每一步实现。
【免费下载链接】euiElastic UI Framework 🙌项目地址: https://gitcode.com/GitHub_Trending/eu/eui
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考