Headless Chrome を操作する Puppeteer で E2E テストを CircleCI で動かしてみた

Chrome DevTools 開発チームによる puppeteer なる Headless Chrome を操作するライブラリがでたので、もろもろ試したことをメモっておく。

試したやつのリポジトリ:cyokodog/puppeteer_study

puppeteer とは

  • Headless Chrome をNode.jsで操作しやすくしたライブラリ
  • Chrome DevTools 開発チームがメンテナンスしてる
  • Node v7.10以降が必要

Headless Chrome のおさらい

puppeteer 位置づけを確認する意味で Headless Chrome をどう動かしてたかをおさらい(via Headless Chrome をさわってみた | CYOKODOG)。

Node.js で Headless Chrome を起動する

  • Headless Chrome を操作するには DevTools プロトコルを有効にして chrome を起動する必要がある
  • --remote-debugging-port フラグつきで実行すると、DevTools Protocol が有効になった状態でインスタンスが起動する
  • child_process で chrome を起動するには以下のとおり
const execFile = require('child_process').execFile;
execFile('/Applications/Google\ chrome.app/Contents/MacOS/Google\ chrome', [ // mac chrome path
    '--headless',
    '--disable-gpu',
    '--remote-debugging-port=9222', // DevTools Protocolが有効になる
    url
  ], (err, stdout, stderr) => {
});
  • Lighthouse の chromeLauncher で起動する方法もある
  • chrome がインストールされてる場所を探してくれる
const {chromeLauncher} = require('lighthouse/lighthouse-cli/chrome-launcher');
const launcher = new chromeLauncher({
  port: 9222, // remote-debugging-port
  additionalFlags: [
    '--headless',
    '--disable-gpu'
  ]
});

Node.js で Headless Chrome を操作するには

インストール

  • puppeteer を npm i するのみ
npm install puppeteer

API をさわってみる

  • 公式ページにコピペで動くサンプルが載ってるので拝借して試す

chrome インスタンスの起動とwebページへの遷移

const puppeteer = require('puppeteer');
puppeteer.launch().then(async browser => {
  const page = await browser.newPage();
  await page.goto('https://www.google.com');
  // other actions...
  await browser.close();
});

class: Puppeteer

iPhone6をエミュレートする

const puppeteer = require('puppeteer');
const devices = require('puppeteer/DeviceDescriptors');
const iPhone = devices['iPhone 6'];
puppeteer.launch().then(async browser => {
  const page = await browser.newPage();
  await page.emulate(iPhone);
  await page.goto('https://www.google.com');
  // other actions...
  await browser.close();
});

page.emulate(options)

スクリーンショットを撮る

const puppeteer = require('puppeteer');
puppeteer.launch().then(async browser => {
  const page = await browser.newPage();
  await page.goto('https://www.google.com');
  await page.screenshot({path: 'screenshot.png'});
  await browser.close();
});

PDFを生成する

const puppeteer = require('puppeteer');
puppeteer.launch().then(async browser => {
  const page = await browser.newPage();
  await page.goto('https://www.google.com', {waitUntil: 'networkidle'});
  await page.pdf({path: 'google.pdf', format: 'A4'});
  await browser.close();
});

page.pdf(options)

ページ内でscriptを評価する

const puppeteer = require('puppeteer');
puppeteer.launch().then(async browser => {
  const page = await browser.newPage();
  await page.goto('https://www.google.com');
  const dimensions = await page.evaluate(() => {
    return {
      width: document.documentElement.clientWidth,
      height: document.documentElement.clientHeight,
      deviceScaleFactor: window.devicePixelRatio
    };
  });
  console.log('Dimensions:', dimensions);
  browser.close();
});

page.evaluate(pageFunction, ...args)

ページ内で実行したconsole.log()をターミナルに出力する

const puppeteer = require('puppeteer');
puppeteer.launch({
  headless: false,
  slowMo: 250 // slow down by 250ms
}).then(async browser => {
  const page = await browser.newPage();
  await page.goto('https://www.google.com');
  page.on('console', (...args) => console.log('PAGE LOG:', ...args));
  await page.evaluate(() => console.log(`url is ${location.href}`));  
  browser.close();
});

event: 'console'

Node.js上の関数をブラウザ内で利用する

  • Node.jsのライブラリ crypto をブラウザ内から利用する
const puppeteer = require('puppeteer');
const crypto = require('crypto');
puppeteer.launch().then(async browser => {
  const page = await browser.newPage();
  page.on('console', console.log);
  await page.exposeFunction('md5', text =>
    crypto.createHash('md5').update(text).digest('hex')
  );
  await page.evaluate(async () => {
    // use window.md5 to compute hashes
    const myString = 'pUPPETEER';
    const myHash = await window.md5(myString);
    console.log(`md5 of ${myString} is ${myHash}`);
  });
  await browser.close();
});
  • /etc/hosts をブラウザ内から読み込む
const puppeteer = require('puppeteer');
const fs = require('fs');
puppeteer.launch().then(async browser => {
  const page = await browser.newPage();
  page.on('console', console.log);
  await page.exposeFunction('readfile', async filePath => {
    return new Promise((resolve, reject) => {
      fs.readFile(filePath, 'utf8', (err, text) => {
        if (err)
          reject(err);
        else
          resolve(text);
      });
    });
  });
  await page.evaluate(async () => {
    // use window.readfile to read contents of a file
    const content = await window.readfile('/etc/hosts');
    console.log(content);
  });
  await browser.close();
});

page.exposeFunction(name, puppeteerFunction)

ググった結果のスクショを撮る

const puppeteer = require('puppeteer');
puppeteer.launch({
  headless: false,
  slowMo: 250 // slow down by 250ms
}).then(async browser => {
  const page = await browser.newPage();
  await page.goto('http://www.google.com', {waitUntil: 'networkidle'});
  await page.type('headless Chrome puppeteer');
  await page.click('input[type="submit"]');
  await page.waitForNavigation();
  await page.screenshot({path: 'search_result.png'});
  browser.close();
});

E2E テストしてみる

適当なTODOアプリを用意

<body>
  <todo></todo>
  <script src="todo.js"></script>
</body>
class Todo {

  constructor () {
    this.list = [
      '買い物に行く',
      '仕事をする'
    ];
    this.rendarView();
  }

  rendarTasks () {
    this.el.tasks.innerHTML = ['<li>', this.list.join('</li><li>'),'</li>'].join('');
  }

  submit () {
    const task = this.el.newTask.value;
    if (task.length) {
      this.list.push(task);
      this.rendarTasks();
      this.el.newTask.value = '';
    }
    event.preventDefault();
  }

  rendarView () {
    document.querySelector('todo').innerHTML = `
      <form onSubmit="todo.submit()" method="post">
        <input class="newTask"/><input type="submit"/>
        <ul class="tasks">
        </ul>
      </form>
    `;
    this.el = {
      newTask: document.querySelector('.newTask'),
      tasks:  document.querySelector('.tasks')
    };
    this.rendarTasks();
  }

}
window.todo = new Todo();

手軽なイメージのある mocha でテストしてみる

const connect = require('connect');
const serveStatic = require('serve-static');
const Mocha = require('mocha');

const runHttpServer = () => {
  const server = connect();
  server.use(serveStatic(__dirname));
  console.log('Server running on 8080');
  return new Promise((resolve, reject) => {
    server.listen(8080, () => {
      return resolve(server)
    });
  });
};

const runTest = () => {
  const mocha = new Mocha();
  mocha.addFile('./demo/todo.spec.js');
  return new Promise((resolve, reject) => {
    mocha.run(failures => {
      resolve(failures);
    });
  });
};

(async () => {
  const server = await runHttpServer();
  const failures = await runTest();
  console.log('failures', failures);
  process.exit();
})();
  • プログラム内で mocha を制御する
  • mocha.addFile() でテストファイルを指定
  • mocha.run() でテストを実行してエラー件数を得られる

