背景
在做 Web 兼容测试时,测试人员往往需要在不同浏览器上重复执行相同的操作。
现有自动化录制手段,其实是后置的对比,效率与反馈都存在延迟,执行过程相对是黑盒的,过程中如果测试人员没细化到具体的校验点,即使是很明显的样式差异,脚本也很难发现。且如果是脚本或浏览器差异的问题,自动化运行的方式并不能够及时手动调整容错。
于是便思考有没有一种实时操作,而且可以便捷校验方案。
通过调研了 browsersync、uirecorder 等工具后,我设计了如下的同步兼容测试工具。
架构设计
系统主要由四个区块组成:
- Web 前端:显示可用浏览器,触发同步操作任务,展示 VNC 连接。
- 服务端:提供数据接口给前端,调用本地脚本,构建任务执行环境。
- 同步驱动(自己起了个名字:yutu):下图中小兔子那部分,一个可单独调用的 npm 库,提供全局命令执行同步操作任务。
- 浏览器池:我使用的是基于 selenoid 的本地容器管理系统,当然也可以换成别的,看自己需求。
总体来说前端和服务端的工作量是比较小的,只要关注任务创建和 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 对外主要提供以下两个命令行功能:
- yutu init:初始化任务目录及配置文件
- yutu start:执行任务并输出日志
效果展示
所以其实从上面的设计图不难发现,它本身就是个独立的工具,可以不依赖于整体系统来使用,是否调用远程浏览器是可以通过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 个页面:
- 浏览器列表
- 同步操作页面(加几个弹层 Modal)

因为考虑到客户实际使用的是以 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 系统,官方提供的 liunx 版本浏览器镜像不能代表实际的浏览器使用场景。
- 方便环境隔离:直接调用单个主机的 webdriver 的方式,无法实现同一时间不同用户的远程操作和代理隔离(f2etest 的 webdriver 云方案无法远程操作)。
关于 windows 封装的基础教程可以参考:windows-images
不过按照教程走下去后会发现,有可能你的容器能启动,但死活连不上浏览器 driver。
再通过反复试验后,我采用了 selenoid+selenoid 的方式,才让流程通起来。
容器内的 selenoid 服务
关键在于在浏览器和 driver 都安装后,再在 windows 里启动一个 selenoid 服务,让它来提供 4444 端口服务给外部的 selenoid hub 调用,由它来和容器内的浏览器 driver 进行交互。
为了方便复用,我在基础镜像中就加入这个基础工具包,文件目录如下:

