1. 项目概述:从录制到扩展,UI Recorder的进阶之路
如果你已经用UI Recorder做过一些自动化测试,录制回放玩得挺溜,那你可能已经遇到了它的“天花板”。比如,想录一个带复杂拖拽的组件,发现它不支持;或者想把录制的脚本直接生成符合公司内部框架的测试用例,而不是默认的WebDriverIO代码;又或者,每次录制前都要手动设置一堆浏览器参数,烦不胜烦。这个时候,你就需要把手伸向UI Recorder的扩展开发了。这不仅仅是“高级玩法”,而是真正把工具变成自己趁手兵器的必经之路。
简单来说,UI Recorder本身是一个优秀的录制回放工具,但它不可能预知所有业务场景和技术栈。它的强大之处在于提供了一个可扩展的架构,允许我们通过开发自定义的“录制插件”来捕获更多类型的用户操作,以及通过定制“模板”来改变最终生成的测试代码结构。这就像给你一套乐高基础颗粒,你可以按照官方图纸搭出一个房子,但如果你想造一座带可动炮塔的城堡,就得自己设计并制作一些特殊形状的零件。本教程要做的,就是教你如何设计和制作这些“特殊零件”。
这个教程适合谁?首先,你得对UI Recorder的基本使用比较熟悉,知道怎么录、怎么放。其次,你需要有一定的JavaScript/Node.js基础,因为扩展开发本质上是在写Node.js模块。最后,也是最重要的,你需要有明确的“痛点”或“定制化需求”。如果你只是想用用现成的功能,那可能暂时用不上;但如果你想打造一个深度贴合自己团队技术栈和测试规范的自动化工具链,那么接下来的内容就是为你准备的。
2. 核心概念拆解:插件与模板到底是什么?
在动手之前,我们必须把两个核心概念——录制插件和模板——彻底掰扯清楚。很多人容易混淆,导致开发时方向错误。
2.1 录制插件:捕获行为的“传感器”
你可以把录制过程想象成在用户界面上布满了各种传感器。默认的UI Recorder自带了一套基础传感器,能感知点击、输入、跳转等常见操作。录制插件,就是你自己新增的、更 specialized 的传感器。
它的核心职责是:监听特定的用户操作或浏览器事件,并将其转化为一条结构化的“命令”。这条命令最终会被添加到录制脚本中。例如:
- 默认插件:监听一个
<button>的click事件,生成{command: ‘click’, target: ‘css=#submit-btn’, value: ‘’}。 - 自定义拖拽插件:监听
dragstart,dragover,drop等一系列事件,最终合成一条命令:{command: ‘dragAndDrop’, source: ‘css=.item’, target: ‘css=.drop-zone’}。 - 自定义文件上传插件:监听
<input type=“file”>的change事件,捕获文件路径,生成{command: ‘uploadFile’, target: ‘css=#file-input’, value: ‘/path/to/file.pdf’}。
插件开发的关键在于事件监听与命令合成。你需要在页面的上下文中(通常通过注入JavaScript代码实现)精准地捕获事件,并处理好事件间的时序和依赖关系,最终输出一条干净、可回放的命令。
2.2 模板:脚本的“模具”与“翻译官”
如果说插件决定了“录什么”,那么模板就决定了“录出来的代码长什么样”。模板是一个代码生成器,它接收录制插件产生的原始命令序列,然后按照你定义的格式和规则,输出最终的测试脚本。
它的核心职责是:将通用的命令对象,翻译成特定测试框架/风格的源代码。例如,同一条click命令:
- WebDriverIO模板:可能生成
await $(‘#submit-btn’).click(); - Puppeteer模板:可能生成
await page.click(‘#submit-btn’); - 你司内部测试框架模板:可能生成
TestAction.click(ElementLocator.byId(‘submit-btn’));
模板开发的关键在于字符串处理和逻辑抽象。你需要设计模板文件的语法(通常是基于类似EJS、Handlebars的模板引擎),并编写生成器逻辑,处理条件判断(如if/else)、循环(如遍历步骤)、变量插入等,使得输出的代码不仅语法正确,而且结构清晰、符合团队规范。
注意:插件和模板是松耦合的。一个自定义插件录制的命令,可以被任何模板使用;反之,一个自定义模板也可以处理所有插件(包括默认插件)产生的命令。这给了我们极大的灵活性。
3. 开发环境搭建与项目结构剖析
工欲善其事,必先利其器。在开始编码前,我们需要一个正确的开发环境,并透彻理解UI Recorder扩展项目的组织结构。
3.1 环境准备与初始化
首先,确保你的系统已经安装了Node.js(建议LTS版本,如16.x或18.x)和npm。接下来,我们不是直接修改UI Recorder主程序,而是创建一个独立的扩展项目。
# 1. 创建一个新的目录作为你的扩展项目 mkdir my-ui-recorder-extension cd my-ui-recorder-extension # 2. 初始化npm项目 npm init -y # 3. 安装UI Recorder作为开发依赖(或peerDependency),以便引用其类型定义和工具函数 # 注意:这里需要根据你使用的UI Recorder具体版本和发布方式来安装。 # 假设它已发布到npm,你可以: npm install ui-recorder --save-dev # 或者,如果你是基于某个特定分支开发,可能需要链接本地版本。一个典型的扩展项目结构如下所示:
my-ui-recorder-extension/ ├── package.json ├── src/ │ ├── plugins/ # 存放自定义录制插件 │ │ ├── my-drag-plugin.js │ │ └── my-upload-plugin.js │ └── templates/ # 存放自定义模板 │ ├── my-custom-template/ │ │ ├── index.js # 模板入口文件 │ │ ├── template.ejs # 模板文件(以EJS为例) │ │ └── assets/ # 静态资源(可选) │ └── another-template/ ├── dist/ # 构建输出目录(可选) └── README.md3.2 理解插件与模板的契约(接口)
UI Recorder通过预定义的接口与插件和模板进行交互。了解这些接口是开发的基础。
对于录制插件,它通常需要导出一个类或对象,包含以下关键方法:
init(context): 初始化方法,接收一个上下文对象,其中包含浏览器页面的实例(如Puppeteer的page对象)、事件总线等。你在这里注入监听脚本。handleEvent(eventData): (可选)处理从页面传递过来的自定义事件。getSupportedCommands(): 返回一个数组,声明本插件能生成哪些命令类型(如[‘dragAndDrop’, ‘customSwipe’])。cleanup(): 清理方法,移除事件监听,释放资源。
对于模板,它通常需要导出一个对象,包含以下关键属性:
name: 模板名称,用于在UI Recorder界面中选择。description: 模板描述。generate(commands, options): 核心方法,接收命令数组和选项,返回生成的代码字符串。fileExtension: 生成文件的后缀名,如.js,.spec.js,.py。
实操心得:在开始编码前,最好的方法是先研究UI Recorder自带的默认插件和模板源码。它们位于UI Recorder安装目录的
lib/plugins和lib/templates下。这是最准确、最直接的学习资料,能让你快速理解其内部工作机制和接口细节,避免自己凭空想象。
4. 实战一:开发一个自定义拖拽录制插件
让我们通过一个实际案例——为可排序列表开发拖拽录制插件,来深入插件开发的全过程。我们的目标是:当用户拖拽一个列表项改变其顺序时,能录制下这个操作。
4.1 需求分析与设计
假设我们有一个使用Sortable.js库实现的列表。拖拽操作涉及多个事件:dragstart(开始拖)、dragover(经过目标)、drop(放下)。我们不可能录制每一个细碎的鼠标事件,那样回放会非常不可靠。我们需要的是语义化的录制:录制“将A元素拖放到B位置”这个意图。
因此,插件设计思路是:
- 向页面注入脚本,监听相关元素的拖拽事件。
- 在
dragstart时,记录被拖拽的元素(源元素)。 - 在
drop时,记录拖放的目标位置(可能是某个容器或另一个元素)。 - 将“源”和“目标”信息合成一条
dragAndDrop命令。
4.2 插件代码实现
以下是插件src/plugins/drag-and-drop-plugin.js的核心代码框架:
// 引入可能需要的工具,具体取决于UI Recorder提供的运行时环境 // const { BasePlugin } = require('ui-recorder/plugin-base'); class DragAndDropPlugin { constructor() { this.name = 'dragAndDropPlugin'; this.supportedCommands = ['dragAndDrop']; this.draggedElement = null; } // 初始化,注入监听脚本 async init(context) { this.page = context.page; // 假设context提供了puppeteer page对象 this.eventBus = context.eventBus; // 用于发送录制命令 // 向页面注入JavaScript代码来监听拖拽事件 await this.page.addScriptTag({ content: ` (function() { // 监听全局的dragstart事件,记录源元素 document.addEventListener('dragstart', function(event) { // 这里需要一种方式将元素信息传递回Node.js环境 // 通常可以通过window.postMessage或暴露全局函数 window.__uiRecorderDragStart = { selector: getUniqueSelector(event.target), // 需要一个生成选择器的函数 tagName: event.target.tagName, // ... 其他有用信息 }; }, true); // 监听drop事件,合成命令并通知录制器 document.addEventListener('drop', function(event) { event.preventDefault(); if (!window.__uiRecorderDragStart) return; const source = window.__uiRecorderDragStart; const targetSelector = getUniqueSelector(event.target); // 发送消息给插件 const message = { type: 'DRAG_AND_DROP', data: { command: 'dragAndDrop', source: source.selector, target: targetSelector, timestamp: Date.now() } }; // 通过某种机制将message发送到Node.js端,例如通过console.log一个特殊格式,或者通过page.exposeFunction console.log('[UI-RECORDER-DRAG]' + JSON.stringify(message)); }, true); // 一个简单的获取元素唯一选择器的函数(实际应用需要更健壮的版本) function getUniqueSelector(el) { if (el.id) return '#' + el.id; let path = []; while (el && el.nodeType === Node.ELEMENT_NODE) { let selector = el.nodeName.toLowerCase(); if (el.className) { selector += '.' + el.className.trim().replace(/\\s+/g, '.'); } path.unshift(selector); el = el.parentNode; } return path.join(' > ') || ''; } })(); ` }); // 监听页面console消息,捕获我们注入脚本打印的命令 this.page.on('console', async (msg) => { const text = msg.text(); if (text.startsWith('[UI-RECORDER-DRAG]')) { try { const message = JSON.parse(text.replace('[UI-RECORDER-DRAG]', '')); if (message.type === 'DRAG_AND_DROP') { // 通过事件总线或直接调用方式,添加命令到录制序列 this.eventBus.emit('command', message.data); } } catch (e) { console.error('解析拖拽命令失败:', e); } } }); } getSupportedCommands() { return this.supportedCommands; } async cleanup() { // 移除事件监听,清理全局变量 await this.page.removeScriptTag(/* 需要保存之前注入的脚本标识 */); this.page.off('console', this._consoleHandler); } } module.exports = DragAndDropPlugin;4.3 插件注册与集成
开发完成后,需要让UI Recorder知道这个插件的存在。通常有两种方式:
- 配置文件:在UI Recorder的配置文件中(如
ui-recorder.config.js)添加插件路径。 - 动态加载:UI Recorder提供API在启动时加载插件。
假设采用配置文件方式:
// ui-recorder.config.js module.exports = { plugins: [ require.resolve('./src/plugins/drag-and-drop-plugin'), // ... 其他插件 ], // ... 其他配置 };注意事项与避坑指南:
- 选择器稳定性:上面例子中的
getUniqueSelector函数非常简陋。在生产环境中,你必须使用更稳健的算法来生成唯一且回放时能准确定位的选择器,可以考虑使用css-selector-generator这类库。- 事件干扰:你注入的脚本可能会影响页面原有逻辑(比如阻止了事件的默认行为)。要确保你的监听器在完成工作后,不影响页面的正常功能。有时需要使用
passive: true等选项。- 通信机制:本例通过
console.log进行通信,简单但不一定是最优解。更可靠的方式是利用page.exposeFunction在浏览器端和Node.js端建立直接的函数调用通道。- 异步处理:页面事件是异步的,要处理好命令生成的时序,避免并发拖拽导致命令错乱。
5. 实战二:开发一个自定义模板(以生成Jest风格测试用例为例)
现在,假设你们的前端测试框架是Jest,并且希望录制的脚本能直接生成*.test.js文件,集成到现有的Jest测试流水线中。我们来开发一个自定义模板。
5.1 模板结构与设计
我们的模板将放在src/templates/jest-template/目录下。它需要生成类似下面的代码结构:
describe(‘用户登录流程’, () => { beforeEach(async () => { await page.goto(‘https://example.com’); }); it(‘应该能成功登录’, async () => { await page.click(‘#username’); await page.type(‘#username’, ‘testuser’); await page.click(‘#password’); await page.type(‘#password’, ‘password123’); await page.click(‘#submit’); await expect(page).toMatchElement(‘.welcome-message’); }); });我们需要处理:生成describe/it块、将通用命令转换为Puppeteer API调用、插入断言等。
5.2 模板文件与生成器实现
第一步:创建模板文件 (template.ejs)。我们使用EJS语法,因为它直观易懂。
describe(‘<%= testSuiteName %>’, () => { <% if (beforeEachHook) { %> beforeEach(async () => { await page.goto(‘<%= initialUrl %>’); }); <% } %> <% commands.forEach((cmd, index) => { %> <% if (cmd.command === ‘click’) { %> await page.click(‘<%= cmd.target %>’); <% } else if (cmd.command === ‘type’) { %> await page.type(‘<%= cmd.target %>’, ‘<%= cmd.value %>’); <% } else if (cmd.command === ‘dragAndDrop’) { %> // 这里需要调用自定义的拖拽辅助函数 await dragAndDrop(‘<%= cmd.source %>’, ‘<%= cmd.target %>’); <% } %> <% // 可以在特定命令后插入断言,例如在登录点击后检查URL %> <% if (cmd.command === ‘click’ && cmd.target === ‘#submit’) { %> await expect(page.url()).toMatch(/dashboard/); <% } %> <% }); %> });第二步:创建模板入口文件 (index.js)。这是模板的核心逻辑。
const ejs = require(‘ejs’); const fs = require(‘fs’); const path = require(‘path’); class JestTemplate { constructor() { this.name = ‘jest-puppeteer’; this.description = ‘生成适用于Jest + Puppeteer的测试用例’; this.fileExtension = ‘.test.js’; } generate(commands, options = {}) { // 读取模板文件 const templatePath = path.join(__dirname, ‘template.ejs’); const templateStr = fs.readFileSync(templatePath, ‘utf-8’); // 准备模板数据 const data = { testSuiteName: options.testSuiteName || ‘录制的测试用例’, initialUrl: options.initialUrl || commands[0]?.url || ‘about:blank’, beforeEachHook: options.beforeEachHook !== false, // 默认生成beforeEach commands: this._preprocessCommands(commands), // 预处理命令 // 可以传入更多自定义选项,如是否生成截图代码、超时时间等 }; // 渲染模板 let generatedCode = ejs.render(templateStr, data); // 后处理:添加必要的导入语句和辅助函数 generatedCode = this._addImportsAndHelpers(generatedCode, options); return generatedCode; } _preprocessCommands(commands) { // 对命令进行清洗和转换 return commands.map(cmd => { // 例如,确保选择器格式符合Puppeteer要求 let target = cmd.target; if (target && target.startsWith(‘css=’)) { target = target.substring(4); } return { …cmd, target }; }).filter(cmd => [‘click’, ‘type’, ‘dragAndDrop’].includes(cmd.command)); // 过滤支持的命令 } _addImportsAndHelpers(code, options) { const imports = ` const { toMatchImageSnapshot } = require(‘jest-image-snapshot’); expect.extend({ toMatchImageSnapshot }); // 拖拽辅助函数(需要在实际项目中实现或引入) async function dragAndDrop(sourceSelector, targetSelector) { const source = await page.$(sourceSelector); const target = await page.$(targetSelector); const sourceBox = await source.boundingBox(); const targetBox = await target.boundingBox(); await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2); await page.mouse.down(); await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2); await page.mouse.up(); } `; return imports + ‘\n\n’ + code; } } module.exports = new JestTemplate(); // 导出一个实例5.3 模板配置与使用
同样,需要在UI Recorder的配置中注册这个模板:
// ui-recorder.config.js module.exports = { templates: { ‘jest’: require.resolve(‘./src/templates/jest-template’), }, defaultTemplate: ‘jest’, // 设置默认模板 // ... 其他配置 };实操心得:模板设计的艺术
- 可配置性:好的模板应该提供丰富的选项(
options),比如是否生成beforeEach/afterEach钩子、是否添加页面截图断言、自定义超时时间等。这能让模板适应更多场景。- 代码质量:生成的代码应该格式优美、符合团队的lint规范。可以考虑集成
prettier在生成后格式化代码。- 可维护性:模板逻辑不宜过于复杂。将不同的命令转换逻辑拆分成独立的函数或子模板,便于维护和扩展。例如,可以为
dragAndDrop命令单独写一个转换器。- 处理边界情况:思考如果命令序列为空怎么办?如果第一个命令不是导航命令怎么办?模板应该足够健壮,能处理这些情况,或者给出友好的提示。
6. 高级技巧与集成策略
掌握了插件和模板的基础开发后,我们可以探讨一些进阶话题,让你的扩展更强大、更专业。
6.1 插件间的协同与事件总线
复杂的操作可能需要多个插件协同工作。例如,一个“上传文件然后提交表单”的流程,可能涉及“文件上传插件”和“表单提交插件”。它们之间如何通信?这时,事件总线(Event Bus)就派上用场了。
UI Recorder的核心通常会提供一个全局的事件总线。插件可以在初始化时订阅和发布事件。
- 发布事件:当一个插件完成某个阶段(如文件已选择),它可以发布一个
file:selected事件,并携带文件信息。 - 订阅事件:另一个插件(如表单提交插件)可以订阅
file:selected事件,当收到事件后,再执行后续的“点击提交按钮”操作,并在生成的命令中关联前一个操作。
这允许你将一个完整的用户流程拆解成多个原子插件,提高复用性。在你的插件init方法中,可以这样使用:
async init(context) { this.eventBus = context.eventBus; // 订阅事件 this.eventBus.on(‘file:selected’, (fileData) => { this.currentFile = fileData; }); // 发布事件 this.eventBus.emit(‘plugin:ready’, { name: this.name }); }6.2 模板的动态片段与代码生成优化
对于大型项目,生成的测试代码可能非常长。我们可以通过“动态片段”和“代码分割”来优化。
- 动态片段(Partial):类似于EJS的
<%- include(‘partials/header’) %>,你可以将常用的代码块(如登录函数、页面对象模型定义)抽离成独立的片段文件,在模板中引入。 - 按场景生成:不是所有命令都需要转换成同样的代码。可以在模板的
generate方法中,先对命令序列进行分析和分组。例如,将所有在同一个页面内的操作分组,并为每个页面生成一个独立的describe块,使得测试结构更清晰。 - 生成Page Object:更高级的模板可以直接生成Page Object模型。分析录制过程中频繁操作的元素,自动生成一个对应的Page Class,将
page.click(‘#submit’)升级为LoginPage.submit(),大幅提升生成代码的可维护性。
6.3 扩展的调试、打包与分发
调试:调试插件最有效的方法是利用Node.js的调试工具和浏览器的DevTools。
- 使用
--inspect-brk参数启动UI Recorder。 - 在Chrome DevTools中连接Node.js调试器,可以给你的插件代码打断点。
- 同时,利用Puppeteer的
page.evaluateOnNewDocument和page.exposeFunction,在浏览器端输出详细的日志,帮助理解事件流。
打包与分发:当你开发了一个好用的扩展,想分享给团队时,需要打包。
- 使用Webpack或Rollup将你的插件/模板代码(可能包含多个文件)打包成一个单独的UMD或CommonJS文件。
- 在
package.json中定义好入口文件(main字段)和必要的元数据。 - 可以发布到私有的npm仓库,或者直接通过git仓库依赖。
- 编写清晰的
README.md,说明安装、配置和使用方法,并提供简单的示例。
7. 常见问题排查与实战经验录
在实际开发中,你会遇到各种各样的问题。这里记录一些典型问题的排查思路和我踩过的坑。
7.1 插件问题排查清单
| 问题现象 | 可能原因 | 排查步骤 |
|---|---|---|
| 插件已加载,但无法录制特定操作 | 1. 事件监听未正确注入。 2. 选择器无法在回放时定位元素。 3. 事件触发时机不对(如动态加载内容)。 | 1. 检查注入脚本的page.addScriptTag是否成功,在浏览器控制台查看是否有错误。2. 在回放环境中,手动执行生成的选择器,看能否找到元素。 3. 尝试使用 MutationObserver监听DOM变化,或在操作前增加等待逻辑。 |
| 录制的命令顺序错乱 | 异步事件处理不当,多个命令同时触发。 | 1. 在插件内部引入命令队列,确保命令按顺序发出。 2. 为命令添加更精确的时间戳,在模板生成时进行排序。 |
| 插件导致原页面功能异常 | 注入的脚本阻止了事件冒泡/默认行为,或污染了全局变量。 | 1. 检查事件监听器是否使用了passive: true或确保在必要时调用event.preventDefault()。2. 将插件使用的全局变量封装在唯一的命名空间下,如 window.__UI_RECORDER_MY_PLUGIN。 |
7.2 模板问题排查清单
| 问题现象 | 可能原因 | 排查步骤 |
|---|---|---|
| 生成的代码无法运行(语法错误) | 1. 模板语法错误。 2. 命令数据包含非法字符(如未转义的单引号)。 3. 生成的代码格式混乱。 | 1. 先用一组简单的命令数据测试模板,确保基础生成逻辑正确。 2. 对插入到代码字符串中的变量(如 cmd.value)进行严格的转义处理。3. 集成代码格式化工具(如 prettier)对输出进行后处理。 |
| 生成的代码运行结果与录制不符 | 1. 命令转换逻辑有误(如选择器转换错误)。 2. 缺少必要的等待或断言。 3. 页面状态在录制和回放时不一致。 | 1. 对比录制时的原始命令和模板生成的代码,逐行检查转换逻辑。 2. 在容易出问题的操作(如跳转、弹窗)后,模板应自动生成等待语句(如 await page.waitForNavigation())。3. 考虑在模板中引入“上下文感知”能力,根据上一个命令推断是否需要等待。 |
| 模板性能差,生成慢 | 1. 模板渲染逻辑复杂,尤其是循环嵌套过多。 2. 在 generate方法中进行了同步的IO操作(如频繁读文件)。 | 1. 优化模板逻辑,减少不必要的循环和条件判断。 2. 将静态资源(如辅助函数代码)缓存起来,避免每次生成都读取。 |
7.3 来自实战的几点核心经验
- 选择器可靠性是第一生命线:无论插件做得多花哨,如果生成的选择器回放时找不到元素,一切归零。投入精力打造一个健壮的选择器生成算法,比开发十个新插件都重要。优先考虑
id、>