背景

在做 Web 兼容测试时,测试人员往往需要在不同浏览器上重复执行相同的操作。
现有自动化录制手段,其实是后置的对比,效率与反馈都存在延迟,执行过程相对是黑盒的,过程中如果测试人员没细化到具体的校验点,即使是很明显的样式差异,脚本也很难发现。且如果是脚本或浏览器差异的问题,自动化运行的方式并不能够及时手动调整容错。

于是便思考有没有一种实时操作,而且可以便捷校验方案。

通过调研了 browsersync、uirecorder 等工具后,我设计了如下的同步兼容测试工具。

架构设计

系统主要由四个区块组成:

总体来说前端和服务端的工作量是比较小的,只要关注任务创建和 vnc 连接展示即可。
系统的核心在于驱动层,它是每个任务同步操作、对比的中心。而浏览器池采用 selenium-gird 或 solenoid 都是可以的,选择适合维护的即可。

功能点

实现效果

操作同步过程中,可以实时看到从浏览器执行情况,也可以通过列表状态颜色来判断。

同步出错的浏览器,执行过程中可以手动进去确认下是不是问题

图像相似度对比,可以自定义允许的差异值。主要是方便测试人员快速识别差异的位置,辅助人工判断。

驱动设计

开源地址

https://github.com/t880216t/yutu-tools.git

开发背景

本系统的核心是同步驱动,这里我且称之为 yutu, 它是在uirecorder项目上,经深度自定义开发而来,如果你查看它源码,不难发现很多 uirecorder 的影子。起初本打算结合browsersync的侵入式脚本实现操作同步功能,但在建设f2etest版本的浏览器云项目时遇到了它,官方版本主要用来做操作录制的,其中有个本地实时对比校验的附属功能,这正合我意,开始撸它源码。

起初在本地 chrome 上一切顺利,但接入 selenoid 浏览器池后,开始对接 firefox 时发现了问题。uirecorder的核心jwebdriver不支持最新的 W3C 协议,而且从钉钉群里官方反馈情况来看,这个项目 2 年没更新了,多半是夭折了。没办法自己从头撸吧,于是采用最新的WebdriverIO客户端结合自身的需求,对其进行了深度的改造,从而有了yutu,在此也感谢下前人努力与开源。

数据流程

功能简介

它主要是一个命令行工具,通过 sudo npm i -g yutu-tools 全局安装到系统中.
其中它的图片对比能力是来自于graphicsmagick,因此还需要额外安装下
mac:brew install graphicsmagick

yutu 对外主要提供以下两个命令行功能:

效果展示

所以其实从上面的设计图不难发现,它本身就是个独立的工具,可以不依赖于整体系统来使用,是否调用远程浏览器是可以通过config.json来配置的,如果是 serverIp 是127.0.0.1那么就会调用本地的 chromedriver 来操作(本地其它浏览器调用功能在开发设计中),开发过程中,本地调试时可以方便快速定位驱动问题。

主要改动点

命令行使用

原先的工程采用的是本地控制台交互问答式的参数配置方式,这肯定不适合我们平台化嵌入,同时为了更强大的功能开发和更复杂的参数支持,我将摒弃了命令行参数的配置方式,现在核心的配置参数都直接读取初始化后config.json,因此如果是接入系统,那么可以用脚本复写配置 json,如果本地调试,那么手动维护下 json 即可。

使用示例:

$ yutu init

修改 config.json 参数,配置内容及格式如下:

{
    "webdriver": {
        "host": "127.0.0.1",  // 远程hub地址
        "port": "4444",
        "mainBrowser": {
            "browserId": 2,
            "displayName": "chrome",
            "browserName": "chrome",
            "version": "106",
            "httpProxy": "",
            "binary": null
        },
        "syncBrowsers": [
            {
                "browserId": 1,  // 浏览器的唯一标识
                "proxy": "",  // 自定义参数,暂未启用
                "screenSize": "1920x1080x24", // 自定义参数,暂未启用
                "browserName": "firefox",  // 浏览器内核的名字,如:chrome、firefox
                "displayName": "firefox",   // 浏览器的名字如:qq、yandex
                "version": "105",
                "binary": null   // chromium内核的国产浏览器的exe执行文件路径
            }
        ]
    },
    "browserSize": "1920x1080x24",
    "defaultUrl": "https://www.baidu.com/",
    "vars": {},
    "serverIp": "192.168.1.101",  //本地执行命令机器的ip,非远程webdriver,可以使用127.0.0.1
    .....
}

启动同步服务

$ yutu start

自定义驱动

在用WebdriverIO替换掉jwebdriver后,原先的很多 api 都改变了,这需要我们对 driver 对象进行深度的包装改造,于是我在本地增加了个 browser 对象,用来代理WebdriverIO的 driver 对象,在其中增加我们需要的 driver 扩展能力。

这里需要用到 nodejs 的 Proxy 机制、链式调用、Promise 等写法。(感谢前端同事龙哥)