テストファイル

const puppeteer = require('puppeteer');
const assert = require("assert");

describe('TODOアプリのテスト', function(){

  // mocha のタイムアウトを設定
  this.timeout(5000);

  const appUrl = 'http://localhost:8080/demo/todo.html';
  let browser, page;

  before(async function(done){

    // CIとlocalでpuppeteerの起動パラメータを切り替える
    const params = process.env.CI ? {
      args: ['--no-sandbox', '--disable-setuid-sandbox']
    } : {
      headless: false,
      slowMo: 250
    };

    browser = await puppeteer.launch(params);
    page = await browser.newPage();
    page.on('console', console.log);
    done();
  });

  describe('画面遷移時', () => {

    before(async function(done){
      await page.goto(appUrl, {waitUntil: 'networkidle'});
      done();
    });

    it('タスクが2つ表示されていること', async () => {
      const tasks = await page.$$('.tasks li');
      assert.equal(tasks.length, 2);
    });
  });

  describe('新規タスク入力後', () => {

    const newTaskValue = '勉強するぞ!';

    before(async function(done){
      await page.focus('.newTask');    
      await page.type(newTaskValue);
      await page.click('input[type=submit]');
      done();
    });

    it('タスクが3つ表示されること', async () => {
      const tasks = await page.$$('li');
      assert.equal(tasks.length, 3);
    });

    it('新規タスク入力フィールドが空になっていること', async () => {
      const val = await page.evaluate(() => 
        document.querySelector('.newTask').value
      );
      assert.equal(val, '');
    });

    it('最終行に表示されたタスクが新規入力したタスクと一致すること', async () => {
      const val = await page.evaluate(() => {
        const list = document.querySelectorAll('.tasks li');
        return list.length ? list[list.length-1].innerText : '';
      });
      assert.equal(val, newTaskValue);
    });

  });

  after(async (done) => {
    browser.close();
    done();
  });

});
  • chromeの起動でモサって mocha がタイムアウトしちゃうので、this.timeout(5000) でタイムアウト時間を変更
  • おそらく puppeteer.launch() が重いので実用時は、テストファイル単位でこれをしない工夫が必要そう
  • CI で puppeteer を起動する場合は {args:['--no-sandbox', '--disable-setuid-sandbox']} の指定が必要
  • ローカルの場合は動作が低速で見れるように {headless: false, slowMo: 250} を指定

こんな感じで動く

CircleCIでもテストしてみる

% ~/p /home/fortes/p/node_modules/puppeteer/.local-chromium/linux-494755/chrome-linux/chrome --help
/home/fortes/p/node_modules/puppeteer/.local-chromium/linux-494755/chrome-linux/chrome: error while loading shared libraries: libX11-xcb.so.1: cannot open shared object file: No such file or directory

.circleci/config.yml

  • なので .circleci/config.yml を次のように設定する
  • chmod で setup_libxcb.sh に実行権限与えてから実行する
# Javascript Node CircleCI 2.0 configuration file
#
# Check https://circleci.com/docs/2.0/language-javascript/ for more details
#
version: 2
jobs:
  build:
    docker:
      # specify the version you desire here
      - image: circleci/node:7.10

    working_directory: ~/repo

    steps:
      - checkout

      # Download and cache dependencies
      - restore_cache:
          keys:
          - v1-dependencies-{{ checksum "package.json" }}
          # fallback to using the latest cache if no exact match is found
          - v1-dependencies-

      - run: chmod +x ./setup_libxcb.sh
      - run: sh ./setup_libxcb.sh
      - run: yarn install

      - save_cache:
          paths:
            - node_modules
          key: v1-dependencies-{{ checksum "package.json" }}

      # run tests!
      - run: yarn test

setup_libxcb.sh

  • setup_libxcb.sh は以下、sudo 付けないとパーミッションエラーになるので注意
#!/bin/bash

sudo apt-get update
sudo apt-get install -yq gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 \
  libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 \
  libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 \
  libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 \
  ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget

めでたし!

参考サイト