start.bat 是一个封装后的执行文件,参数可以根据自己设备性能调整,内容如下:
C:selenoid-windowsselenoid_windows_386.exe -conf C:selenoid-windowsbrowsers.json -disable-docker -limit 4 -service-startup-timeout 240s -session-attempt-timeout 240s -session-delete-timeout 240s -timeout 240s > C:selenoid-windowsselenoid.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
我贡献的这个方法可以曲线解决这个问题,步骤也很简单,
- 1.在基础镜像中安装个 python(2、3 随意)
- 2.安装工具包中依赖(关键是 flask、pywin32、winproxy 这几个库)
- 3.容器退出保存前,启动 flask、selenoid 服务
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 人/月左右,按照时间顺序来回顾这过程中的坑吧。
同步浏览器从一个改成 N 个后,浏览器关闭异常。
结合异步转同步的方式,改造关闭浏览器方法,保证执行有序async function closeBrowser (){ if(syncCheckBrowserDrivers){ for (var browserIndex in syncCheckBrowserDrivers){ var browser = syncCheckBrowserDrivers[browserIndex]; await new Promise((resolve) => { const {browserName, browserVersion } = browser; browser.close(() => { console.log(`${__('checker_browser_closed')}${browserName}${browserVersion}`.green); resolve(); }) }) } } }原先的 webdriver 客户端不支持 w3c 协议,导致新浏览器无法正常同步操作
这是我万万没想到的,虽然知道前端技术日新月异,用外部老的工具,肯定会有需要兼容的差异,但没想到 ali 老大哥们也摆烂了,也导致了我在中后期几乎重写了大部分的 yutu 中的同步操作方法。windows 镜像的产出后,运行不起来
一开始并没有明确定义 windows 支持对于我们系统的重要性,因此一直在以 liunx 版本的浏览器作为最小试验对象,但后期流程通后,要交付时才发现没有以用户实际的使用场景为目标,这样即使交付也没啥用处。
因此开始硬啃,为了突破这个技术难点,我的工作机被刷成 ubuntu 系统,好不容易产出了 windows 镜像后,又发现镜像运行很艰难,我的小破机根本带不动。
终于在领导的关照下,搞个高配的开发机器,运行流畅。windows 系统分辨率超出,操作界面显示不全的问题
原先的 selenoid 系统,是通过传给 qemu 分辨率参数来设置系统环境变量的。但 windows 版本并没有这样的能力,老外给的方案是默认启动时就给最大的分辨率,在通过设置浏览器窗口大小来实现指定大小分辨率的测试执行,最起码这样显示内容是全的。但这对于我这种可能需要用户手动去 vnc 操作的方式来说,并不适用,用户一旦不小心点了最大化,那么同步浏览器的比例就完全乱套了。因此结合自己 python 脚本经验,深度挖掘了上述更为灵活的 flask server 方案。windows 系统代理无法通过环境变量设置
原先的 selenoid,我只要在启动容器时给个 env 变量就可以指定代理了,但 windows 版本这样不行。有了上面的分辨率解决经验,我通过 python 的 winproxy 库进行了处理。非主流浏览器借用的别人的内核,缺少浏览器驱动
这里的非主流浏览器其实不光指的是我们的一众换皮国产浏览器,国外的 opera、yandex 等等其实也是换皮 chromium。这些家伙的兼容才是真的坑,我到现在还没填完。
下面就 qqbrowser 举个例子吧:
它的内核是 chrome 94 的,在启动时要传的浏览器名称 “chrome”,而我们系统中本身也可能有 chrome 这个版本。因此要区分开,我是自定义一个版本区间给它,如:chrome 1~11,同样在 yutu 中要做别名区分,启动时传 chrome,记录时要记 qq。{"chrome":{"default":"94","versions":{"94":{"image":"windows/chrome:80","port":"4444","path":"/wd/hub"},"11":{"image":"windows/qq:11","port":"4444","path":"/wd/hub"}}}}var browserNameToDriver = { 'yandex': 'chrome', 'qq': 'chrome', 'chrome': 'chrome', 'firefox': 'firefox', 'MicrosoftEdge': 'MicrosoftEdge', 'opera': 'opera', 'safari': 'safari', 'internet explorer': 'internet explorer', } capabilities['browserName'] = browserNameToDriver[options.browserName]
结语
项目目标算是达成了,但还不够完美,我会持续的优化。
通过此次的开发经历,也使我感触良多,技术类需求的不确定性,是软件行业的特性。以后对接公司工作中技术需求,我也要引以为戒,做好风险管理。
同时看到一个个曾经的明星项目的沉寂,也是让我百感交集,他们本该能够成长的更好,但或是公司环境的变化,或是创作者乏力无奈,总之慢慢淡出人们的记忆,甚至连创作团队自己都忘记,而我们就在这不断创造与消亡中轮回。
最后,借用尼采的警言与各位共勉:所有美好的事物都是曲折地接近自己的目标,一切笔直都是骗人的,所有真理都是弯曲的,时间本身就是一个圆圈。


未知地区 30F
学习,收藏
未知地区 29F
先收藏吧
未知地区 28F
厉害,收藏了
未知地区 27F
赞,先收藏啦
未知地区 26F
能,不过不完全能,要看被阉割的程度。
未知地区 25F
有没办法驱动国产浏览器?
未知地区 24F
赞,很不错
未知地区 23F
这个真不错,作者有句话说的真对 非主流浏览器做兼容的时候真的是让人欲仙欲死,之前用 selenium 做自动化兼容测试的时候 就碰到过类似的问题
未知地区 22F
精华帖,先马住了
未知地区 21F
赞~