新人第一次在 cnode 发帖,请多关照。
前言
什么叫驱动?驱动就是一种将应用程序和硬件设备进行连接的特殊程序。不同的硬件有不同的接口和操作方式,驱动程序则屏蔽了这些差异,对上层应用提供了统一的硬件无关的接口。
Macaca 是一套跨平台的自动化方案,在 Macaca 中,你可以选择业务需要的平台:iOS/Android/Chrome 等等进行自动化操作。不同平台提供的操作接口原本不相同,而 Macaca 将它们进行了封装,提供了一套统一的 API,让用户编写的同一份测试用例能够运行于不同的容器之中。 我们把底层的工具(iOS Safari, Android Chrome 等)比作硬件,对它们的封装就是驱动。
顾名思义,macaca-electron 是基于 Electron 开发的 Macaca 驱动,是 Macaca 驱动之一。本文将从零介绍如何对 Electron 进行封装,实现一个简易版的驱动程序。
Electron 简介
本章节简单介绍了 Electron 的使用,如果你有使用经验,可以直接跳过。
Electron 是 Github 开发出来的使用 Node.js + HTML 开发跨平台桌面应用的框架,知名的产品有 Github 的 Atom 和 Microsoft 的 VS Code 等。
npm install electron-prebuilt
Hello World
Electron 应用程序的入口为一个.js
文件,我们需要使用 Electron 的 bin 文件进行启动:
node_modules/.bin/electron app.js
app.js
const electron = require('electron')
const BrowserWindow = electron.BrowserWindow
const app = electron.app
app.on('ready', () => {
const win = new BrowserWindow({
width: 1200,
height: 800,
})
win.loadURL('https://www.baidu.com/')
})
执行electron app.js
命令之后,我们启动了一个 Electron 主进程,并在这个进程中执行 app.js。
app.js 运行于 Electron 主进程,在 Electron 主进程中,可以使用require()
加载 NodeJS 模块,就好像是使用 node app.js
运行 app.js 一样。
和普通 node 环境不同的是,我们还可以require()
一个特殊的模块:electron
。
通过electron
模块,我们可以对 electron 进行一系列操作。
在前面的实例代码中,我们 new 了一个BrowserWindow
类,打开了一个浏览器窗口,然后使用loadURL(url)
在窗口中加载一个网页。
主进程和渲染进程的通信
网页运行于独立的进程(Renderder)之中,Renderer 的 JS 执行环境可以直接 require node
和electron
模块,但是与主进程的electron
模块有一些区别,在此不详细阐述。
通常可以使用两种模式在 Renderer 和 Main 之间进行通信:
executeJavaScript
注入 JS
使用win.webContents.executeJavaScript(code, callback)
方法可以在页面运行环境执行一段 JS 表达式,表达式的返回值通过 callback 回传给主进程。这种情况只可用于 Main 进程单向调 Renderer 进程。
- 使用 Electron 的 ipc 模块进行通信
Electron 使用 ipcMain
和 ipcRenderer
子模块提供了 Main 和 Renderer 之间的双向进程通信,篇幅有限就不给出实例代码。
封装驱动
在前面一章,我们已经知道了如何在 Electron 主进程中使用BrowserWindow
打开窗口并加载一个页面。BrowserWindow
还有很多其他操作窗体的方法和事件,比如设置窗口大小和位置、关闭窗口、截图、关闭窗口等等,可以在 electron.atom.io/docs 中找到详细的使用方法。
如果要操作页面内部的东西,比如 DOM 元素,则可以通过executeJavaScript
实现。
在前面的例子里,我们的 Electron 进程是通过执行electron
bin 文件启动的。如果我们只是自己临时玩玩,复制前面的 app.js 文件,改改逻辑,然后electron app.js
一下,完全是可行的。但是假如我们要和其他流程或者平台进行整合,这样搞就不太现实了。我们需要的是类似下面这种能运行于普通 Node 环境的代码:
async function test() {
// 实例代码, API 随意取的
const driver = new ElectronDriver()
await driver.init()
await driver.get('https://github.com')
let title = await driver.getTitle()
assert.equal(title, 'GitHub')
await driver.close()
}
另外,封装的另一好处就是:假如我们的 API 遵循了统一的规范,前面同一份代码我们把ElectronDriver
替换成ChromeDriver
、AndroidWebkitDriver
、XyzDriver
之后, 一样可以运行起来。对于使用者来说,完全不需要了解 Electron 的 API 细节,一次学习,到处运行。
包装 Electron 进程
在普通 Node 进程中需要通过child_process
模块对 Electron 进行包装。
// node app.js
const spawn = require('child_process').spawn
// 获取 electron-prebuilt bin 文件路径
const electronBin = require('electron-prebuilt')
const child = spawn(electronBin, [
path.join(__dirname, 'runner.js')
])
// electron runner.js
const electron = require('electron')
const BrowserWindow = electron.BrowserWindow
const app = electron.app
app.on('ready', () => {
const mainWindow = new BrowserWindow({
// BrowserWindow options
})
mainWindow.loadURL('https://github.com'))
})
我们在 Node 进程中,使用child_process.spawn
创建了一个 Electron 主进程,然后在 Electron 主进程中打开一个 BrowserWindow.
假如我们要在 Node Process 中拿到 BrowserWindow 中页面的标题,我们可以这样做:
- Node Process 向 Electron Main Process 发送消息,通知它执行
getTitle
动作. - Electron Main Process 收到动作,往 BrowserWindow 注入 JS 片段
document.title
,并拿到返回值。 - Electron Main Process 把 2 的返回值返回给 Node Process.
用前面提到的executeJavaScript
即可完成步骤 2,现在我们还需要实现 Node Process 和 electron process 之间的通信。
Node Process 进程通信
通过 Node.js 官方文档,可以找到两个父子关系的 Node 进程之间使用内置的 IPC 信道进行通信的方法。
在使用 spawn 创建子进程时,在stdio
配置项数组中添加'ipc'
,就可以在父子进程中使用send
方法和message
事件进行通信。
var child = spawn('node', [
'child.js'
], {
stdio: [process.stdin, process.stdout, process.stderr, 'ipc']
})
child.send({ foo: 123 })
child.on('message', (data) => {})
// child.js
process.on('message', (data) => {})
process.send({ bar: 456 })
stdio 设置中除了 ipc,我们还把子进程的
stdout
,stderr
分别 pipe 到父进程上,这样我们在子进程中console.log
出来的 debug 信息就可以通过父进程直接输出到屏幕上了。
使用这种方式,我们也可以实现 Node 进程和 Electron 主进程之间的通信,全身经脉就打通了:
要实现这个 driver,我们至少需要两个 JS 文件,一个负责 Node 端的逻辑,一个负责 Electron 主进程逻辑:
// index.js
import { spawn } from 'child_process'
import electronPath from 'electron-prebuilt'
export default class Electron {
sendIpc(data) {
return new Promise((resolve, reject) => {
this.child.send(data)
this.child.once('message', (data) => {
if (data.errCode) {
reject(new Error(data.errMsg))
}
else {
resolve(data.result)
}
})
})
}
init() {
this.child = spawn(electronPath, ['runner.js'], {
stdio: ['inherit', 'inherit', 'inherit', 'ipc']
})
return this.sendIpc({ action: 'init' })
}
get(url) {
return this.sendIpc({
action: 'get',
payload: url
})
}
getTitle() {
return this.sendIpc({ action: 'getTitle' })
}
}
// runner.js
import { app, BrowserWindow } from 'electron'
let win
process.on('message', (data) => {
if (data.action === 'init') {
app.on('ready', () => {
win = new BrowserWindow({})
process.send({ errCode: null })
})
}
if (data.action === 'get') {
win.webContents.loadURL(data.payload)
win.webContents.on('did-finish-load', () => {
process.send({ errCode: null })
})
}
if (data.action === 'getTitle') {
win.webContents.executeJavaScript('document.title', (result) => {
process.send({
errCode: null,
result
})
})
}
})
这个例子仅仅是一个示例,用来说明这个 driver 是如何工作的,它简化了很多东西。
比如,在上面的代码里面,document.title
是写死的,它肯定是一段正确的代码,运行多少遍都不会有问题。而一个完整的驱动需要向用户暴露execute()
方法,用来执行用户传入的 JS 字符串,用户传入的 JS 代码就不一定正确了。
而 executeJavaScript 有一个特性,如果它传入的代码执行出错,它的回调函数将永远不会执行。所以,还需要对传入的代码进行类似下面这样的包装:
win.webContents.executeJavaScript(
`
(function() {
try {
return {
error: null,
result: eval('document.title')
}
} catch(e) {
return {
error: e
}
}
})()
`
)
使用驱动进行自动化测试
完成驱动之后,我们就可以使用驱动来进行各种自动化操作。例如,你可以结合 mocha 编写测试脚本进行自动化测试用例,由于篇幅限制代码省略。
你也可以直接安装并使用 Macaca,它会让你的测试更方便。
在缺少桌面环境的操作系统中,可以使用 Xvfb 提供虚拟的显示环境,Electron 的窗体才可以正常运行。也就是说,如果你希望你的 Electron 驱动运行于 travis-ci 等服务上,需要配置 Xvfb 服务。在 Electron 文档中有相关说明: testing-on-headless-ci.md
我用 Macaca 写了一个自动登录微博并发送一条微博的测试用例:macaca-electron-test-sample-weibo.
git clone
此 repo,在你的本地试一试吧。
下面是测试用例运行的 GIF 截图:
赞,很详细!
可以自动水军刷屏
大写的赞
其实这是一个爬虫技术 自豪地采用 CNodeJS ionic
@tsj1107 那我还得写自动注册 cnodejs,所以还要写个自动注册 Github 的功能,还要写自动注册邮箱…
有意思。。