class mBrowser {
    constructor(browser) {
        const handers = {
            get(obj, key){
                return key in obj?obj[key]: browser[key]
            }
        }
        return new Proxy(this, handers);
    }
...

浏览器插件

yutu 能够将用户操作回传给 socket server 的关键是依靠一个浏览器插件,它只会在启动主控制浏览器时,通过goog:chromeOptions参数将插件以文件数据流传给 chrome 浏览器,因此我们的主控浏览器默认也必须是 chrome。这个插件本体还是uirecorder的,只做了对接yutu的适应的调整。

var crxPath = path.resolve(__dirname, '../tool/uirecorder.crx');
var extContent = fs.readFileSync(crxPath).toString('base64');
capabilities["goog:chromeOptions"] = {
    args: ['--disable-bundled-ppapi-flash'],
    prefs: {
        'plugins.plugins_disabled': ['Adobe Flash Player']
    },
    excludeSwitches: ['enable-automation'],
    extensions: [extContent],
};

在我的需求里,插件中还有很多需要优化的地方,后面有空慢慢搞吧,目前改动的主要是插件启动页面接收参数、动态服务器 ip 等:

if (mapParams.defaultUrl && txtUrl){
        txtUrl.value = decodeURIComponent(mapParams.defaultUrl);
    }
function connectServer(data){
    const {ip, port} = data
    console.log('data', data);
    if(!wsSocket){
        wsSocket = new WebSocket('ws://'+ ip + ':' + port, "protocolOne");
...

修改插件的 js 后,需要重新打包生成插件 crx。

$ ./buildcrx.sh

前端设计

这个系统前端部分主要是 2 个页面:


因为考虑到客户实际使用的是以 windows 为主,为了保证测试结果的准确性,所以我们这里浏览器运行镜像主要是自定义封装的 windows 系统镜像(太痛苦了,此处包含泪水,详见下文解读)。

在代码方面,继续秉承组件化思想,结合 antd pro 的高阶组件,对多处进行了抽象复用。

import { ProCard, ProTable } from '@ant-design/pro-components';

操作部分,结合了较为小众但稳定可靠的react-vnc库,同时为了降低用户的浏览器资源消耗,操作页面在同步浏览器列表展开时才会进行 vnc 连接展示。

<Sider width={'20%'} collapsible collapsed={collapsed} onCollapse={collapsed => this.setState({collapsed})}>
  <Card size="small" title={!collapsed? "同步浏览器列表": '同步'} >
    {syncBrowsers && syncBrowsers.length > 0? (
      syncBrowsers.map(item => (
        <Card.Grid key={item.sessionId} className={styles.syncContainer}>
          {!collapsed ? (
            <VncScreen
              url={item.vncUrl}
              rfbOptions={{
                credentials: {
                  password: 'selenoid',
                },
              }}
              scaleViewport
              background='#000000'
              style={{
                height: '100%'
              }}
            />
          ): (
            <div className={styles.browserName}><img src={`/${item?.browserName}.react.svg`} alt='' />{item.browserName}</div>
          )}
          <div ref={n => (this[`hover_${item.sessionId}`] = n)} className={styles.hoverContainer}>
            <SyncModal data={item} actions={this.state.syncActions[item.sessionId]} />
          </div>
        </Card.Grid>
      ))
    ): (
      <Empty />
    )}
  </Card>
</Sider>

服务端设计

服务端主要是提供数据给前端展示,以及启动脚本调用系统命令的,接口部分千篇一律,增删改查而已,不展开介绍了。

这里有个小细节,yutu本身不会和数据库已经服务端进行交互的,所以它的任务运行状态,需要告知脚本是个麻烦事。我是通过脚本监控单个任务进程的控制台信息,来达成的,这样的成本最小,也不必让 2 个工具过度耦合。

async def start_task(self):
    logger.info(f'Task {self.task_id} is starting...')
    run_cmd = f'yutu case.spec.js' \
              f' --browser_size={self.task_info["screen"]}' \
              f' --http_proxy={self.task_info["proxy"]}' \
              f' --default_url={self.task_info["url"]}'
    logger.info(f'start cmd: {run_cmd} ')
    p = subprocess.Popen(
      run_cmd,
      shell=True,
      stdout=subprocess.PIPE,
      stderr=subprocess.STDOUT,
      encoding='utf-8',
      cwd=self.task_dir_path
    )
    for i in iter(p.stdout.readline, 'utf-8'):
      if 'consoleParams:' in i:
        try:
          data = json.loads(i.replace('consoleParams:', ''))
          singal = await self.update_task_info(data)
          print(data)
          if not singal:
            break
        except Exception as e:
          print(e)
async def update_task_info(self, data):
    if not data:
      return
    row = BrcSyncTask.query.filter_by(id=self.task_id).first()
    if data['type'] == 'server':
      row.sync_server_ip = data['serverAddress']
      row.sync_server_port = data['serverPort']
      db.session.commit()
    elif data['type'] == 'main':
      row.main_session_id = data['sessionId']
      db.session.commit()
    elif data['type'] == 'sync':
      info = json.loads(row.sync_sessions) if row.sync_sessions else {}
      info[data['browserInfo']] = data['sessionId']
      row.sync_sessions = json.dumps(info)
      db.session.commit()
    elif data['type'] == 'signal':
      if data['status'] == 'ready':
        self.update_task_status(5)  # 开始同步
      if data['status'] == 'end':
        self.update_task_status(3)  # 同步结束
        db.session.flush()
        return False
    return True

自定义镜像封装

此处主要介绍本地封装 windows 版本的 selenoid 浏览器镜像的心得,懂得都懂,就不详细展开介绍了。
为什么要封装 windows 镜像,有 2 个原因。

关于 windows 封装的基础教程可以参考:windows-images

不过按照教程走下去后会发现,有可能你的容器能启动,但死活连不上浏览器 driver。
再通过反复试验后,我采用了 selenoid+selenoid 的方式,才让流程通起来。

容器内的 selenoid 服务

关键在于在浏览器和 driver 都安装后,再在 windows 里启动一个 selenoid 服务,让它来提供 4444 端口服务给外部的 selenoid hub 调用,由它来和容器内的浏览器 driver 进行交互。

为了方便复用,我在基础镜像中就加入这个基础工具包,文件目录如下:

start.bat 是一个封装后的执行文件,参数可以根据自己设备性能调整,内容如下:

C:\selenoid-windows\selenoid_windows_386.exe -conf C:\selenoid-windows\browsers.json -disable-docker -limit 4 -service-startup-timeout 240s -session-attempt-timeout 240s -session-delete-timeout 240s -timeout 240s > C:\selenoid-windows\selenoid.log 2>&1

browsers.json 如下

{
    "MicrosoftEdge": {
        "default": "18",
        "versions": {
            "18": {
                "image": [ "C:\\selenoid-windows\\webdrivers\\msedgedriver.exe", "--host=127.0.0.1", "--verbose" ]
            }
        }
    }
}

容器内的 flask 服务

看上面的工具包内容可以看到,我们还在里面起了个 flask 轻量服务,它的作用是接收外部传过来的配置参数,动态设置当前容器中的分辨率和系统代理。这个问题是 windows 镜像特有的,selenoid 官方团队说解决不了,为此也做过解释,
windows starts 1024x768 resolution even SCREEN_RESOLUTION changed to 1920x1080x24

我贡献的这个方法可以曲线解决这个问题,步骤也很简单,

flask 中的内容如下:

from flask import Flask
from flask import request
from flask import jsonify
import win32api
from winproxy import ProxySetting

app = Flask(__name__)

def setProxy(host, port):
    proxy = ProxySetting()
    proxy.enable = True
    proxy.server = f"{host}:{port}"
    proxy.override = ["127.*","192.168.*","10.*"]
    proxy.registry_write()

def setScreen(width, height):
    dm = win32api.EnumDisplaySettings(None, 0)
    dm.PelsWidth = int(width)
    dm.PelsHeight = int(height)
    dm.BitsPerPel = 32
    dm.DisplayFixedOutput = 0
    win32api.ChangeDisplaySettings(dm, 0)

@app.route('/setDisplay', methods=['GET'])
def index():
    height = request.args.get('height')
    width = request.args.get('width')
    host = request.args.get('host')
    port = request.args.get('port')
    setScreen(width, height)
    if host and port:
        setProxy(host, port)

    return jsonify({'width': width, 'height': height})

if __name__ == '__main__':
    app.run(host='127.0.0.1', port=5000)

由于这个服务是起在容器里的,因此我们可以在 yutu 的 driver 建立后,通过固定的 url 来调用,而不必维护容器的 ip 和网络情况。

if (configJson.webdriver.host !== '127.0.0.1'){
    const setScreenUrl = `http://127.0.0.1:5000/setDisplay?width=${width}&height=${height}&host=${hostname}&port=${port}`
    await driver.url(setScreenUrl);
}

开发过程中坑

项目真正开发到完成,投入 1.5 人/月左右,按照时间顺序来回顾这过程中的坑吧。

结语

项目目标算是达成了,但还不够完美,我会持续的优化。

通过此次的开发经历,也使我感触良多,技术类需求的不确定性,是软件行业的特性。以后对接公司工作中技术需求,我也要引以为戒,做好风险管理。

同时看到一个个曾经的明星项目的沉寂,也是让我百感交集,他们本该能够成长的更好,但或是公司环境的变化,或是创作者乏力无奈,总之慢慢淡出人们的记忆,甚至连创作团队自己都忘记,而我们就在这不断创造与消亡中轮回。

最后,借用尼采的警言与各位共勉:所有美好的事物都是曲折地接近自己的目标,一切笔直都是骗人的,所有真理都是弯曲的,时间本身就是一个圆圈。


↙↙↙阅读原文可查看相关链接,并与作者交流