返回文章
测试与验证·约 13 分钟·Kain

从双引擎到统一框架——VibeTest平台的架构演进

复盘 VibeTest 从双引擎走向统一框架时的边界调整和工程取舍。

从双引擎到统一框架——VibeTest平台的架构演进的封面
文章目录

上周我们分享了如何借助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 };
}

关键设计决策:

  1. resolveWsUrl:CDP地址可能是http://host:port格式,需要先访问/json/version获取webSocketDebuggerUrl。如果已经是ws://地址则直接使用。
  2. remote: true标记:handle携带这个标记,close()函数据此决定行为——远程模式只关tab,本地模式关浏览器。
  3. 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模式的优势:

  1. 生命周期完整:beforeSetup() → specs → afterSetup(),支持模块级初始化和清理
  2. 数据注入:ctx.data支持跨spec数据传递
  3. 排序可控:order字段决定执行顺序
  4. 标签分组: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容器环境中验证通过。

继续阅读同主题文章测试与验证