做端到端自动化的引擎,最怕的不是用例写不出来,而是同一个脚本今天跑得过、明天换台机器就崩,崩了还查不出崩在哪。我在几个不同类型的项目里反复踩过这个坑之后,慢慢形成一个习惯:先把e2e 引擎当成一个“可替换的部件”来设计,用一份明确的Engine Contract把它和上层调度隔开,再把Session的生命周期当成一等公民来管。这样做的直接好处是,换底层驱动、加并发、做录制回放这些事,都不会把上层用例搅成一锅粥。这篇内容就是把这套东西从头到尾拆一遍:契约怎么写、能力声明怎么定、Session 怎么创建怎么回收、并发和隔离怎么取舍、一条用例怎么真正跑通,以及那些只有踩过才知道的坑。适合已经写过一些自动化脚本、想把“脚本集合”升级成“引擎”的读者,也适合刚接手一套祖传测试框架、正准备重写的人。全文的代码用 TypeScript 示意,思路换成 Java、Go、Python 一样成立。
1. 先把引擎当成一份契约来设计
很多人做 e2e 引擎的顺序是反的:先写一个能启动浏览器的类,再往里面加方法,加着加着这个类就有了两千行,最后谁也不敢动。我的建议是倒过来——先写契约,哪怕里面全是空实现。契约的价值不在于“定义了几个方法”,而在于它强迫你回答三个问题:引擎对外承诺什么、上层依赖什么、出问题时责任边界在哪。这三个问题回答清楚了,后面写多少代码都是体力活,不会变成设计灾难。
1.1 为什么先写 Contract 而不是先写实现
先说个我自己的教训。早期我做过一套脚本,直接在上层用例里调用底层驱动的 API,一开始很爽,两周就铺了八十条用例。后来换了执行环境,驱动版本不兼容,八十条用例里有一半直接报同一个错,而错误信息是底层抛的,跟业务语义完全对不上。排查花了整整两天,最后发现只是某个等待策略的默认值变了。那次之后我就明白了:上层用例不应该知道底层驱动长什么样,它只应该知道“我要创建一个会话、我要执行一条命令、我要拿到结果”。
契约的第一层作用就是收窄表面积。一个底层驱动可能有上百个方法,但上层真正需要的抽象通常不超过十个:启动引擎、创建会话、执行命令、订阅事件、关闭会话、停止引擎、查询能力、健康检查。把这些抽象定死,剩下的上百个方法就变成了引擎内部实现细节,可以随便改、随便换。
第二层作用是让测试可替换。契约一旦稳定,你就可以写一个假引擎(Fake Engine)来做单元测试,不用每次都真起浏览器。假引擎跑一条命令只花几毫秒,真引擎要花几百毫秒,这个差距在 CI 里会被放大成十几分钟的差别。我现在的习惯是:引擎契约层必须有假实现,任何针对调度逻辑的测试都跑在假引擎上,只有少量冒烟测试跑真引擎。
第三层作用是让版本演进有据可依。契约里写清楚哪些是必须实现的、哪些是可选能力,后面加功能就知道能不能破坏兼容。没有契约的框架,加功能全靠改签名,改完所有调用方一起崩,这种事经历一次就够了。
提示:契约不是接口文档。接口文档描述“现在是什么样”,契约描述“必须是什么样”。前者可以跟着代码变,后者变更要走评审。
1.2 契约里到底该放什么:能力声明、生命周期、命令通道
一份够用的引擎契约,我一般拆成四块,缺一块后面都会补课。
| 契约块 | 核心内容 | 缺了会怎样 |
|---|---|---|
| 能力声明 | 引擎标识、版本、支持的定位策略、并发上限、是否支持录制、是否支持网络拦截 | 上层无法做能力探测,只能硬编码假设,换引擎必崩 |
| 生命周期 | start、stop、createSession、closeSession 的状态与顺序约束 | 关闭顺序混乱,资源泄漏、端口占用 |
| 命令通道 | 统一的命令信封(命令名、参数、超时、追踪 ID)与返回信封 | 每条命令各写一套错误处理,重复且不一致 |
| 事件通道 | 会话创建、命令失败、会话崩溃、资源告警等事件 | 只能靠轮询和日志猜状态,问题定位极慢 |
能力声明是最容易被忽略、又最值钱的一块。举个具体例子:不同底层驱动支持的“元素定位策略”差别很大,有的支持文本定位,有的只支持选择器。如果上层脚本直接写findByText("提交"),换一个不支持文本定位的引擎就直接崩。正确做法是引擎启动后先调一次capabilities(),把结果缓存到会话上下文里,上层要用文本定位时先探测,不支持就降级到选择器加索引的写法。
生命周期这块,我喜欢在契约里就画死状态机,而不是留给实现自由发挥。原因是状态机是唯一能防止“先关会话再执行命令”这类低级错误的手段。契约里定好:命令只能在 Ready 或 Busy 状态下执行,Draining 状态只允许收尾命令,Closed 和 Crashed 状态一律拒绝。实现层照做就行,不用每条命令都自己判断一遍。
命令通道要用统一的信封,这个后面第 2 章会展开。这里只说一点:命令信封里必须有超时字段。没有超时的命令通道,一旦底层卡住,整个引擎就跟着挂住,而且挂得毫无征兆。
1.3 契约版本化与向后兼容的取舍
契约一定会有第二版。区别只在于:你是被动改,还是主动规划。我现在的做法是,契约里带一个contractVersion,引擎实现里带一个engineVersion,两个版本分开走。上层在启动时读一次,遇到不认识的大版本直接拒绝启动,遇到小版本差异则走降级分支。
兼容策略我一般按“加不加、删不删、改不改”三档处理:
- 只加不删:新增可选方法或可选事件,属于小版本升级,上层不实现也能跑。
- 加必选:新增必选方法,属于大版本升级,但可以通过提供一个默认实现(比如抛“未支持”错误)来软化冲击。
- 改签名或删方法:属于破坏性变更,必须大版本升级,并且提供一个适配层过渡。
这里有个取舍值得说清楚:要不要为了兼容而在引擎里塞一堆适配器?我的答案是分情况。如果引擎是内部自用、调用方不超过三个,直接改,别留适配层,留了就是技术债。如果引擎被多个团队依赖、发版节奏不受你控,那适配层是必要的成本,但一定要打上废弃标记和下线日期。
注意:适配层不要做成“万能转换器”。我见过一个适配层为了兼容三种旧签名,内部写了九层 if 判断,最后没人敢删。正确做法是每兼容一个旧版本就写一个独立的小适配器,各自可删。
2. 契约落地:接口定义与类型系统
契约是设计层面的东西,落到代码上就是接口加类型。这一层做得好,后面写 Session 实现基本是填空题;做得糙,后面每个实现都要自己处理边界。我用 TypeScript 举例,因为它的类型系统足够表达“可选能力”和“命令与结果的配对”,换成别的语言也能找到等价物,核心是用类型把非法状态挡在编译期。
2.1 用一份能力描述把差异挡在引擎外
能力描述我建议做成纯数据,不要做成方法。原因是纯数据可以序列化、可以缓存、可以写进报告、可以在 UI 上展示。方法只能调用,用起来麻烦。
export interface EngineCapabilities { engineName: string; engineVersion: string; contractVersion: string; locatorStrategies: Array<'css' | 'xpath' | 'text' | 'id' | 'accessibility'>; maxConcurrentSessions: number; supportsRecording: boolean; supportsNetworkIntercept: boolean; supportsFileUpload: boolean; defaultCommandTimeoutMs: number; metadata?: Record<string, string>; }字段看着多,其实每一行都对应一次真实的翻车。maxConcurrentSessions是为了让调度器知道能开多少,别一上来开五十个把机器拖死;supportsRecording是为了让报告生成器决定要不要挂录制钩子;defaultCommandTimeoutMs是为了在没有显式超时时有个兜底。
上层拿到能力描述之后,正确的用法是做一次能力探测加降级决策,而不是每个用例里都去判断一遍。我的习惯是在会话创建阶段就把降级决策算好,存进会话上下文:
function resolveLocatorStrategy( caps: EngineCapabilities, preferred: 'text' | 'css' ): 'text' | 'css' { if (preferred === 'text' && caps.locatorStrategies.includes('text')) { return 'text'; } return 'css'; }这段逻辑很短,但它的意义在于:整个系统里只有这一处知道“文本定位可能不支持”,其他地方一律按决策结果走。以后某个引擎支持了文本定位,改这一处就行。
2.2 命令与事件的统一信封
命令通道我坚持用信封,不用“一个方法对应一个命令”。原因很实际:e2e 引擎的命令种类会从十来个涨到上百个,方法式接口最后必然膨胀成一个巨大的接口,每个实现类都要写上百个空方法。信封式接口只需要一个execute,命令名当参数,新增命令不用改接口。
export interface CommandEnvelope<T = unknown> { commandId: string; sessionId: string; name: string; payload: T; timeoutMs: number; traceId: string; retryable: boolean; } export interface ResultEnvelope<R = unknown> { commandId: string; ok: boolean; durationMs: number; result?: R; error?: EngineError; metrics?: Record<string, number>; }commandId和traceId是这套设计里最关键的两个字段。commandId用于结果配对,避免串话;traceId用于把一次用例执行里所有命令串起来,出问题时可以直接捞出一条完整链路。没有这两个字段,日志就是一锅乱炖,你只能看到“某个命令失败了”,不知道它属于哪条用例、前面发生了什么。
事件通道同理,统一信封,带上会话 ID 和时间戳:
export type EngineEvent = | { type: 'session:created'; sessionId: string; at: number } | { type: 'session:closed'; sessionId: string; at: number; reason: string } | { type: 'session:crashed'; sessionId: string; at: number; detail: string } | { type: 'command:failed'; sessionId: string; commandId: string; at: number };事件我建议只推“状态变化”,不推“过程细节”。过程细节走日志和追踪文件,量大、写入快、不需要实时消费。状态变化量小,适合用来驱动调度器做决策,比如收到session:crashed就把这个会话从池子里摘掉,重新补一个。
2.3 错误模型:可重试、不可重试、致命
错误模型是契约里最容易被敷衍的一块,大家往往随手抛个Error就完事。但 e2e 场景里错误的性质差别巨大:元素暂时没出现是可重试的,元素选择器写错了是不可重试的,会话崩了是致命的。三者混在一起,重试策略就没法做。
我一般分三类,用一个枚举标识:
| 错误类别 | 典型场景 | 处理策略 |
|---|---|---|
RETRYABLE | 元素未就绪、网络抖动、动画未结束 | 指数退避重试,最多 N 次 |
NON_RETRYABLE | 选择器无匹配、断言失败、参数非法 | 直接失败,记录现场 |
FATAL | 会话崩溃、驱动进程退出、端口占用 | 终止会话,重建,标记用例环境异常 |
export class EngineError extends Error { constructor( public readonly kind: 'RETRYABLE' | 'NON_RETRYABLE' | 'FATAL', public readonly code: string, message: string, public readonly detail?: Record<string, unknown> ) { super(message); } }这套分类真正的价值在重试层。重试层只需要看kind,RETRYABLE走退避,FATAL直接往上抛并触发会话重建,NON_RETRYABLE原样返回。我见过太多框架把重试逻辑写死在每个命令里,结果同一个命令里既有重试又有断言,重试把断言失败也吃掉了,最后报告显示“全部通过”,实际上什么都没验证。
提示:断言失败永远不要标成
RETRYABLE。断言失败是业务结论,不是环境问题,重试只会把问题藏起来。
3. Session 的生命周期设计
Session 是整个引擎里状态最多、最容易出事的地方。它承载了“一次用例执行的完整上下文”,从创建到销毁可能跨越几十秒到几分钟,中间会经历初始化、空闲、忙碌、收尾多个阶段。这一章我把生命周期拆开讲,包括创建链路、上下文内容和超时回收,这三块做好了,会话相关的线上问题能少一大半。
3.1 从请求到句柄:一次会话创建的完整链路
创建会话看似就是“起个浏览器”,实际链路比想象长。我把它拆成六步,每一步都可能失败,也都可以做优化:
- 配额检查:查当前活跃会话数是否超过
maxConcurrentSessions,超过就排队而不是直接拒绝。 - 资源预留:预留内存、磁盘临时目录、端口,预留失败就回滚。
- 驱动初始化:启动底层驱动进程或连接远端服务,设置握手超时。
- 能力协商:拉取能力描述,做降级决策,把结果写进上下文。
- 环境准备:设置视口、语言、时区、证书策略等初始状态。
- 句柄注册:生成
sessionId,注册到会话表,推送session:created事件。
这六步里,第三步和第五步是耗时大头,加起来能占到整个创建时间的八成。所以调度器需要知道“创建会话是重操作”,不能在每个用例里都创建一次。我的做法是会话复用:一个会话可以连续跑多条同类用例,只在环境被污染(比如登录态变化、页面残留弹窗)时重建。
创建链路一定要有整体超时,不能只给每一步设超时。因为六步各设 30 秒,理论上最坏要 180 秒,调度器等不了那么久。我一般给整个创建过程设 60 秒硬超时,超时后强制回滚,把预留的资源释放掉。
async function createSession(req: SessionRequest): Promise<SessionHandle> { const deadline = Date.now() + req.createTimeoutMs; const reserved = await resourcePool.reserve(req.profile); try { const driver = await driverFactory.launch(req.profile, deadline - Date.now()); const caps = await driver.capabilities(); const ctx = buildContext(req, caps, reserved); await driver.applyInitialState(ctx.initialState, deadline - Date.now()); const handle = sessionRegistry.register(ctx, driver); eventBus.emit({ type: 'session:created', sessionId: handle.id, at: Date.now() }); return handle; } catch (err) { await reserved.release(); throw normalizeError(err, 'SESSION_CREATE_FAILED'); } }这段代码里有个细节值得强调:reserved.release()放在 catch 里,而不是放在 finally 里。因为成功路径上资源要交给会话持有,由会话关闭时释放;只有失败路径才需要当场回滚。放 finally 会导致成功创建后资源被立刻释放,后面执行命令直接报“资源不存在”,这个 bug 我排查过一小时。
3.2 会话上下文里必须存什么
上下文设计的原则是:凡是重建会话后需要重新计算的东西,都必须存。因为 FATAL 错误会触发重建,重建后如果上下文丢了,很多决策就得重算,重算本身又可能失败。
我一般存这几类:
- 身份类:
sessionId、traceId、所属用例 ID、创建时间。 - 能力类:能力描述快照、降级决策结果(定位策略、等待策略、截图策略)。
- 资源类:临时目录路径、预留端口、录制文件句柄。
- 状态类:当前状态(Ready/Busy/Draining)、已执行命令数、最近一次错误码。
- 观测类:命令耗时直方图、失败计数、重试计数。
状态类字段要和契约里的状态机严格对齐。我见过一个实现把状态存在两个地方,一个是内存里的布尔量isBusy,一个是枚举state,两者偶尔不同步,导致命令被错误拒绝。后来统一成只信枚举,布尔量改成派生方法get isBusy() { return this.state === 'Busy' },问题就消失了。这种“单一事实来源”的原则,在会话上下文里尤其重要。
3.3 超时、心跳与僵死会话回收
会话最恶心的状态不是崩溃,是“假活”——进程还在,命令发过去没反应,日志也不报错。这种僵死会话会一直占着配额,把后面的用例全堵死。解决办法是心跳加空闲检测。
心跳我建议做在引擎层,而不是驱动层。具体做法是每隔固定间隔发一条极轻量的探活命令(比如查询当前状态),带一个很短超时。连续三次失败就判定为僵死,直接杀掉重建。间隔怎么定?我的经验值是:心跳间隔 = 单条命令 P95 耗时 × 3,但不超过 15 秒。假设你这类项目的单命令 P95 是 800 毫秒,那心跳间隔 3 秒左右比较合适。间隔太短会干扰正常命令,太长又发现不及时。
空闲回收则是另一套逻辑。会话跑完一条用例后进入空闲,如果空闲超过阈值(我一般设 3 到 5 分钟)就主动关闭,释放资源。阈值不能太短,因为连续用例之间切换有间隔,关得太勤反而增加创建开销。这里可以做个简单的成本估算:假设创建会话平均 4 秒,跑一条用例平均 20 秒,那复用会话能省下大量时间,阈值设 5 分钟是划算的。
| 检测手段 | 触发条件 | 处理动作 |
|---|---|---|
| 心跳探活 | 连续 3 次探活超时 | 杀进程,标记 FATAL,重建 |
| 空闲回收 | 空闲超过 5 分钟 | 正常关闭,释放资源 |
| 配额告警 | 活跃会话数达上限 80% | 记录指标,触发排队策略 |
| 泄漏巡检 | 会话表里存在无主会话 | 强制关闭并告警 |
4. Session 实现:并发、隔离与资源清理
契约和生命周期定好之后,实现层的核心矛盾就剩下三个:并发怎么控、隔离怎么做、清理怎么做干净。这三件事任何一件做糙了,表现都是“偶发失败”,而偶发失败是自动化里最消耗人的东西,因为你很难复现它。
4.1 会话池与配额
会话池的本质是拿内存换时间。创建会话贵,那就提前建好一批放在池子里,用例来了直接取。但池子会带来新问题:池子里的会话可能已经被污染,比如上一条用例改过登录态、留了个弹窗、改了本地存储。
我的做法是分两级池:热池和冷池。热池里的会话是干净状态,可以直接用;冷池里的会话跑过用例,需要执行一次“重置流程”才能回到热池。重置流程比全量创建便宜得多,通常就是清 Cookie、清本地存储、关掉多余标签页、回到初始页,耗时大概是创建的十分之一。
池子大小怎么定?这个可以算。假设:
- 并发用例数峰值为 8
- 单条用例平均耗时 20 秒
- 创建会话平均 4 秒
- 重置会话平均 0.4 秒
- 期望排队等待时间小于 2 秒
按最简单的排队模型估,池子至少要能覆盖“创建时间占执行时间比例”的冗余。8 × (4 / 20) ≈ 1.6,向上取整再加一点缓冲,热池设 4 到 5 个、冷池设 5 到 6 个比较稳。这只是起点,上线后要看实际排队指标再调,别把公式当真理。
注意:池子不是越大越好。池子里的空闲会话本身也占内存,一个浏览器会话轻松吃掉几百兆,开十个池子可能直接把 CI 机器打爆。我遇到过一台 8G 的构建机,池子设了 12 个,跑起来疯狂换页,整体耗时反而翻倍。
4.2 隔离级别:进程级、上下文级、设备级
隔离是 e2e 里最需要提前想清楚的设计决策,因为它一旦定下来,后面改造成本极高。我实践过的隔离级别大致三档,各有代价。
| 隔离级别 | 做法 | 隔离强度 | 资源成本 | 适用场景 |
|---|---|---|---|---|
| 进程级 | 每条用例独立进程 | 最强 | 最高 | 涉及全局状态的用例 |
| 上下文级 | 同进程内独立上下文 | 中等 | 中等 | 大多数 UI 用例 |
| 设备级 | 独立设备或独立远端节点 | 最强 | 最高 | 移动端、多端并行 |
大部分情况下,上下文级隔离就够用了,代价小、启动快。但有两类场景必须上进程级:一类是修改了全局配置(比如证书信任、代理设置),另一类是出现了内存泄漏或进程崩溃的用例。我的处理办法是在用例上打标,标了“必须强隔离”的走进程级,其余走上下文级。这样既保住整体速度,又保住了稳定性。
设备级隔离其实是最容易被低估的。做移动端的时候,同型号设备的物理差异、系统版本差异会让用例表现完全不同,这时候在同一个节点上强行复用,只会得到一堆莫名其妙的失败。与其花时间调,不如直接把节点维度纳入调度,让用例绑到具体节点上跑。
4.3 清理顺序与幂等 teardown
清理是整套实现里最不显眼、出事最多的部分。我总结的经验是:清理必须按逆序、分阶段、可幂等。
逆序是指,创建时做了什么,清理时就按相反顺序撤销。创建顺序是预留资源、启动驱动、注册会话,那清理顺序就是注销会话、关闭驱动、释放资源。顺序错了就会出现“驱动已经关了,但资源还被引用着”的尴尬情况。
分阶段是指,别指望一次清理就能全做完。我一般分三个阶段:第一阶段软清理(关页面、清状态),有超时限制,超时就进第二阶段;第二阶段硬清理(关驱动进程、强杀),也有超时;第三阶段兜底回收(释放临时目录、归还端口),这一步必须保证成功,所以不能有外部依赖。
async function closeSession(id: string): Promise<void> { const handle = sessionRegistry.get(id); if (!handle) return; // 幂等:已关闭直接返回 handle.state = 'Draining'; await withTimeout(handle.driver.closePages(), 3000).catch(() => {}); await withTimeout(handle.driver.shutdown(), 5000).catch(() => {}); await handle.reserved.release().catch(() => {}); sessionRegistry.unregister(id); eventBus.emit({ type: 'session:closed', sessionId: id, at: Date.now(), reason: 'normal' }); }幂等这点特别重要。因为清理路径经常会被调用两次:一次是正常结束,一次是超时兜底。如果清理不幂等,第二次调用就会在已经注销的会话上再操作一遍,报错把真正的错误信息盖掉。上面代码开头那句if (!handle) return就是干这个用的,看起来不起眼,实际能省不少排查时间。
5. 实操:把一条 e2e 用例跑通
前面都是设计,这一章把它跑起来。我用一个虚拟的示例项目做演示,结构尽量贴近真实工程。这套结构我在几个项目里都用过,改动不大,主要是换引擎实现和报告格式。
5.1 环境与目录结构
目录我建议按“契约、引擎实现、调度、用例、报告”五层分开,别混在一个 src 里。这样边界清晰,后面加新引擎只要新增一个目录。
e2e-engine/ ├── packages/ │ ├── contract/ # 契约与类型定义,不依赖任何实现 │ ├── engine-core/ # 会话管理、池子、调度、事件总线 │ ├── engine-driver/ # 具体驱动适配,实现 Engine Contract │ ├── fake-engine/ # 假引擎,供调度层单测使用 │ └── reporter/ # 报告与追踪文件生成 ├── cases/ │ └── login.spec.ts └── run.config.ts契约包必须零依赖,这是硬要求。因为契约包会被所有其他包引用,一旦它依赖了某个驱动,下层就被污染了。我见过契约包里为了图方便直接 import 了驱动类型,结果驱动升级时整个项目连带编译失败,这种耦合非常难受。
配置文件我倾向于显式写全,不依赖默认值:
export const runConfig = { engine: 'driver-a', pool: { hot: 4, cold: 6, idleRecycleMs: 300_000 }, timeouts: { createSessionMs: 60_000, commandDefaultMs: 15_000, teardownMs: 8_000, }, retry: { max: 3, baseDelayMs: 200, maxDelayMs: 2000 }, isolation: { default: 'context', strongTag: 'isolation:strong' }, };超时这块我习惯把teardownMs单独列出来,因为收尾超时和命令超时的取值逻辑不一样。命令超时看业务,收尾超时看最坏情况下的驱动响应,通常给 8 秒够用。
5.2 关键代码:Contract 定义与 Session 实现
契约接口我写得比较克制,只保留必要方法:
export interface EngineContract { readonly name: string; start(): Promise<void>; stop(): Promise<void>; capabilities(): Promise<EngineCapabilities>; createSession(req: SessionRequest): Promise<SessionHandle>; closeSession(sessionId: string): Promise<void>; execute<R>(cmd: CommandEnvelope): Promise<ResultEnvelope<R>>; on(listener: (e: EngineEvent) => void): () => void; health(): Promise<{ ok: boolean; activeSessions: number }>; }实现层要注意两个点。第一,execute里必须先校验会话状态和超时预算,再转发给驱动。第二,execute要负责把驱动抛出的原始错误归一化成EngineError,别让原始错误往上冒。
async execute<R>(cmd: CommandEnvelope): Promise<ResultEnvelope<R>> { const handle = this.registry.get(cmd.sessionId); if (!handle) { throw new EngineError('FATAL', 'SESSION_NOT_FOUND', `会话不存在: ${cmd.sessionId}`); } if (handle.state !== 'Ready' && handle.state !== 'Busy') { throw new EngineError('FATAL', 'SESSION_BAD_STATE', `状态不允许执行: ${handle.state}`); } const started = Date.now(); handle.state = 'Busy'; try { const result = await withTimeout(handle.driver.send(cmd), cmd.timeoutMs); return { commandId: cmd.commandId, ok: true, durationMs: Date.now() - started, result: result as R }; } catch (err) { const normalized = normalizeError(err, 'COMMAND_FAILED'); this.eventBus.emit({ type: 'command:failed', sessionId: cmd.sessionId, commandId: cmd.commandId, at: Date.now(), }); return { commandId: cmd.commandId, ok: false, durationMs: Date.now() - started, error: normalized }; } finally { if (handle.state === 'Busy') handle.state = 'Ready'; } }注意execute返回的是结果信封而不是抛异常(除了致命错误)。这样上层可以统一处理“命令失败但引擎还活着”的情况,做重试或降级,而不用到处 try-catch。
调度层的核心是重试加会话选择:
async function runCommand<R>(cmd: CommandEnvelope): Promise<R> { let attempt = 0; while (true) { try { const res = await engine.execute<R>(cmd); if (res.ok) return res.result as R; if (res.error?.kind === 'RETRYABLE' && attempt < runConfig.retry.max) { await sleep(Math.min(runConfig.retry.baseDelayMs * 2 ** attempt, runConfig.retry.maxDelayMs)); attempt += 1; continue; } throw res.error ?? new EngineError('NON_RETRYABLE', 'UNKNOWN', '未知命令失败'); } catch (err) { if (err instanceof EngineError && err.kind === 'FATAL') { await engine.closeSession(cmd.sessionId).catch(() => {}); throw err; } throw err; } } }退避用 2 的幂,从 200 毫秒起,封顶 2 秒。这个参数不是拍脑袋的:底层 UI 的动画和渲染通常在一秒内结束,重复探测间隔太密只会浪费命令配额,间隔太长又拖慢整体时长。200 毫秒起步、2 秒封顶,实测大部分抖动都能在第二次或第三次重试里恢复。
5.3 运行与观测:日志、Trace、录制
用例跑起来之后,真正决定效率的是可观测性。我要求每个引擎至少在三个维度留痕:结构化日志、命令追踪、失败现场。
结构化日志按traceId打,每条日志带traceId、sessionId、commandId、phase、durationMs。这样出问题时一条 grep 就能拉出完整链路。日志级别我一般设三档:info记状态变化和命令起止,debug记参数摘要,trace记完整信封。生产跑用info,本地复现用trace。
命令追踪我建议单独落一个 JSONL 文件,一行一个命令,包含耗时、结果、重试次数。这个文件比日志更好解析,做耗时分析、失败聚类都靠它。我一般会在报告里生成一张“最慢的十个命令”的表,优化时优先看这几条。
失败现场是重头戏,至少要包含:截图、当前 URL、页面标题、最近 20 条命令、控制台错误。这五样凑齐,绝大多数问题不用复现就能定位。截图我建议截全页而不是当前视口,因为很多问题出在视口外。控制台错误单独收集,因为 UI 上的失败经常是脚本报错引起的,只看截图看不出来。
| 观测手段 | 采集时机 | 存储形式 | 主要用途 |
|---|---|---|---|
| 结构化日志 | 全程 | 文本或日志服务 | 链路追踪、问题定位 |
| 命令追踪 | 每条命令 | JSONL | 耗时分析、失败聚类 |
| 失败现场 | 失败时 | 截图加 JSON | 快速复现、责任划分 |
| 录制回放 | 可选 | 视频或动作序列 | 复杂交互复盘 |
提示:录制功能开销不小,别默认开着。我的做法是只在失败重试时开启,或者按用例标签开启,避免拖慢整体执行。
6. 常见问题与排查实录
这一章是我这些年攒下来的问题清单,基本覆盖了会话相关的大部分疑难杂症。有些问题看起来是随机的,实际上都有固定成因,只是被并发和时序掩盖了。
6.1 会话泄漏与端口占用
会话泄漏的典型表现是:跑了几十轮之后,机器上残留一堆驱动进程,新会话创建越来越慢,最后直接失败。成因通常是清理路径被异常跳过,比如用例抛了未捕获异常、调度器被强制重启、超时监控自己挂了。
排查思路是从“会话表”和“操作系统进程”两头对账。会话表里还有但进程已经没了,说明注册了没启动成功;进程还在但会话表里没有,说明清理时没注销干净。两头对不上就说明有中间态没处理。
async function reconcileSessions(engine: EngineContract): Promise<void> { const known = sessionRegistry.listIds(); const alive = await listDriverProcesses(); for (const pid of alive) { if (!known.includes(pidToSessionId(pid))) { logger.warn({ pid }, '发现无主驱动进程,强制清理'); await killProcess(pid); } } for (const id of known) { const h = sessionRegistry.get(id); if (h && Date.now() - h.lastActiveAt > STALE_MS) { await engine.closeSession(id); } } }这个巡检我一般挂在定时任务里,每五分钟跑一次。上线之后会话泄漏问题基本绝迹。端口占用是它的兄弟问题,成因是驱动进程被杀但端口没释放,或者临时目录里的锁文件没清。解决办法是给每个会话分配独立端口段,清理时连同临时目录一起删,别指望驱动自己清。
6.2 断言抖动与等待策略
抖动是自动化里最磨人的问题。同一个用例,跑十次过八次。大部分时候是等待策略不对:该等的东西没等,不该等的东西死等。
我的原则是只等状态,不等时间。写sleep(2000)是最差的做法,它既慢又不可靠。正确做法是等一个可观测的信号:元素可见、网络空闲、某个属性变成目标值、某段文本出现。这些信号都能通过轮询加超时实现。
async function waitFor<T>( probe: () => Promise<T | null>, timeoutMs = 10_000, intervalMs = 200 ): Promise<T> { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const v = await probe(); if (v !== null) return v; await sleep(intervalMs); } throw new EngineError('RETRYABLE', 'WAIT_TIMEOUT', `等待超时: ${timeoutMs}ms`); }轮询间隔 200 毫秒是个经验值,太密会给驱动增加压力,太疏会让用例整体变慢。超时值要按场景给,不要全局设一个。元素出现通常 5 秒够,页面导航给 15 秒,文件上传给 30 秒。
还有一种抖动是“用例之间互相影响”。表现是单独跑都过,一起跑就有几个挂。成因通常是共享了状态:同一个账号、同一个本地存储、同一个临时目录。解决办法就是第 4 章说的隔离,给每条用例分配独立的上下文,登录态通过接口预置而不是 UI 操作。
6.3 常见问题速查表
把上面这些经验整理成一张表,出问题时先照表排查,能省不少时间。
| 现象 | 高概率成因 | 排查动作 | 处理办法 |
|---|---|---|---|
| 创建会话越来越慢 | 会话泄漏、进程残留 | 对账会话表与进程列表 | 定时巡检加强制清理 |
| 命令间歇性超时 | 未设命令超时、驱动卡死 | 看命令追踪里的耗时分布 | 加超时、加心跳探活 |
| 用例单独跑过、一起跑挂 | 状态共享、隔离不足 | 检查账号、存储、临时目录 | 提升隔离级别 |
| 报告全绿但实际没验证 | 断言被重试吞掉 | 检查断言是否标了 RETRYABLE | 断言失败直接终止 |
| 清理阶段报错盖住原错误 | teardown 不幂等 | 看清理是否被调用两次 | 开头加存在性判断 |
| 截图全黑或空白 | 截图时机太早 | 看截图与命令时序 | 等渲染完成再截 |
| 池子命中率低 | 池子太小或重置太慢 | 看排队与重置耗时指标 | 调池子大小、优化重置 |
| 移动端用例跨节点失败 | 设备差异 | 对比节点系统版本与配置 | 用例绑定具体节点 |
再说两个不太容易想到的坑。第一个是临时目录累积。每个会话都会建临时目录存截图、下载文件、录制,如果清理时只删会话不删目录,磁盘迟早满。我的做法是给临时目录加日期前缀,巡检时删掉三天前的,双保险。
第二个是时钟不一致。分布式执行时,不同节点的系统时间可能有偏差,导致超时判断错乱,比如明明只跑了 5 秒却被判定超时 30 秒。解决办法是统一用单调时钟算耗时,别用墙上时间做差值。
注意:排查抖动问题时,先别急着改代码。把失败样本收集十个,看它们的共同点在哪,往往是同一个成因的十种表现。盲目加重试只会把问题推到更晚才暴露。
最后分享一个我自己的习惯:每次给引擎加新能力,我都会先问一句“这个能力加进去,清理路径要不要跟着改”。九成情况下答案是“要”。因为引擎的复杂度绝大部分不在主流程,而在收尾。主流程写错了当场就报错,收尾写错了要跑几百次才露头,等露头的时候,你已经很难回忆起当时为什么那么写了。