Playwright移动端设备模拟:不只是User-Agent

Playwright移动端设备模拟:不只是User-Agent 1. 为什么“模拟手机设备”不是简单改个 User-Agent 就完事很多人第一次接触移动端自动化测试时第一反应是“不就是把浏览器的 User-Agent 换成 iPhone 或 Android 的字符串吗加一行page.setUserAgent(Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) ...)就能跑起来了”——我去年在给一家电商 App 做 H5 流量漏斗复现时也这么天真地试过。结果是页面渲染错乱、触摸事件完全失灵、媒体查询media screen and (max-width: 480px)没生效、甚至某些 JS SDK比如微信 JSSDK 的wx.ready直接拒绝初始化。最后发现User-Agent 只是冰山露出水面的 5%而 Playwright 所谓的“模拟手机设备”本质是一整套协同工作的视口-设备-网络-行为仿真系统。它要同时满足至少六个维度的匹配缺一不可视口尺寸与 DPR设备像素比iPhone 14 Pro 是 390×844 3x不是随便设个 375×667 就叫“模拟 iPhone”Touch 事件支持开关必须显式启用hasTouch否则page.tap()会退化为click()而真实手机上 tap 和 click 的触发时机、冒泡路径、默认行为完全不同Viewport meta 标签解析逻辑Playwright 内部会模拟 WebKit/Blink 对meta nameviewport contentwidthdevice-width, initial-scale1的解析流程影响缩放、滚动惯性、字体渲染设备方向orientation传感器模拟横竖屏切换时window.orientation、screen.orientation.type、CSSmedia (orientation: landscape)必须同步响应网络条件模拟如 3G throttling真实弱网下资源加载顺序、JS 执行时机、图片懒加载触发点都会偏移仅靠前端navigator.onLine判断远远不够地理定位与权限提示框行为geolocationAPI 调用后是否弹出模拟权限弹窗、返回 mock 坐标、还是直接拒绝都需预设策略。这些不是“可选项”而是 Playwright 设备描述符DeviceDescriptor的硬性字段。你传入playwright.devices[iPhone 14 Pro]它背后加载的是一个 JSON 对象包含viewport、deviceScaleFactor、isMobile、hasTouch、defaultBrowserType等 12 个关键属性。漏掉任何一个你的“模拟”就只是徒有其表的幻觉。这也是为什么很多团队用 Puppeteer 做移动端测试总翻车——Puppeteer 的设备模拟是半手动拼装的而 Playwright 把整套设备能力封装成了原子化的、经过 Chromium/WebKit 实测验证的 descriptor。接下来我们就一层层拆开这个 descriptor 是怎么驱动真实行为的。2. Playwright 设备描述符的底层结构与三大核心字段解析Playwright 的设备模拟能力根植于其对 Chromium DevTools ProtocolCDP和 WebKit Remote Debugging Protocol 的深度封装。当你调用chromium.launch({ headless: true })后再执行browser.newContext({ ...devices[iPhone 14 Pro] })Playwright 实际做了三件关键的事向浏览器进程发送 CDP 命令Emulation.setDeviceMetricsOverride设置 viewport 宽高、DPR、屏幕方向发送Emulation.setTouchEmulationEnabled开启触摸事件注入通道在新建的 Page 实例中自动注入一段轻量级 polyfill劫持window.screen、window.matchMedia、navigator.maxTouchPoints等只读属性使其返回设备描述符中定义的值。这三步缺一不可。我们以playwright.devices[iPhone 14 Pro]为例看它的原始定义已精简保留核心字段{ name: iPhone 14 Pro, userAgent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1, viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true, defaultBrowserType: chromium }2.1 viewport 字段不只是宽高更是渲染管线的起点viewport看似简单但它直接决定HTMLhtml元素的clientWidth/clientHeightCSSvw/vh单位的基准值1vw viewport width / 100window.innerWidth/innerHeight的返回值document.documentElement.scrollWidth/scrollHeight的计算逻辑尤其当内容溢出时更关键的是它触发了浏览器的“移动优先”渲染模式。Chromium 在检测到viewport.width ≤ 480且isMobile true时会强制启用 mobile viewport 渲染引擎禁用 desktop-style 的双击缩放、调整字体大小等行为。实测对比若你手动设置viewport: { width: 390, height: 844 }但isMobile: false页面虽然看起来窄了但media (max-width: 480px)依然不生效因为媒体查询的判定依据是isMobileviewport.width的组合而非单纯宽度。提示不要试图用page.setViewportSize({ width: 390, height: 844 })替代设备描述符。前者只改 viewport不改isMobile、hasTouch等元信息属于“形似神不似”。2.2 deviceScaleFactorDPR 如何影响像素级渲染与截图精度deviceScaleFactorDPR是移动端测试中最容易被忽视、却最致命的字段。它的值决定了CSS 像素CSS pixel与物理像素device pixel的换算比例canvas绘图时ctx.scale(dpr, dpr)的默认缩放page.screenshot()输出图像的分辨率fullPage: true时截图宽度 viewport.width × dpr图片srcset属性中2x、3x候选资源的匹配逻辑。以 iPhone 14 Pro 的 DPR3 为例当你写div { width: 100px; height: 100px; }它实际占用 300×300 物理像素。如果错误地设为 DPR1那么截图会模糊100px 宽度只占 100 物理像素而非应有的 300高清图标img srcseticon2x.png 2x, icon3x.png 3x永远加载不到3x版本Canvas 绘制的线条、文字会发虚因为抗锯齿算法基于 DPR 计算。我在做某金融 App 的图表截图比对测试时就因忘记设置 DPR导致 baseline 截图和 test 截图在像素级比对pixelmatch时出现 12% 的差异率——问题根源就是 DPR 不一致导致 canvas 渲染偏移了半个像素。2.3 isMobile 与 hasTouch行为仿真的分水岭这两个布尔值是 Playwright 区分“桌面”与“移动”行为的开关isMobile: true启用 mobile viewport 模式、禁用右键菜单、禁用文本选中除非显式user-select: text、启用滚动惯性hasTouch: true让page.tap()方法可用并确保其触发touchstart→touchend事件流而非降级为mousedown→mouseup同时page.click()在hasTouch: true下也会优先尝试tap()除非目标元素明确禁用了触摸touch-action: none。一个典型陷阱某电商 H5 的“加入购物车”按钮绑定了touchstart事件用于快速响应但未绑定click。如果你用page.click()调用且hasTouch: false事件根本不会触发。正确做法是await page.tap(#add-to-cart-btn)前提是上下文启用了hasTouch。注意hasTouch并非“是否支持触摸”的硬件声明而是“是否启用触摸事件模拟”。即使你在桌面 Chrome 中运行只要hasTouch: truePlaywright 就会注入 touch 事件。3. 从零搭建可复现的移动端测试环境Context 配置的完整链路很多团队卡在第一步写了const iPhone devices[iPhone 14 Pro]; await browser.newContext(iPhone);结果报错TypeError: Cannot read property newContext of undefined。这不是代码问题而是对 Playwright 的 Context 生命周期理解有偏差。移动端测试不是“启动浏览器 模拟设备”两步而是一个五层嵌套的配置链路每一层都可能成为断点。3.1 第一层BrowserType 选择——Chromium、WebKit 还是 FirefoxPlaywright 支持三大浏览器引擎但移动端设备模拟能力差异巨大Chromium支持全部 iOS/Android 设备描述符DPR 最高支持到 4xhasTouch行为最接近真实 iOS SafariWebKit即 Safari仅支持 iOS 设备iPhone,iPad不支持 AndroidDPR 固定为 2x无法设为 3xgeolocation权限模拟不完善Firefox完全不支持设备描述符devices对象为空newContext({ ...devices[iPhone] })会静默忽略设备参数。因此99% 的移动端测试场景必须使用chromium.launch()。别被“跨浏览器测试”的宣传误导——移动端的“跨浏览器”本质是“跨 WebView 引擎”而 WebView 的底层就是 ChromiumAndroid或 WebKitiOS所以 Chromium 是最贴近生产环境的选择。3.2 第二层Browser Launch 参数——headless 模式下的隐藏限制chromium.launch({ headless: true })是常规写法但在移动端测试中headless: true会带来两个硬性限制无法模拟真实触摸事件headless 模式下 Chromium 的输入事件系统被大幅精简page.tap()会退化为page.click()且touchstart事件无法触发DPR 被强制锁定为 1无论你传入多高的deviceScaleFactorheadless 模式下截图永远是 1x 渲染。解决方案只有两个开发/调试阶段用headless: false启动真实 GUI 窗口所有设备能力全开CI/CD 阶段用headless: newChromium 112这是新版 headless 模式支持完整的触摸事件和 DPR 模拟但需确保 CI 环境的 Chromium 版本 ≥ 112。我在某次 CI 流水线中踩坑本地headless: false测试全绿CI 上headless: true却大量失败。排查日志发现page.tap()返回null最终确认是旧版 headless 的能力缺失。升级 Chromium 并改用headless: new后问题解决。3.3 第三层Context 创建——设备描述符必须作为顶层参数传入这是最常被写错的一层。正确写法是// ✅ 正确设备描述符作为 newContext() 的直接参数 const iPhone devices[iPhone 14 Pro]; const context await browser.newContext(iPhone); // ❌ 错误试图 merge 到其他配置里 const context await browser.newContext({ ...iPhone, permissions: [geolocation], // 这样写会覆盖 iPhone 的 hasTouch 等字段 });原因在于devices[iPhone 14 Pro]是一个完整、自洽的配置对象它内部各字段如viewport和deviceScaleFactor是强关联的。如果你用扩展运算符...解构并混入其他字段JavaScript 对象合并会覆盖原值。例如permissions字段不存在于 iPhone descriptor 中但isMobile、hasTouch等字段会被后续同名参数意外覆盖。更安全的做法是用merge工具函数Playwright v1.40 内置import { merge } from playwright/test; const context await browser.newContext( merge(devices[iPhone 14 Pro], { permissions: [geolocation] }) );3.4 第四层Page 创建与导航——viewport 重置的隐形陷阱context.newPage()创建新页面后viewport 会继承 Context 的设置但有一个例外当你调用page.goto(url)时如果目标页面的meta nameviewport标签与 Context 的 viewport 冲突Chromium 会优先遵循 meta 标签。这会导致Context 设置了390×844但页面 meta 写了width320最终 viewport 变成320×568页面布局错乱元素定位偏移。解决方案有两个强制重置 viewport在goto后立即调用page.setViewportSize()禁用 viewport meta 解析通过 CDP 命令Emulation.setDocumentContent绕过 meta 标签高级技巧见后文。我推荐方案一简单可靠const page await context.newPage(); await page.goto(https://m.example.com); await page.setViewportSize({ width: 390, height: 844 }); // 强制重置3.5 第五层权限与地理定位——如何让navigator.geolocation返回 mock 数据移动端 H5 常依赖定位服务。Playwright 提供了context.grantPermissions()和context.setGeolocation()两个 API但它们的生效时机很关键grantPermissions([geolocation])必须在page.goto()之前调用否则页面加载时的geolocation请求会被拒绝setGeolocation({ latitude: 31.2304, longitude: 121.4737 })设置的是全局坐标所有后续getCurrentPosition()调用都返回该值如果需要动态 mock可在页面中注入脚本覆盖navigator.geolocationawait page.addInitScript(() { const original navigator.geolocation; navigator.geolocation { getCurrentPosition: (success) success({ coords: { latitude: 31.2304, longitude: 121.4737, accuracy: 10 } }), watchPosition: () {}, clearWatch: () {} }; });实操心得在测试含定位的电商首页时我发现setGeolocation()对某些第三方 SDK如高德地图 JSAPI无效因为它们在页面加载早期就缓存了navigator.geolocation引用。此时必须用addInitScript方案且addInitScript要在goto之前执行。4. 真实业务场景还原电商 H5 “领券中心” 的端到端测试实战理论讲完现在用一个真实业务场景——某头部电商平台的“领券中心”H5 页面——来串联所有知识点。这个页面有四个关键交互点页面顶部 banner 轮播图依赖touchstart/touchmove/touchend手势中部“限时秒杀”商品列表点击跳转商品详情页需校验 URL 参数底部“我的优惠券”入口需 mock 地理位置触发 LBS 推荐右下角悬浮“客服”按钮固定定位需验证在不同 DPR 下不遮挡内容。我们将用 Playwright 编写一个覆盖全部功能的测试脚本并逐行解释每一步背后的原理。4.1 初始化精准匹配生产环境的设备与网络import { chromium, devices } from playwright; // 1. 选择 Chromium启用新版 headlessCI 环境 const browser await chromium.launch({ headless: process.env.CI ? new : false, args: [--disable-web-security, --disable-featuresIsolateOrigins] }); // 2. 使用真实设备描述符而非“近似值” const iPhone14Pro devices[iPhone 14 Pro]; // ✅ 不是 iPhone 12 或 iPhone // 3. 模拟 3G 网络真实用户常处弱网 const context await browser.newContext({ ...iPhone14Pro, // 网络节流下载 40 kbps上传 20 kbps延迟 300ms offline: false, // 先设为在线 }); await context.route(**/*, async (route) { // Playwright 无内置网络节流需用 CDP const client await context.newCDPSession(); await client.send(Network.emulateNetworkConditions, { offline: false, latency: 300, downloadThroughput: 40 * 1024, uploadThroughput: 20 * 1024, connectionType: cellular3g }); await route.continue(); }); // 4. 授予地理位置权限并设置 mock 坐标 await context.grantPermissions([geolocation]); await context.setGeolocation({ latitude: 31.2304, longitude: 121.4737 }); const page await context.newPage();这里的关键决策点为什么选iPhone 14 Pro而非iPhone 12因为该 App 的前端监控数据显示iPhone 14 系列占 iOS 流量 37%且其 DPR3 是当前主流高端机的代表能暴露3x图片加载、Canvas 渲染等兼容性问题为什么用 CDP 而非context.setOffline()因为setOffline()是全站断网而真实 3G 是“慢网”而非“断网”资源仍会加载只是耗时更长这对测试首屏时间、骨架屏展示逻辑至关重要。4.2 Banner 轮播图测试验证触摸事件链与手势容错await page.goto(https://m.example.com/coupon); // 等待 banner 加载完成避免 waitForSelector 依赖 DOM 结构 await page.waitForFunction(() document.querySelector(.banner)?.offsetHeight 0 ); // 1. 模拟向左滑动切换到第二张图 await page.touchscreen.tap(300, 200); // 点击中间区域 await page.touchscreen.press(ArrowRight); // 或用键盘模拟备用方案 // 2. 更真实的触摸手势模拟手指拖拽 const touchStartX 300; const touchStartY 200; const touchEndX 100; // 向左滑动 200px // Playwright 的 touchscreen API 不支持多点触控但单点拖拽足够 await page.touchscreen.down(touchStartX, touchStartY); await page.waitForTimeout(100); // 模拟按住 await page.touchscreen.move(touchEndX, touchStartY); await page.waitForTimeout(100); await page.touchscreen.up(); // 3. 验证轮播图已切换检查 active class 或 src await expect(page.locator(.banner-item.active)).toHaveCount(1); await expect(page.locator(.banner-item.active img)).toHaveAttribute(src, /banner2\.jpg/);这段代码的深意touchscreen.down/move/up是模拟真实手指轨迹的黄金组合比tap()更能触发touchmove事件适用于轮播图、地图拖拽等场景waitForTimeout(100)不是随意写的它模拟了真实用户“按住-滑动-松手”的节奏太快如 10ms会被浏览器识别为“点击”而非“滑动”验证src属性而非alt因为src是资源加载的实际依据alt可能被 SEO 优化填充不具备业务意义。4.3 商品列表跳转测试URL 参数校验与页面守卫// 点击第一个“限时秒杀”商品 await page.locator(.flash-sale-list .product).first().tap(); // 等待新页面加载并校验 URL const [newPage] await Promise.all([ context.waitForEvent(page), page.locator(.flash-sale-list .product).first().tap() ]); await newPage.waitForLoadState(networkidle); // 校验 URL 是否包含预期参数 await expect(newPage).toHaveURL(/\/product\/\d\?sourcecoupon_flash/); // 进一步校验商品详情页是否显示“来自领券中心”的标识 await expect(newPage.locator([data-sourcecoupon_flash])).toBeVisible();这里的关键技巧context.waitForEvent(page)是捕获新打开页面的唯一可靠方式page.waitForNavigation()在target_blank场景下会失效waitForLoadState(networkidle)比domcontentloaded更严格确保所有图片、JS、CSS 都加载完毕避免因资源未加载导致的断言失败toHaveURL()的正则匹配比字符串包含更健壮能应对 ID 动态变化如/product/123456。4.4 LBS 推荐模块测试mock 定位后的动态内容渲染// 返回领券中心页 await newPage.close(); await page.bringToFront(); // 滚动到底部触发“我的优惠券”入口可见 await page.evaluate(() window.scrollTo(0, document.body.scrollHeight)); // 等待 LBS 模块加载它依赖地理位置 API await page.waitForFunction(() document.querySelector([data-lbsrecommend])?.offsetHeight 0 ); // 验证推荐标题是否包含城市名 await expect(page.locator([data-lbsrecommend] h2)).toContainText(上海); // 验证推荐商品列表不为空 await expect(page.locator([data-lbsrecommend] .coupon-item)).toHaveCount(3);这个测试的难点在于LBS 模块通常采用“异步加载 缓存”策略。如果setGeolocation()太晚页面可能已用默认坐标如北京渲染完毕。因此setGeolocation()必须在goto()之前执行且waitForFunction要等待 DOM 元素真正渲染而非仅存在。4.5 悬浮按钮适配测试DPR 对 fixed 定位的影响// 截图整个页面用于视觉回归测试 await page.screenshot({ path: screenshots/coupon-center-iphone14pro.png, fullPage: true }); // 验证悬浮按钮在 DPR3 下不遮挡底部内容 const buttonBox await page.locator(.floating-button).boundingBox(); const footerBox await page.locator(footer).boundingBox(); // 计算按钮底部与 footer 顶部的距离像素 const gap footerBox.y - (buttonBox.y buttonBox.height); console.log(悬浮按钮与 footer 间距${gap}px); // 断言间距 20px避免遮挡 expect(gap).toBeGreaterThan(20);这里体现的是移动端测试的“像素级严谨”boundingBox()返回的是 CSS 像素坐标但screenshot()输出的是物理像素390×844 × 3 1170×2532所以截图能真实反映用户看到的清晰度gap计算基于boundingBox()它受 DPR 影响——在 DPR3 下buttonBox.height是 CSS 像素值但实际渲染高度是 3 倍所以间距判断必须基于 CSS 像素而非物理像素。5. 高阶技巧与避坑指南那些文档里不会写的实战经验写到这里你已经掌握了 Playwright 移动端测试的核心脉络。但真实项目远比教程复杂。以下是我在过去 18 个月、23 个不同行业客户项目中踩过的坑、总结的技巧全是文档里找不到的“血泪经验”。5.1 技巧一用 CDP 绕过 viewport meta 标签的强制覆盖如前所述页面的meta nameviewport会覆盖 Context 的 viewport 设置。有些老项目为了兼容 IE写了width320导致测试环境始终是 320px。官方文档建议“修改页面源码”但这在测试环境中不现实。真正的解法是用 CDP 强制禁用 viewport meta 解析const client await context.newCDPSession(); await client.send(Emulation.setDocumentContent, { frameId: (await page.mainFrame())._id, html: htmlbody/body/html }); // 然后立即 goto此时 meta 标签被忽略 await page.goto(https://m.example.com); await page.setViewportSize({ width: 390, height: 844 });原理setDocumentContent会清空当前 frame 的 DOM并注入空白 HTML从而让后续goto加载的页面失去“初始 meta 标签”的上下文。这是 Chromium 的底层机制稳定可靠。5.2 技巧二模拟横竖屏切换的完整事件链page.setViewportSize({ width: 844, height: 390 })只改了尺寸但不会触发resize、orientationchange事件。真实手机旋转时会依次触发window.orientation变为90或-90screen.orientation.type变为landscape-primarywindow.matchMedia((orientation: landscape)).matches变为trueresize事件。Playwright 提供了page.emulateMedia({ media: screen, colorScheme: light })但它不支持 orientation。终极方案是手动触发// 模拟横屏 await page.setViewportSize({ width: 844, height: 390 }); await page.evaluate(() { // 手动触发 orientationchange const event new Event(orientationchange); window.dispatchEvent(event); // 更新 screen.orientation需在 CDP 中注入 }); // 注入 CDP 修改 screen.orientation const client await context.newCDPSession(); await client.send(Emulation.setScreenOrientation, { orientation: { type: landscapePrimary, angle: 90 } });5.3 技巧三处理 WebView 中的window.webkit.messageHandlers很多 App 的 H5 会通过window.webkit.messageHandlers.xxx.postMessage()与原生通信。Playwright 默认不提供该对象导致 JS 报错中断。解决方案是在页面加载前注入 polyfillawait page.addInitScript(() { if (!window.webkit?.messageHandlers) { window.webkit { messageHandlers: {} }; } // 为每个 handler 添加 postMessage 方法 Object.keys(window.webkit.messageHandlers).forEach(key { window.webkit.messageHandlers[key] { postMessage: (data) console.log([Mock] ${key} received:, data) }; }); });5.4 避坑指南CI 环境中的字体渲染差异本地测试截图与 CI 截图对比时常出现文字模糊、行高不一致的问题。根源是本地 macOS 使用 San Francisco 字体CI Linux 使用 Noto Sans字体度量metrics不同导致line-height计算偏移进而影响元素高度和滚动位置。解决方案统一字体栈在测试前注入 CSS强制使用 Web 安全字体await page.addStyleTag({ content: * { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif !important; } });禁用字体平滑Linux CIawait browser.newContext({ ...devices[iPhone 14 Pro], // Linux 下添加字体渲染参数 args: [--font-render-hintingnone] });5.5 避坑指南page.tap()在某些元素上失效的根因page.tap()失效的常见原因有三个按优先级排查目标元素被pointer-events: none覆盖检查元素上方是否有透明遮罩层如 loading spinner元素visibility: hidden或opacity: 0tap()要求元素isVisible()为 true而opacity: 0的元素isVisible()返回 true但isIntersectingViewport()返回 falsehasTouch: false且元素监听touchstart此时tap()会静默失败无报错。终极调试法在tap()前加日志const element page.locator(#btn); console.log(Element visible:, await element.isVisible()); console.log(Element in viewport:, await element.isIntersectingViewport()); console.log(Element enabled:, await element.isEnabled()); await element.tap();我在某次金融 App 测试中发现tap()失效是因为一个position: fixed的广告条在z-index: 9999虽不可见但isVisible()为 true遮挡了下方按钮。解决方案是page.locator(#btn).click({ force: true })绕过可见性检查。6. 性能与稳定性优化让移动端测试在 CI 中稳定运行移动端测试比桌面端更脆弱网络波动、渲染延迟、触摸事件时序敏感。一套未经优化的测试在 CI 中失败率可能高达 30%。以下是经过 12 个大型项目验证的稳定性加固方案。6.1 超时策略分层设置拒绝一刀切Playwright 的默认超时是 30 秒但移动端场景需精细化全局超时test.setTimeout(60000)60 秒覆盖整个测试用例动作超时page.tap(selector, { timeout: 10000 })触摸操作设为 10 秒因弱网下元素渲染慢等待超时page.waitForSelector(selector, { state: visible, timeout: 15000 })可见性等待设为 15 秒网络超时page.goto(url, { waitUntil: networkidle, timeout: 45000 })networkidle 设为 45 秒容忍 CDN 缓慢。理由移动端首屏时间FCP在 3G 下平均为 3.2 秒networkidle需等待所有资源包括图片、字体加载45 秒是 P95 分位数的合理上界。6.2 重试机制用test.retry()替代 try-catchPlaywright Test 内置重试比手动 try-catch 更优雅test(领券中心 - banner 滑动, async ({ page }) { // 测试逻辑 }, { retry: 2 }); // 失败后重试 2 次共运行 3 次优势重试时会重新创建 Page 和 Context彻底隔离状态测试报告中会标记为 “flaky”便于后续分析不污染主逻辑无需写冗余的if (failed) await page.reload()。6.3 视觉回归用 pixelmatch 做像素级比对而非简单截图page.screenshot()只是保存图片真正的视觉回归需要比对。我推荐pixelmatch库它能忽略抗锯齿导致的亚像素差异设置颜色容差threshold: 0.1输出差异掩码图diff.png直观定位问题区域。import pixelmatch from pixelmatch; import { PNG } from pngjs; const baseline PNG.sync.read(fs.readFileSync(baseline.png)); const current PNG.sync.read(fs.readFileSync(current.png)); const diff new PNG({ width: baseline.width, height: baseline.height }); const numDiffPixels pixelmatch( baseline.data, current.data, diff.data, baseline.width, baseline.height, { threshold: 0.1 } ); if (numDiffPixels 100) { // 允许 100 像素内差异 fs.writeFileSync(diff.png, PNG.sync.write(diff)); throw new Error(视觉差异过大${numDiffPixels} 像素); }6.4 日志与调试在 CI 中保留失败现场CI 失败时光有截图不够。必须保留控制台日志page.on(console, msg console.log(msg.text()))网络请求日志page.on(request, req console.log(req.url()))错误堆栈page.on(pageerror, error