文章目录
上周我们分享了如何借助SDD + AI + CDP打通E2E测试的最后一公里。文章发出后,项目经历了两次重大架构调整:一次是框架与业务逻辑的分离,一次是Chrome生命周期的集中化。本文作为续篇,记录这些演进背后的设计决策、踩过的坑,以及最终落地的方案。
前言
*** 架构与流程升级 ***(高效交付)基于 Monorepo 架构、SDD(架构驱动设计)与 SPEC(规范定义),结合稳定的结构化能力与 AI 自动化测试生成技术(auto-test-gen SKILL),推动测试深度左移。通过将自动化测试全面纳入 CI/CD 流水线,实现研发现效的飞跃式提升。
*** 协作与门槛降级 ***(全员协同)在新型前后端分离项目中,AI 能力重塑了自动化测试场景的构建方式。它彻底打破了技术壁垒,使得开发、测试、产品(PM)乃至最终用户均能零门槛介入测试流程,打造了真正的一体化协同生态。
一、演进背景
1.1 上周的架构
上周文章描述的是一个"双引擎"架构:
05.Test/04.auto-test/
├── lib/
│ ├── cdp-session.js # CDP通信层
│ ├── chrome-launcher.js # Chrome启动管理
│ ├── pilot-runtime.js # 测试执行引擎(含GMS登录逻辑)
│ ├── suite-runner.js # CDP用例编排
│ ├── playwright-suite-runner.js # Playwright hybrid编排
│ └── ...
├── easyui-test-helper.js # 浏览器端Helper
├── run-suite.js # CDP入口
├── run-playwright-suite.js # Playwright hybrid入口
└── cases/ # 各模块测试套件
这个架构解决了EasyUI黑盒问题,实现了AI驱动的用例生成。但在实际使用中,两个问题逐渐暴露:
问题一:框架里混着业务代码
pilot-runtime.js同时包含了通用能力(浏览器启动、iframe发现、Helper注入)和GMS特有逻辑(登录、地区切换、菜单导航)。当第二个workspace——一个React + Ant Design的SPA应用——接入时,框架无法适配:SPA不需要iframe,不需要EasyUI Helper,更不需要GMS的登录流程。
问题二:Chrome实例到处创建
浏览器启动逻辑散落在三个文件中:eui-pilot-runtime.js、browser-session-manager.js、spa-suite-runner.js。三份几乎相同的chromium.launch代码,各自处理persistent context分支、锁文件清理、PID记录。更严重的是:
- EUI路径硬编码viewport
{1440, 900},而配置文件默认{1920, 1080} - 共享会话的
close()无法清理锁文件——因为session对象里没有存profileDir - EUI共享会话的
initEuiSession签名缺少pwConfig参数,调用方传入但被静默忽略
这不是"代码不够优雅"的问题,而是单一职责原则的违反——Chrome的生命周期管理没有一个明确的负责人。
1.2 新增的需求:远程Chrome
团队引入了KasmVNC容器化Chrome环境,用于CI场景:
docker run -d \
--shm-size=4gb \
-p 6901:6901 \
-p 9223:9223 \
--name playwright-desktop \
kasmweb/chrome:1.19.0
远程Chrome通过9223端口暴露CDP调试接口。测试脚本需要连接这个远程实例,而不是启动本地浏览器。关键约束:远程Chrome不能被关闭(它是共享基础设施),但测试创建的tab必须关闭(防止资源泄露)。
这个需求进一步放大了Chrome生命周期管理的问题——如果启动/关闭逻辑散落各处,远程模式的适配就要改三个文件,每个都要处理"连接而非启动"、"关tab而非关浏览器"的差异。
同时通过这种方式也能解决服务器无法处理有头浏览器模式的问题。(BTW:当然用来跑RPA也不是个难事。)
二、架构重构
2.1 项目结构重组
项目从单一目录拆分为两个并行模块:
venus-sdd-auto-test/
├── pilot/ # test-pilot 测试执行引擎
│ ├── run.js # CLI入口,路由分发
│ ├── lib/
│ │ ├── browser-manager.js # ★ Chrome生命周期唯一管理者
│ │ ├── base-runner.js # 共享基础设施
│ │ ├── unified-runner.js # EUI模式runner
│ │ ├── spa-suite-runner.js # SPA模式runner
│ │ ├── eui-pilot-runtime.js # EUI通用能力(仅iframe+Helper)
│ │ ├── browser-session-manager.js # 共享会话管理
│ │ └── ...
│ ├── helpers/
│ │ ├── easyui-test-helper.js # EasyUI交互API
│ │ └── antd-test-helper.js # Ant Design交互API
│ └── examples/
│ ├── gms/ # EUI workspace(jQuery + EasyUI)
│ └── aigo/ # SPA workspace(React + Ant Design)
└── mission-control/ # 测试管理平台(待开发)
核心变化:pilot/成为纯粹的框架层,所有业务逻辑下沉到workspace。
2.2 Chrome生命周期集中化
新建browser-manager.js,作为Chrome实例的唯一出生地和唯一死亡地:
// pilot/lib/browser-manager.js
async function launch(pwConfig, logger, runId) {
// 远程模式:连接已有Chrome
if (pwConfig.remoteChromeUrl) {
return connectRemote(pwConfig, logger);
}
// 本地模式:启动新Chrome
// persistent context vs regular launch
// 锁清理 + PID记录
...
}
async function close(handle, logger) {
if (handle.remote) {
// 远程:只关tab,不关浏览器
await handle.page.close();
return;
}
// 本地:关context + browser + 清锁
...
}
设计原则:launch和close是纯技术操作,autoCloseChrome等flag判断留在调用方。这遵循了单一职责——browser-manager只管"怎么启动/关闭",runner/session-manager决定"要不要启动/关闭"。
重构前后对比:
| 调用路径 | 重构前 | 重构后 |
|---|---|---|
| SPA独立启动 | spa-suite-runner内联chromium.launch |
browser-manager.launch(pwConfig, logger, runId) |
| SPA共享启动 | browser-session-manager内联chromium.launch |
browser-manager.launch(pwConfig, logger, 'shared-spa') |
| EUI独立启动 | eui-pilot-runtime.launchBrowser(config) |
browser-manager.launch(pwConfig, logger, 'gms') |
| EUI共享启动 | pilot.launchBrowser(config) |
browser-manager.launch(pwConfig, logger, 'shared-eui') |
| 任何关闭 | 各自内联close逻辑 | browser-manager.close(handle, logger) |
一个文件,一个入口,一个出口。 远程模式只需改这一个文件,其他所有调用方无感知。
2.3 远程Chrome连接
远程模式的实现集中在browser-manager.js的connectRemote函数:
async function connectRemote(pwConfig, logger) {
const cdpUrl = pwConfig.remoteChromeUrl;
// 从 /json/version 端点获取 WebSocket URL
const wsUrl = await resolveWsUrl(cdpUrl);
// 通过CDP连接远程Chrome
const browser = await chromium.connectOverCDP(wsUrl);
// 使用默认上下文,新建tab
const context = browser.contexts()[0];
const page = await context.newPage();
return { browser, context, page, profileDir: null, launched: false, remote: true };
}
关键设计决策:
resolveWsUrl:CDP地址可能是http://host:port格式,需要先访问/json/version获取webSocketDebuggerUrl。如果已经是ws://地址则直接使用。remote: true标记:handle携带这个标记,close()函数据此决定行为——远程模式只关tab,本地模式关浏览器。launched: false:表示这不是我们启动的实例,不应由我们关闭。
配置方式:
// playwright.config.js(workspace级)
module.exports = {
remoteChromeUrl: 'http://192.168.1.13:9223',
};
// 或环境变量(CI临时覆盖)
REMOTE_CHROME_URL=http://192.168.1.13:9223
2.4 框架与业务逻辑分离
eui-pilot-runtime.js从"万能runtime"精简为纯框架能力:
// 重构前:4个函数(含GMS业务逻辑)
module.exports = {
launchBrowser, // → 移至 browser-manager
closeBrowser, // → 移至 browser-manager
findBusinessFrame, // 保留
injectHelper, // 保留
};
// 重构后:2个纯框架函数
module.exports = {
findBusinessFrame, // iframe发现(通用)
injectHelper, // EasyUI Helper注入(通用)
};
GMS的业务逻辑全部移入gms/workspace-runtime.js,它从39行的pass-through shell变为330行的完整实现:
// gms/workspace-runtime.js
const { launch: launchBrowser, close: closeBrowser } =
require(path.join(FRAMEWORK_ROOT, 'lib/browser-manager'));
const { findBusinessFrame, injectHelper } =
require(path.join(FRAMEWORK_ROOT, 'lib/eui-pilot-runtime'));
// GMS特有逻辑
function createConfig() { ... } // portalUrl, systemName, loginPassword...
async function loginRetailStable() { ... } // #nav_retail, #loginName, /gms/new_index
async function switchCurrentZone() { ... } // /gms/user/swichZone
async function openMenu() { ... } // #mainTabs, addTab
async function ensurePortalReady() { ... } // login + zone switch
// 主入口:编排上述GMS函数
async function connectToMenuPage(menuText, url, options) {
const browserHandle = await launchBrowser(pwConfig, logger, 'gms');
await ensurePortalReady(page, config);
await openMenu(page, menuText, url, config);
const frame = await findBusinessFrame(page, url);
await injectHelper(frame);
return { page, frame, browserHandle, ... };
}
依赖关系清晰化:
gms/workspace-runtime.js
├── browser-manager.js (launch, close)
└── eui-pilot-runtime.js (findBusinessFrame, injectHelper)
workspace只引入它需要的通用能力,自行实现业务逻辑。第二个workspace(aigo)完全不需要这些GMS函数,它有自己的ensureAuthenticated()处理SPA cookie认证。
2.5 EUI workspace-runtime契约
框架定义了EUI workspace必须实现的接口:
| 导出 | 必须 | 说明 |
|---|---|---|
createConfig(workspaceConfig) |
✅ | 工作区配置构建 |
connectToMenuPage(menuText, url, opts) |
✅ | 完整导航到业务页面 |
ensurePortalReady(page, config) |
⚠️ 共享会话必须 | 登录+初始化(不含菜单导航) |
connectToMenuPage是独立模式的入口,ensurePortalReady是共享模式的入口。框架的browser-session-manager通过pilot.ensurePortalReady()调用workspace的实现,零GMS耦合。
三、Class模式与数据注入
3.1 从对象模式到Class模式
上周文章中的测试用例是对象模式:
// 对象模式(legacy)
module.exports = {
module: 'bill-delivery-nt',
cases: [{
id: 'query-components',
layer: 'ui-structure',
steps: [...]
}]
};
重构后引入了Class模式作为标准:
// Class模式(标准)
class SkillsPublishSpec {
name = 'skills-publish';
description = '技能发布页面测试';
order = 40;
labels = { workflow: 'skills-lifecycle' };
constructor(ctx) {
this.ctx = ctx;
}
async beforeSetup() {
this.dataset = loadYamlFile(path.join(__dirname, 'datasets/smoke.yaml'));
}
get specs() {
return [
{
name: 'skills-publish-form-flow',
description: '填写表单、切换来源、验证操作按钮',
run: async ({ page, step, assert }) => {
await step('navigate', async () => {
await page.goto(baseUrl + routes.publish);
});
// ...
},
},
];
}
}
module.exports = SkillsPublishSpec;
Class模式的优势:
- 生命周期完整:
beforeSetup()→specs→afterSetup(),支持模块级初始化和清理 - 数据注入:
ctx.data支持跨spec数据传递 - 排序可控:
order字段决定执行顺序 - 标签分组:
labels支持workflow级别的用例筛选
3.2 三层数据模型
参照Web的application/session/request设计,DataContext提供三层隔离:
| 层级 | 生命周期 | 读写 | 用途 |
|---|---|---|---|
applicationData |
整个run进程 | 只读 | config.json + datasets配置 |
sessionData |
一条闭环链路 | 读写 | 跨spec共享状态 |
requestData |
单个spec执行 | 读写 | spec内临时数据,自动清空 |
查找优先级:request → session → named datasets → application
这解决了跨模块数据传递的问题。例如技能生命周期测试中,发布spec产生的skillName通过ctx.data.set('session.skillName', name)写入session层,后续的审核spec、详情spec通过ctx.data.get('session.skillName')读取——无需硬编码,无需环境变量。
3.3 Workflow编排
Class模式 + 数据注入使得跨模块workflow编排成为可能:
// skills-lifecycle.spec.js — 跨模块编排
class SkillsLifecycleSpec {
async beforeSetup() {
// 实例化其他模块的spec class,共享同一个ctx
const publish = new SkillsPublishSpec(this.ctx);
const mySkills = new SkillsMySpec(this.ctx);
const review = new SkillsReviewSpec(this.ctx);
const discover = new SkillsDiscoverSpec(this.ctx);
await publish.beforeSetup();
await mySkills.beforeSetup();
await review.beforeSetup();
await discover.beforeSetup();
// 从各模块pick特定spec,编排完整生命周期
this.specs = [
pick(publish.specs, 'skills-publish-zip-upload-flow', 10),
pick(mySkills.specs, 'skills-my-browse-flow', 20),
pick(review.specs, 'skills-review-browse-flow', 30),
pick(review.specs, 'skills-review-confirm-flow', 31),
pick(discover.specs, 'skills-discover-browse-flow', 40),
// ... offline → delete → verify-deleted
];
}
}
发布spec创建技能并写入session.skillName,审核spec读取它进行审核,发现spec验证它出现在列表中——一条完整的业务闭环,8个spec,0个硬编码。
四、架构师的思考
4.1 单一职责不是口号
Chrome生命周期集中化的动机不是"代码不够优雅",而是实际问题驱动:
- viewport不一致导致测试行为差异
- 锁文件无法清理导致CI环境Chrome进程残留
- 远程模式需要改三个文件才能适配
当同一个职责(启动/关闭浏览器)散落在N个地方时,任何变更都需要N次修改,且每次修改都可能引入不一致。集中化后,远程模式的适配只改了一个文件——browser-manager.js——其他所有调用方通过pwConfig.remoteChromeUrl自动切换行为。
单一职责的价值不在于代码整洁,而在于变更成本的可控性。
4.2 框架与业务的边界
eui-pilot-runtime.js曾经同时包含launchBrowser(通用)和loginRetailStable(GMS特有)。当第二个workspace接入时,这个混合体无法适配——SPA不需要GMS登录,但需要浏览器启动。
分离后的依赖关系变得清晰:
框架层(pilot/lib/):
browser-manager.js → 通用浏览器生命周期
eui-pilot-runtime.js → 通用iframe发现 + Helper注入
spa-suite-runner.js → 通用SPA执行引擎
unified-runner.js → 通用EUI执行引擎
业务层(workspace):
gms/workspace-runtime.js → GMS登录 + 地区切换 + 菜单导航
aigo/workspace-runtime.js → SPA cookie认证
框架不知道GMS的存在,GMS不知道aigo的存在。每个workspace只引入它需要的通用能力,自行实现业务逻辑。 这正是SOLID中开闭原则的体现——框架对扩展开放(新workspace),对修改关闭(不加业务代码)。
4.3 远程Chrome是CI的基础设施
本地Chrome模式适合开发调试——看得见浏览器,可以手动排查。远程Chrome模式适合CI——浏览器在容器中运行,测试脚本通过CDP连接。
两种模式的切换只需一个配置项:
# 开发调试(本地Chrome,可见)
node pilot/run.js <workspace> <module>
# CI执行(远程Chrome,无头)
REMOTE_CHROME_URL=http://10.10.198.13:9223 node pilot/run.js <workspace> <module>
远程模式的关键约束——不能关闭浏览器,但必须关闭tab——看似简单,实则触及了"谁拥有资源生命周期"的核心问题。如果关闭逻辑散落各处,每个调用方都要判断"我是远程还是本地",代码就会变成条件分支的迷宫。集中化后,browser-manager.close()根据handle的remote标记自动选择行为,调用方无需关心。
4.4 从"能跑"到"能演进"
上周的架构解决了"能跑"的问题——CDP绕过EasyUI黑盒,AI生成用例降低编写成本。但"能跑"和"能演进"是两件事:
- 能跑:单一workspace、单一模式、硬编码配置
- 能演进:多workspace、双模式、配置驱动、远程支持
从"能跑"到"能演进"的关键转变是职责的明确分配:
| 职责 | 负责人 |
|---|---|
| 浏览器生命周期 | browser-manager.js |
| 业务认证 | workspace-runtime.js |
| 测试编排 | runner(EUI/SPA) |
| 数据传递 | DataContext |
| 配置加载 | playwright-config.js |
每个职责有明确的边界和接口。新增workspace时,只需实现workspace-runtime.js的契约,不需要修改框架代码。新增运行模式时(比如未来的移动端测试),只需在browser-manager.js中增加一种连接方式,不需要修改runner。
架构的价值不在于当下的功能,而在于未来变更的成本。
五、结语
上周的文章讲的是"如何打通最后一公里"——从0到1的突破。本文讲的是"如何让这条路可持续"——从1到N的演进。
两次重构的核心逻辑是相同的:让每个职责有唯一的负责人。Chrome的生命周期由browser-manager.js负责,业务认证由workspace-runtime.js负责,数据传递由DataContext负责。当远程Chrome需求出现时,只改了一个文件;当第二个workspace接入时,框架零修改。
E2E测试的最后一公里打通之后,真正的挑战不是"能不能跑",而是"能不能持续跑"——当业务迭代、技术栈演进、运行环境变化时,测试框架能否跟上而不崩溃。
单一职责、框架/业务分离、配置驱动——这些不是架构师的审美偏好,而是可持续演进的必要条件。
本文基于venus-sdd-auto-test项目的真实重构实践。框架代码约5000行,支持EUI(jQuery + EasyUI)和SPA(React + Ant Design)双模式,已接入2个workspace、覆盖8个测试模块。远程Chrome模式已在KasmVNC容器环境中验证通过。