We read every piece of feedback, and take your input very seriously.
To see all available qualifiers, see our documentation.
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
上篇文章介绍了整体的功能,基本实现以及交互,这篇介绍下模块设计,项目结构,代码组织这块。
技术实现上用到的 nestjs,vue3,puppeteer,pixelmatch,socket.io 这几个工具,行文以模块进行拆分
对与跨进程的通信,先提下nodejs之前的跨进程都有哪些方式
在具体的实现上,因为我们是启动的静态服务,在外部的浏览器直接打开的属于外部进程,所以这里就通过 socket.io 的方式来处理外部浏览器启动node服务的方式
// 客户端代码 const socket = io('ws://127.0.0.1:8000/', { transports: ['websocket'] }) // 服务端socket代码 io.on('connection', (socket) => { socket.emit('open') //通知客户端已连接 console.log('connected') socket.on('disconnect', () => { console.log('disconnect') }) socket.on('openTest', (data) => { openTest(data) }) socket.on('replayTest', (data) => { replayTest(data) }) })
客户端的配置在跨域的时候,一定要加上 transports: ['websocket'] 这个配置不然会导致链接不上,这个在调的时候花了很多时间,最后在配置里面发现了这个
transports: ['websocket']
点击页面的录制操作,通过 socket 会出发 node逻辑层 打开一个包装过的 puppeteer-chromium ,并且打开配置的 url 地址。
录制效果已经在第一篇文章中展示了,在录制的这一步,主要是自定义代码的注入、页面数据与node逻辑层的数据通信、用户行为劫持。
这里介绍两个方法 page.exposeFunction(定义一个native函数,函数会挂载在page的window上)而 page.evaluate (会在 page内创建一个 script ,并且立即执行),所以没有定义 exposeFunction 没办法对外部的数据进行处理。
这里的 exposeFunction 相当于一个 brige,链接了 page 中的 js 到外部逻辑层。顺便提及下,定义的 exposeFunction 可以是一个 promise 。
async function injection(browser, page) { let data = null // todo: 这里放一个空的 try catch 捕获,在 document onload 的时候,重复执行会报错 try { await page.exposeFunction( 'exposeFunction', function (arg) { data = arg } ) } catch (e) {} return page.evaluate(() => { window.exposeFunction('i am evaluate') }) }
事件的触发会先经历捕获阶段,到达目标节点后再经历冒泡阶段,也就是事件一开始在最外层元素触发,向内经历一轮后回到最外层元素、。同时在这个链条中的任意一个节点,都可以阻止事件向后传递。因此对于页面内任意元素的事件,我们都可以在捕获阶段,在最外层元素上统一进行监听。
在冒泡阶段进行,元素如果取消冒泡就无法捕获到了。
// getPathTo 获取的是元素的 xpath document.body.addEventListener( eventName, (event) => { recorder({ event: eventName, value: `${event.pageX},${event.pageY}`, target: getPathTo(event.target) }) }, true )
图中 eventRecord 是一个对象,定义十分简单
eventRecord = { record: [], status: 'pause' }
record 是一个队列,记录用户的操作;status 是状态,标示当前是否处理录制过程。
在实现上,面板模块和录制模块我是拆解成两个文件模块来实现,这样从工程的角度看更加模块化。eventRecord 在这个结构是一个中介者,为两个模块做数据传递。
在调用注入的时候,简单将 eventRecord 传入两个模块。
page.on('domcontentloaded', async () => { await recoderHandle(browser, page, eventRecord, opt) await pannelHandle(browser, page, eventRecord, opt) })
这里涉及到一个点,就是在录制模块内进行记录写入,面板模块的记录数需要递增,其实就是一个发布订阅的实现。
这里的特殊性在于跨模块的数据通信,所以这个 eventRecord 作为中介者作用就很突显了,在面板模块设置监听,这里用到proxy方法,只要录制模块对 eventRecord 进行修改,面板模块就可以计算 record 的长度进行显示。以前这种实现我们都是借助框架来做,那么自己手写的时候怎么写最简洁,介绍一个 api - CustomEvent 自定义事件。
// 给 record 套一层 proxy ,变化的时候,广播 eventRecord 事件 function recordProxy(eventRecord, cb) { return new Proxy(eventRecord.record, { set: function (obj, prop, newval) { obj[prop] = newval cb && cb() return true } }) } recordProxy(eventRecord, () => { const { record } = eventRecord page.evaluate( (record) => { document.dispatchEvent( new CustomEvent('eventRecord', { detail: { record } }) ) }, record ) }) // 面板模块的监听 createCount() { const tip = this.document.createElement('span') const text = (r, t) => `当前有记录 ${r} 条` tip.innerHTML = text(0, 0) // 这里用原生实现了一个简单的发布订阅 document.addEventListener('eventRecord', (event) => { const { record = 0, total = 0 } = event.detail || {} tip.innerHTML = text(record, total) }) return tip }
最后的验证测试是否通过,我们是通过用户每次的点击截图生成的快照以及自动运行测试用例产生的快照进行diff来判断的。
所以如果在后端数据不一致,最后生成的快照也一定是不一致的,那这样的UI对比没有意义了,所以这里需要保证录制前后后端借口数据都是一致的。
这个时候用 puppeteer 作为工具的好处就体现出来了,在录制用例的过程,可以对请求和相应进行拦截存储。
const collect = async (browser, page, opt) => { await page.setRequestInterception(true) page.on('response', async (response) => { // 在这个位置拦截 ajax 或 jsonp 的数据 // 获取当前 url + query + body 这几个参数合并成一个大 string // 对这个 string 进行 md5 处理成一个固定大小的唯一字符串 const request = response.request() const type = request.resourceType() if (type !== 'xhr' && type !== 'fetch') { return } try { const rsp = await response.text() let result = '' const url = request.url() const method = request.method() const postData = request.postData() result += type result += url result += method result += postData ? JSON.stringify(postData) : '' // console.log('request', md5(result), rsp) const key = md5(result) reqresUpdate({ pid: opt.pid, cid: opt.id, data: { [key]: rsp } }) // 在这里写一个截流函数,截流着去更新 } catch (e) { console.log('eee', e) } }) // 拦截请求,要拦截 response 这个也要写成这样,不然运行不了 page.on('request', async (interceptedRequest) => { interceptedRequest.continue() }) }
最终存储到数据库的形式
在执行测试用例的时候,page.on('request') 中拦截请求,对 url,method,postData 同样地进行 md5 然后用map的方式获取录制时产生的后端数据,通过这样记录回吐的方式保证测试过程接口数据的一致性。
当然目前只是对 ajax 和 fetch 数据进行拦截保存,项目中如果使用 jsonp ,需要在参数上显示标示出来是 jsonp ,然后再进行相应的拦截操作。
监听到所有的操作后,需要将记录的步骤一一保存下来,需要保存的其实只有三类数据数据:
这里有个问题,就是输入事件的情况:
对于 input 表单,在输入的时候就会触发 keydown 和 keyup 事件,在处理中文的时候,这样获取到的都是一个个英文字母,明显是无法满足实际需要的。
document.addEventListener( 'keydown', function keydownHander(event) { const tagName = event.target.tagName const key = event.key const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' const isInputBlurBind = event.target.getAttribute('blur-by-test') if (isInput && isInputBlurBind) { return } if (isInput && !isInputBlurBind) { function inputBlurHandle(blurEvent) { recorder( { event: 'type', value: blurEvent.target.value, target: getPathTo(blurEvent.target) }, true ) } event.target.addEventListener('blur', inputBlurHandle) // 设置特殊属性防止重复绑定 event.target.setAttribute('blur-by-test', true) return } recorKeydown(event) }, true )
在 input 类元素失去焦点的时候,获取当前 input 元素的 value,并且推入对应的记录。
重放操作主要是利用 page 实例上的方法,在存储用例的时候,用到的 event 字段其实来自 page 实例提供的方法。
所以核心实现如下,等待对应元素的出现,然后在改元素上执行对应的 event。
function replay(page, record, ido) { const result = [] for (let handle of record) { const { event, target, value } = handle if (event === 'screenShot') { const img = await screenshot(page) // 执行对比操作 const { url, flag } = await imageDiff(config.host + '/' + value, img) const testImg = await fileUpload(img) result.push({ origin: value, test: testImg, diff: url, flag: flag }) continue } const elements = await page.$x(target) await (elements[0] && elements[0][event] && elements[0][event](value)) } }
最后在进行结果比对的有很2种方案,
DOM对比这个方案实现起来比较简单,直接选择目标节点元素的内容进行对比即可,但是用例的维护成本较高,当界面发生变动的时候需要修改代码,而且直观性上差一些,无法直接反映出界面上的变动。
截屏对比方案的初始实现成本较高,要搭建一套截图保存、对比的流程,不过后续的维护成本较低,有新增页面的时候直接截图即可。同时由于是对整个界面进行截屏,所以可以覆盖页面上的所有元素,覆盖率更高一些,并且图片也更加直观。
所以最后选择了用截屏对比的方式来进行,用到的工具是 jpeg-js(jpeg解析,生成的图片是jpeg类型的),pixelmatch(提供图片的diff功能),直接上代码
/** * 图片 diff * @param {*} originUrl 原始图片地址 * @param {*} testImg 自动测试过程产生的图片的 buffer 对象 * @returns 返回 diff 图片地址,以及不一致的像素数量 */ async function imageDiff(originUrl, testImg) { const originUrlRsp = await got(originUrl) const originUrlData = jpeg.decode(originUrlRsp.rawBody) const TestImgData = jpeg.decode(testImg) const frameData = new Buffer(width * height * 4) const flag = pixelmatch( originUrlData.data, TestImgData.data, frameData, width, height, { threshold } ) const rawImageData = { data: frameData, width, height } const jpegImageData = jpeg.encode(rawImageData, genImgSize) const imgUrl = await fileUpload(jpegImageData.data) return { url: imgUrl, flag } }
存储后的数据库就是
其中 flag 的是两张图片 diff 的像素差异数
The text was updated successfully, but these errors were encountered:
No branches or pull requests
技术实现上用到的 nestjs,vue3,puppeteer,pixelmatch,socket.io 这几个工具,行文以模块进行拆分
页面与nodejs的跨进程通信
对与跨进程的通信,先提下nodejs之前的跨进程都有哪些方式
在具体的实现上,因为我们是启动的静态服务,在外部的浏览器直接打开的属于外部进程,所以这里就通过 socket.io 的方式来处理外部浏览器启动node服务的方式
客户端的配置在跨域的时候,一定要加上
transports: ['websocket']
这个配置不然会导致链接不上,这个在调的时候花了很多时间,最后在配置里面发现了这个测试用例的录制操作
点击页面的录制操作,通过 socket 会出发 node逻辑层 打开一个包装过的 puppeteer-chromium ,并且打开配置的 url 地址。
录制效果已经在第一篇文章中展示了,在录制的这一步,主要是自定义代码的注入、页面数据与node逻辑层的数据通信、用户行为劫持。
自定义代码的注入
这里介绍两个方法 page.exposeFunction(定义一个native函数,函数会挂载在page的window上)而 page.evaluate (会在
page内创建一个 script ,并且立即执行),所以没有定义 exposeFunction 没办法对外部的数据进行处理。
这里的 exposeFunction 相当于一个 brige,链接了 page 中的 js 到外部逻辑层。顺便提及下,定义的 exposeFunction 可以是一个 promise 。
监听操作
事件的触发会先经历捕获阶段,到达目标节点后再经历冒泡阶段,也就是事件一开始在最外层元素触发,向内经历一轮后回到最外层元素、。同时在这个链条中的任意一个节点,都可以阻止事件向后传递。因此对于页面内任意元素的事件,我们都可以在捕获阶段,在最外层元素上统一进行监听。
在冒泡阶段进行,元素如果取消冒泡就无法捕获到了。
页面数据与node逻辑层的数据通信
图中 eventRecord 是一个对象,定义十分简单
record 是一个队列,记录用户的操作;status 是状态,标示当前是否处理录制过程。
在实现上,面板模块和录制模块我是拆解成两个文件模块来实现,这样从工程的角度看更加模块化。eventRecord 在这个结构是一个中介者,为两个模块做数据传递。
在调用注入的时候,简单将 eventRecord 传入两个模块。
这里涉及到一个点,就是在录制模块内进行记录写入,面板模块的记录数需要递增,其实就是一个发布订阅的实现。
这里的特殊性在于跨模块的数据通信,所以这个 eventRecord 作为中介者作用就很突显了,在面板模块设置监听,这里用到proxy方法,只要录制模块对 eventRecord 进行修改,面板模块就可以计算 record 的长度进行显示。以前这种实现我们都是借助框架来做,那么自己手写的时候怎么写最简洁,介绍一个 api - CustomEvent 自定义事件。
数据一致性的保证
最后的验证测试是否通过,我们是通过用户每次的点击截图生成的快照以及自动运行测试用例产生的快照进行diff来判断的。
所以如果在后端数据不一致,最后生成的快照也一定是不一致的,那这样的UI对比没有意义了,所以这里需要保证录制前后后端借口数据都是一致的。
这个时候用 puppeteer 作为工具的好处就体现出来了,在录制用例的过程,可以对请求和相应进行拦截存储。
最终存储到数据库的形式
在执行测试用例的时候,page.on('request') 中拦截请求,对 url,method,postData 同样地进行 md5 然后用map的方式获取录制时产生的后端数据,通过这样记录回吐的方式保证测试过程接口数据的一致性。
当然目前只是对 ajax 和 fetch 数据进行拦截保存,项目中如果使用 jsonp ,需要在参数上显示标示出来是 jsonp ,然后再进行相应的拦截操作。
自动验证及结果保存
用例保存
监听到所有的操作后,需要将记录的步骤一一保存下来,需要保存的其实只有三类数据数据:
这里有个问题,就是输入事件的情况:
对于 input 表单,在输入的时候就会触发 keydown 和 keyup 事件,在处理中文的时候,这样获取到的都是一个个英文字母,明显是无法满足实际需要的。
在 input 类元素失去焦点的时候,获取当前 input 元素的 value,并且推入对应的记录。
自动测试的还原操作
重放操作主要是利用 page 实例上的方法,在存储用例的时候,用到的 event 字段其实来自 page 实例提供的方法。
所以核心实现如下,等待对应元素的出现,然后在改元素上执行对应的 event。
图片diff与报告生成
最后在进行结果比对的有很2种方案,
DOM对比这个方案实现起来比较简单,直接选择目标节点元素的内容进行对比即可,但是用例的维护成本较高,当界面发生变动的时候需要修改代码,而且直观性上差一些,无法直接反映出界面上的变动。
截屏对比方案的初始实现成本较高,要搭建一套截图保存、对比的流程,不过后续的维护成本较低,有新增页面的时候直接截图即可。同时由于是对整个界面进行截屏,所以可以覆盖页面上的所有元素,覆盖率更高一些,并且图片也更加直观。
所以最后选择了用截屏对比的方式来进行,用到的工具是 jpeg-js(jpeg解析,生成的图片是jpeg类型的),pixelmatch(提供图片的diff功能),直接上代码
存储后的数据库就是
其中 flag 的是两张图片 diff 的像素差异数
The text was updated successfully, but these errors were encountered: