Macaca Electron 驱动开发实录
发布于 9 年前 作者 meowtec 7067 次浏览 最后一次编辑是 8 年前 来自 分享

新人第一次在 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 nodeelectron模块,但是与主进程的electron模块有一些区别,在此不详细阐述。

通常可以使用两种模式在 Renderer 和 Main 之间进行通信:

  1. executeJavaScript注入 JS

使用win.webContents.executeJavaScript(code, callback)方法可以在页面运行环境执行一段 JS 表达式,表达式的返回值通过 callback 回传给主进程。这种情况只可用于 Main 进程单向调 Renderer 进程。

  1. 使用 Electron 的 ipc 模块进行通信

Electron 使用 ipcMainipcRenderer 子模块提供了 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替换成ChromeDriverAndroidWebkitDriverXyzDriver之后, 一样可以运行起来。对于使用者来说,完全不需要了解 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 中页面的标题,我们可以这样做:

  1. Node Process 向 Electron Main Process 发送消息,通知它执行getTitle动作.
  2. Electron Main Process 收到动作,往 BrowserWindow 注入 JS 片段document.title,并拿到返回值。
  3. 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 截图:

6 回复

赞,很详细!

可以自动水军刷屏

其实这是一个爬虫技术 自豪地采用 CNodeJS ionic

@tsj1107 那我还得写自动注册 cnodejs,所以还要写个自动注册 Github 的功能,还要写自动注册邮箱…

回到顶部