1. 为什么我要自己造一个Web UI巡检轮子
做前端或者测试的同学大概都有过这种体验:项目上线之后,每天最怕的不是写代码,而是老板突然在群里甩一张截图,说“这个页面怎么白了”“这个按钮怎么点不动了”。尤其是那种多系统、多环境、多租户的后台产品,页面数量动辄上百个,靠人工每天点一遍根本不现实。我所在的团队维护着一套面向企业客户的管理平台,光是核心业务流程就有二十多条,加上各种配置页、报表页、权限页,手工回归一轮下来至少两个人天。更麻烦的是,很多问题不是功能逻辑挂了,而是样式错乱、资源加载失败、接口返回空数据这类“看起来能用但其实已经坏了”的情况,人工巡检很容易漏掉。
online-inspection-tracker这个项目就是在这个背景下折腾出来的。它的定位很明确:一个轻量级的Web UI自动化巡检工具,核心目标是用尽量低的维护成本,把“页面能不能正常打开、关键元素在不在、核心接口通不通、控制台有没有报错”这几件事自动化掉,并且把结果以人看得懂的方式呈现出来。它不追求替代完整的E2E测试框架,而是填补“上线后日常巡检”这个空档。适合谁来参考?我觉得三类人最对口:一是中小团队里没有专职测试、前端自己兼着做质量保障的开发者;二是测试同学想快速搭一套冒烟巡检体系但不想引入太重框架的;三是对自动化感兴趣、想拿一个真实项目练手的学习者。
我先把结论摆出来:这套东西的技术底座并不复杂,主流方案就是Playwright 或 Puppeteer 驱动无头浏览器 + 定时任务调度 + 结果落库与告警。真正难的不是“怎么打开一个页面”,而是“怎么让这套巡检长期稳定运行、误报少、维护成本低”。后面我会把选型逻辑、核心实现、踩过的坑全部摊开讲,你能直接照着复现。
2. 整体架构设计与技术选型拆解
2.1 巡检工具的本质是什么
很多人一上来就想上Selenium,我建议先想清楚巡检和功能测试的区别。功能测试是“给定输入,断言输出等于预期”,它要求环境干净、数据可控、步骤完整;而巡检是“在真实运行的系统上,周期性确认它还活着且关键特征没变”。这两者的诉求完全不同:巡检要的是快、稳、低维护、可批量,它不需要覆盖所有分支,只需要盯住那些“一旦坏了就说明出大事”的信号点。
基于这个认知,我把巡检的检查项归纳成四类,这也是整个工具设计的骨架:
| 检查类型 | 具体内容 | 判断依据 | 优先级 |
|---|---|---|---|
| 可用性检查 | 页面能否正常加载、HTTP状态码 | 状态码200且DOM就绪 | 最高 |
| 元素存在性 | 关键按钮、表格、图表容器是否存在 | 选择器命中数量大于0 | 高 |
| 数据渲染检查 | 列表是否有数据、统计数字是否非空 | 文本内容非空或数量达标 | 高 |
| 运行时健康 | 控制台报错、接口请求失败 | 错误数量阈值为0 | 中高 |
把这四类想明白,你就知道工具不需要多复杂,它本质上是一个“带断言的批量页面访问器”。这个认知直接决定了后面的技术选型——不要为了巡检去背一整套测试框架的包袱。
2.2 为什么选 Playwright 而不是 Selenium
选型这块我对比过三个方案,最后落在 Playwright 上,理由很实在。Selenium 生态最成熟,但它的启动速度慢、对现代前端框架(尤其是SPA)的等待机制不够优雅,经常要手写一堆显式等待,巡检场景下页面多、跑得频繁,这些等待累积起来很要命。Puppeteer 速度快、API 简洁,但它对多浏览器的支持偏弱,而且早期版本在跨平台稳定性上有些小毛病。Playwright 则兼顾了速度和跨浏览器能力,自带智能等待(元素可交互才操作),并且它的page.on('console')和page.on('requestfailed')事件监听非常顺手,正好对应我要的“运行时健康检查”。
还有一个很实际的原因:Playwright 的 trace 和截图能力开箱即用,巡检失败时自动留证据,这对排查问题太重要了。你想想,半夜定时任务跑挂了,第二天早上你看到的不应该只是一行“元素未找到”,而应该是一张截图加一段控制台日志,直接定位问题。
2.3 整体模块划分
整个工具我拆成五个模块,职责清晰、互不耦合:
- 配置层:用一份 YAML 或 JSON 描述“巡检哪些页面、每个页面检查什么”,这是维护成本的核心,必须让人改起来无痛。
- 执行引擎:基于 Playwright 封装页面访问、断言、证据采集的通用逻辑。
- 调度层:定时触发,可以用系统 cron,也可以用 Node 的 node-cron,看部署环境。
- 结果存储:轻量场景直接写 JSON 文件或 SQLite,团队规模大了再上数据库。
- 告警通知:失败时推送到企业微信、钉钉或邮件,这是让巡检“有用”的关键一环。
这个分层的好处是,每一块都能单独替换。比如你以后想换调度方式,或者想接不同的告警渠道,都不用动执行引擎。我见过太多人把巡检脚本写成一个几百行的巨型文件,页面一多就彻底没法维护,这是要极力避免的。
2.4 配置驱动的设计哲学
我特别想强调配置驱动这一点。巡检工具的生命周期里,最频繁的操作不是写代码,而是“加一个页面”“改一个选择器”。如果每次都要改代码、重新部署,那这套工具活不过三个月。所以我把所有页面相关的信息全部外置到配置文件里,执行引擎只负责读配置、跑流程。一个典型的巡检项配置长这样:
- name: 用户管理列表页 url: https://example.com/admin/users waitFor: ".user-table" checks: - type: elementExists selector: ".user-table tbody tr" minCount: 1 message: 用户列表无数据 - type: noConsoleError message: 页面存在控制台报错 - type: noFailedRequest message: 存在失败的网络请求 screenshotOnFail: true这份配置的每一行都在回答“检查什么、怎么判断、失败怎么说”。新人接手时看配置文件就能明白巡检覆盖了什么,不需要读源码。这就是配置驱动带来的可维护性红利。
3. 核心实现细节与关键代码拆解
3.1 环境准备与依赖安装
先把地基打好。Node 环境建议 18 以上,Playwright 对 Node 版本有要求,太老的版本会踩兼容坑。初始化项目后装依赖:
mkdir online-inspection-tracker && cd online-inspection-tracker npm init -y npm install playwright yaml node-cron npx playwright install chromium这里有个细节值得说:npx playwright install chromium只装 Chromium,不装全部浏览器。巡检场景下我们通常固定用一个浏览器内核就够了,全装会白白占用几百兆磁盘,CI 环境里还会拖慢构建。如果你确实需要跨浏览器验证,再单独装 firefox 或 webkit。
注意:在 Linux 服务器上跑无头浏览器,经常会缺系统依赖库,表现为启动时报一堆
.so找不到。Playwright 提供了npx playwright install-deps来补依赖,但这条命令需要 root 权限。容器化部署的话,直接用官方提供的镜像基础层最省事。
3.2 页面访问与智能等待的封装
执行引擎的核心是一个inspectPage函数,它接收一个巡检项配置,返回结构化的结果。我把等待逻辑做了统一封装,避免每个检查项都手写等待:
const { chromium } = require('playwright'); async function inspectPage(browser, item) { const context = await browser.newContext(); const page = await context.newPage(); const consoleErrors = []; const failedRequests = []; page.on('console', msg => { if (msg.type() === 'error') consoleErrors.push(msg.text()); }); page.on('requestfailed', req => { failedRequests.push({ url: req.url(), reason: req.failure()?.errorText }); }); const result = { name: item.name, url: item.url, passed: true, failures: [] }; try { const response = await page.goto(item.url, { waitUntil: 'networkidle', timeout: 30000 }); if (!response || response.status() >= 400) { result.passed = false; result.failures.push(`页面状态码异常: ${response ? response.status() : '无响应'}`); } if (item.waitFor) { await page.waitForSelector(item.waitFor, { timeout: 15000 }); } for (const check of item.checks || []) { const checkResult = await runCheck(page, check, { consoleErrors, failedRequests }); if (!checkResult.passed) { result.passed = false; result.failures.push(checkResult.message); } } } catch (err) { result.passed = false; result.failures.push(`执行异常: ${err.message}`); } if (!result.passed && item.screenshotOnFail) { result.screenshot = `./screenshots/${Date.now()}-${sanitize(item.name)}.png`; await page.screenshot({ path: result.screenshot, fullPage: true }); } await context.close(); return result; }这段代码里有几个关键决策。waitUntil: 'networkidle'表示等网络基本空闲再判断,这对 SPA 很重要,因为很多页面首屏 HTML 回来了但数据还在异步加载。但 networkidle 也不是万能的,有些页面有轮询请求永远不空闲,这时候就要靠waitForSelector兜底,等某个标志性元素出现。两个机制配合使用,稳定性会好很多。
3.3 各类检查项的具体实现
runCheck是一个分发函数,根据检查类型走不同逻辑。元素存在性检查最简单,但要注意minCount的语义——不是“存在即可”,而是“至少有几个”。为什么?因为列表页空数据和列表页正常渲染,元素选择器可能都能命中容器,但行数为零就说明数据没出来,这是典型的“页面没报错但业务已挂”的场景。
async function runCheck(page, check, ctx) { switch (check.type) { case 'elementExists': { const count = await page.locator(check.selector).count(); const min = check.minCount ?? 1; return { passed: count >= min, message: count >= min ? '' : (check.message || `元素数量不足: ${check.selector} 期望>=${min} 实际=${count}`) }; } case 'noConsoleError': { return { passed: ctx.consoleErrors.length === 0, message: ctx.consoleErrors.length === 0 ? '' : `${check.message}: ${ctx.consoleErrors.slice(0, 3).join(' | ')}` }; } case 'noFailedRequest': { return { passed: ctx.failedRequests.length === 0, message: ctx.failedRequests.length === 0 ? '' : `${check.message}: ${ctx.failedRequests.slice(0, 3).map(r => r.url).join(' | ')}` }; } case 'textNotEmpty': { const text = await page.locator(check.selector).first().innerText().catch(() => ''); return { passed: text.trim().length > 0, message: text.trim().length > 0 ? '' : (check.message || `文本为空: ${check.selector}`) }; } default: return { passed: true, message: '' }; } }这里有个容易忽略的点:noFailedRequest检查要小心误报。现代页面里有些第三方统计、字体、图标的请求失败并不影响业务,如果一刀切全算失败,巡检结果会天天飘红,最后没人看。我的做法是在配置里加一个忽略列表,按 URL 关键字过滤掉已知的无害请求。这个细节后面在避坑部分还会展开。
3.4 批量执行与并发控制
页面多了之后,串行跑太慢。假设一个页面平均3秒,100个页面就是5分钟,加上失败重试可能更久。所以执行层要支持并发,但并发不能无脑开大,否则本机资源打满反而更慢,还可能把被测系统压出问题。我的经验值是并发数控制在 3 到 5 之间,具体看机器配置和被测系统的承受能力。
async function runAll(items, concurrency = 3) { const browser = await chromium.launch({ headless: true }); const results = []; const queue = [...items]; async function worker() { while (queue.length) { const item = queue.shift(); const r = await inspectPage(browser, item); results.push(r); console.log(`[${r.passed ? 'PASS' : 'FAIL'}] ${r.name}`); } } await Promise.all(Array.from({ length: concurrency }, worker)); await browser.close(); return results; }用共享 browser 实例、每个巡检项独立 context 的方式,既复用了浏览器进程节省启动开销,又通过 context 隔离了 cookie 和缓存,避免页面之间互相污染。这个模式在 Playwright 里是官方推荐的做法,实测下来比每个页面都新开浏览器快好几倍。
3.5 结果落库与告警推送
巡检结果如果只是打印在控制台,那等于没做。我把它落成 JSON 文件,按日期归档,方便回溯历史。同时把失败项汇总,推送到团队常用的告警渠道。告警内容要克制,只推失败项和关键信息,别把成功项也刷进去,否则群里天天被刷屏,大家很快就会把告警群静音。
function buildAlertMessage(results) { const failed = results.filter(r => !r.passed); if (failed.length === 0) return null; const lines = failed.map(r => `- ${r.name}: ${r.failures.join('; ')}`); return `巡检发现 ${failed.length} 个异常页面:\n${lines.join('\n')}`; }告警渠道的接入方式取决于团队用什么工具,核心就是把上面这段文本通过对应的 webhook 发出去。这里不展开具体平台的接口细节,思路是通用的:拼消息、发请求、失败重试。
4. 实操全流程与参数调优实录
4.1 从零跑通第一个巡检项
理论讲完,走一遍完整流程。第一步建配置文件inspection.yaml,先只放一个页面,确保链路通:
- name: 首页 url: https://example.com waitFor: "body" checks: - type: elementExists selector: "body" - type: noConsoleError第二步写入口脚本index.js,读配置、跑巡检、输出结果:
const fs = require('fs'); const yaml = require('yaml'); const { runAll } = require('./engine'); (async () => { const config = yaml.parse(fs.readFileSync('./inspection.yaml', 'utf8')); const results = await runAll(config, 3); fs.writeFileSync(`./reports/${Date.now()}.json`, JSON.stringify(results, null, 2)); const alert = buildAlertMessage(results); if (alert) console.log('需要告警:\n' + alert); })();第三步执行node index.js,看控制台输出。第一次跑大概率会遇到超时或者选择器不匹配,别慌,这正是调优的开始。我建议新手先用headless: false跑一遍,肉眼看着浏览器怎么操作,问题一目了然,等稳定了再切回无头模式。
4.2 超时参数的取值逻辑
超时是最容易拍脑袋设错的参数。设太短,网络稍抖就误报;设太长,一个卡死的页面拖垮整轮巡检。我的取值逻辑是这样的:页面导航超时给 30 秒,因为首屏加载涉及资源下载,留足余量;元素等待超时给 15 秒,因为此时页面已经加载完,元素还没出现通常是渲染或接口问题,15 秒足够暴露;单个检查项内部的操作超时给 5 秒。这三个值不是随便定的,而是基于“正常情况下的P95耗时再乘以2到3倍”这个经验公式。你可以先跑一周收集实际耗时分布,再回头校准这些参数,比一开始就纠结数字靠谱得多。
4.3 定时调度的落地方式
巡检要“巡”起来才有价值,手动跑没意义。调度有两种常见做法:一是用系统级 cron,简单粗暴,0 8 * * *表示每天早上八点跑一次;二是在 Node 进程内用 node-cron,好处是能跟应用生命周期绑定,坏处是进程挂了调度就停了。生产环境我更推荐系统 cron 或容器编排平台的定时任务,因为它跟应用解耦,应用重启不影响调度,日志也更好管理。
# crontab -e 添加一行,每天 8 点和 14 点各跑一次 0 8,14 * * * cd /opt/inspection && /usr/bin/node index.js >> /var/log/inspection.log 2>&1跑巡检的机器最好和被巡检系统分开,避免互相影响。另外日志一定要重定向到文件,否则出问题时你连现场都找不到。
4.4 证据采集的实操要点
失败截图是排查利器,但截图也有讲究。fullPage: true会截整页,适合长页面,但页面特别长时图片会很大,加载慢。我的做法是默认截视口,只有配置里明确要求才截全页。另外截图文件名要能对应到巡检项,用时间戳加名称清洗后的组合,避免中文和特殊字符导致文件系统出问题。控制台日志和失败请求也要一并存下来,跟截图放同一个目录,形成一份完整的“事故现场包”。
4.5 巡检覆盖率的渐进式扩展
不要一上来就想覆盖所有页面,那样配置写到手软还容易出错。我的策略是分三批推进:第一批只覆盖核心业务主流程的入口页,大概五到十个,先把链路跑稳;第二批扩展到各功能模块的列表页和详情页,这时候配置模板已经成熟,复制粘贴改改就行;第三批才去覆盖那些边缘的配置页、帮助页。每批上线后观察一周误报率,稳定了再推进下一批。这种渐进式扩展能让工具在团队里慢慢建立信任,而不是一上线就一堆误报把人劝退。
5. 常见问题排查与独家避坑指南
5.1 误报是巡检工具的头号杀手
我必须把这句话放在最前面:巡检工具最大的敌人不是漏报,而是误报。漏报一次可能只是运气不好,但误报三次以上,团队就会认定“这工具不靠谱”,然后彻底不看它的结果。我踩过最惨的一次是某个页面有个第三方地图组件,网络偶尔抖动导致它的请求失败,结果每天告警,查了半天发现根本不影响业务。从那以后我立了规矩:任何新加的检查项,必须先在测试环境跑满三天,确认零误报才能进生产配置。
5.2 常见问题速查表
| 现象 | 可能原因 | 排查思路 | 解决方式 |
|---|---|---|---|
| 元素明明在却报找不到 | 元素在 iframe 内 | 检查页面是否有嵌套框架 | 用 frameLocator 定位 |
| 偶发超时 | 网络抖动或接口慢 | 看失败时的截图和请求记录 | 适当放宽超时或加重试 |
| 控制台报错但业务正常 | 第三方脚本报错 | 看报错来源域名 | 加忽略规则 |
| 截图是白屏 | 截图时机太早 | 确认截图在断言失败后执行 | 截图前加短暂等待 |
| 并发跑时互相干扰 | 共享了登录态或缓存 | 检查 context 是否独立 | 每项独立 context |
| 定时任务不执行 | cron 环境变量缺失 | 看 cron 日志 | 用绝对路径并显式设环境变量 |
5.3 iframe 与 Shadow DOM 的坑
现代后台系统里 iframe 和 Shadow DOM 用得不少,这两个是选择器失效的重灾区。iframe 里的元素,普通选择器根本够不着,必须先用page.frameLocator('iframe选择器')进入框架再定位。Shadow DOM 更隐蔽,元素在页面上看得见,但常规选择器就是命中不了,得用 Playwright 的穿透选择器或者locator的链式写法。遇到“元素可见但找不到”的情况,第一反应就该怀疑这两者。我一般的排查顺序是:先在浏览器开发者工具里确认元素确实存在,再看它的父级链路上有没有 iframe 或 shadow-root,有的话就针对性处理。
5.4 登录态处理的两种思路
大部分巡检页面都在登录之后,登录态怎么处理是个绕不开的问题。思路一是在巡检开始前跑一次登录流程,把 cookie 存下来,后续所有页面复用这个 storageState;思路二是每个巡检项独立登录。前者快但要注意 cookie 过期时间,后者稳但慢。我推荐第一种,Playwright 的storageState机制就是干这个的:
// 登录一次,保存状态 await page.goto('https://example.com/login'); await page.fill('#username', process.env.INSPECT_USER); await page.fill('#password', process.env.INSPECT_PASS); await page.click('#submit'); await page.waitForURL('**/dashboard'); await context.storageState({ path: 'auth.json' }); // 后续 context 复用 const context = await browser.newContext({ storageState: 'auth.json' });账号密码千万别硬编码在代码或配置里,用环境变量注入。cookie 过期导致巡检集体失败是很常见的故障,建议在巡检开始前先探测一下登录态是否有效,无效就自动重新登录,而不是让整轮巡检全部报错。
5.5 选择器的稳定性设计
选择器写得好不好,直接决定这套工具能活多久。我的原则是:优先用业务语义稳定的属性,坚决不用会随样式变动的类名。比如>