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 を操作するには
- DevTools プロトコルと対話する
- リファレンス -> chrome DevTools Protocol Viewer
- 公式サイトでは chrome-remote-interface (DevTools Protocol をベースとしたハイレベルな API を提供) による操作方法を紹介している
- より利用しやすくした chrome-remote-interface の ラッパーライブラリがいろいろある
- puppeteer の場合
- chrome-remote-interface を介さず直接 DevTools プロトコルと対話する
- chromiumもダウンロードされるので、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();
});
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();
});
スクリーンショットを撮る
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();
});
ページ内で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();
});
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でもテストしてみる
- CircleCI 2.0 のように Docker 系の CI の場合以下が必要
- SUID Sandbox の無効化(['--no-sandbox', '--disable-setuid-sandbox'])
- libxcb パッケージのインストール
- これしないと chromium が見つからない的なエラーがでる
% ~/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
めでたし!
参考サイト
- GoogleChrome/puppeteer: Headless Chrome Node API
- Getting started with Puppeteer and Chrome Headless for Web Scraping
- Dockerで日本語対応のHeadless Chrome + puppeteerを立ち上げ | 酒と涙とRubyとRailsと
- Headless Chromeでファイルをダウンロードする - sambaiz-net
- Chrome Headless ブラウザでテストを実行する
- PuppeteerでヘッドレスChromeを操ってみる - Qiita
- Feature request: debugging inside test runners · Issue #699 · GoogleChrome/puppeteer
- --headless時代の本命? Chrome を Node.jsから操作するライブラリ puppeteer について - Qiita
- Quramy/angular-puppeteer-demo: A demonstration repository explains how to using Puppeteer in unit testing
- Quramy/puppeteer-example
- Linux SUID Sandbox Development
- Chrome Headless doesn't launch on Debian · Issue #290 · GoogleChrome/puppeteer
- charlieduong94/mocha-puppeteer: Run your mocha tests in headless chrome using puppeteer
- A Mocha test runner for RequireJS
- Using mocha programmatically · mochajs/mocha Wiki
- javascript - Change default timeout for mocha - Stack Overflow
- Run test from string code · Issue #2638 · mochajs/mocha
- The same test suite won't run twice when using mocha programmatically · Issue #995 · mochajs/mocha
- ScalaプロジェクトをCircleCI 2.0に対応させてみた話 - Qiita
- cordovaアプリをCircleCI上でe2eテストする - Aqutras Members' Blog
- Headless Chrome instances is reported as Safari 0.0.0 · Issue #2603 · karma-runner/karma