JavaScript
Node.js
Chrome
React
puppeteer

PuppeteerでヘッドレスChromeを操ってみる

 Puppeteerのnode apiを使ったサンプルプログラムを作り、ヘッドレスChromeを操作してみましたので報告します。

 先日、Google Chromeにヘッドレス機能が追加されました。GUI無しにコマンドラインからChromeを操作できるようになったわけです。
https://developers.google.com/web/updates/2017/04/headless-chrome?hl=ja

 ちょっと前まではnodeからこのヘッドレスChromeへアクセスするにはchrome-remote-interfaceなどが必要でしたが、最近Puppeteerというものが現れてもっと簡単にヘッドレスChromeを操作できるようになりました。
https://github.com/GoogleChrome/puppeteer

公式サイトにあるPuppeteerの使い方

  • スクリーンショットやPDFの生成
  • SPAのクロールとpre-renderedコンテンツの生成(SSR)
  • Webサイトスクレイプ
  • フォームテストの自動化
  • 最新Chromeを使っての最新のJavaScriptやブラウザ機能のテスト
  • timeline traceをキャプチャーしてパフォーマンス診断を助ける

 開発はChrome DevTools のチームが行っているということなので、かなり信頼できます。ちょっと使ってみましたが、結構良さそうなのでそのレポートです。まだネット上の使用例も少なく、Puppeteer API v0.10.2-alphaを見ながらの試行錯誤の結果ですので、間違っているところがあったらごめんなさい。日々修正が加えられているようですのでご考慮下さい。
https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md

 以前からCasperJSなどを使って自動的にWeb画面のスクリーンショットを撮ることは行われてきましたが、ReactなどのJavaScriptを使ったWebアプリに関しては問題がありました。空白画面だけがキャプチャーされたりして。今回のPuppeteerについては、最新のChromeを使っているのでその辺の問題はないようです。今回はそれを実証すべく、自分で以前に作成した以下のReactのサイトに対してテストを行いました。結果は満足できるものでした。
http://www.mypress.jp:3021

ページの解説は過去のページを参照してください
Falcor+Reactフルスタック(Material-UI)

 まずはOSをインストールします。Ubuntu 16.04LTS Desktopを使います。Serverバージョンだとフォント等の問題でスクリーンショットが文字化けします。

 次にnodeをインストールします。

curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.2/install.sh | bash
source ~/.nvm/nvm.sh       //ubuntuだけ?
nvm install v8.4.0  --v8.4.0のnodeをインストール
node -v
npm -v

 次にpuppeteerのインストールです。これでChromiumも自動的にインストールされるので大変助かります。

mkdir puppeteer
cd  puppeteer
npm i puppeteer

 本来ならこれで十分なはずですけれど、サンプルプログラムを起動しようとするとエラーになります。調べたら以下のパッケージをインストールするとエラーを回避できることがわかりました。
https://github.com/GoogleChrome/puppeteer/issues/290

sudo apt-get install 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

 以上で準備ができましたので、サンプルプログラムを作成して走らせます。サンプルプログラムのシナリオは以下の通りです。

  1. まずhomeページを開きます(http://www.mypress.jp:3021)
  2. メニューのログインをクリックしてログイン画面に行きます
  3. ユーザ名とパスワードを入力してsubmitボタンを押します。これで管理画面に入ります。
  4. ログアウトをクリックします
  5. 登録をクリックします。
  6. 再度、homeへ戻り終了です。

以下はプログラムの解説です

  • 各ページでpage.screenshot()でpngファイルを作り、page.plainText()でページのテキストを端末に表示しています。画像とテキストの両方でページ遷移を確認できます。
  • プログラムの全体で同期をとるためasync/awaitが使われています。
  • Reactアプリで、非同期に取得したコンテンツを表示するのに遅延が生じる場合があるので、ところどころに以下のような考慮が必要になります。
page.goto('http://www.mypress.jp:3021', {waitUntil: 'networkidle',networkIdleTimeout:5000});
await page.waitFor(5000);

サンプルを走らせるには以下のコマンドです

node script.js

最後にサンプルの全ソースを掲載します。

script.js
const fs = require('fs');
const assert = require('assert');
const puppeteer = require('puppeteer');

(async() => {


//  const browser = await puppeteer.launch({args: ['--no-sandbox', '--disable-setuid-sandbox']});
  const browser = await puppeteer.launch();
  const page = await browser.newPage();

  /*** 1 home ***/
  /* 非同期に取得するコンテンツの表示のためには、networkidleのtimeoutを十分大きくとる */
  await page.goto('http://www.mypress.jp:3021', {waitUntil: 'networkidle',networkIdleTimeout:5000}); //default 1000
  /* 画面をフルサイズで撮る */
  await page.screenshot({path: 'home1.png', fullPage: true});
  await page.plainText().then( text => console.log("############# TOP text="+text) );
  //await page.content().then( content => console.log("************* TOP content="+content) );

  /*** 2 login ***/
  const loginElement = await page.$('a[href="/login"]');
  await loginElement.click();
  await page.screenshot({path: 'login.png', fullPage: true});
  await page.plainText().then( text => console.log("############# LOGIN text="+text) );
  //await page.content().then( content => console.log("************* LOGIN content="+content) );


  /*** 3 login input-then-submit ***/
  await page.focus('input[name="username"]');
  await page.type('xxxxx'); //xxxxxはユーザ名です
  await page.focus('input[name="password"]');
  await page.type('yyyyy');  //yyyyyはパスワードです
  const buttonElement = await page.$('button[type=submit]');
  await buttonElement.click();
  await page.waitFor(5000);
  await page.screenshot({path: 'logined.png', fullPage: true});
  await page.plainText().then( text => console.log("############# LOGINED text="+text) );

  /*** 4 logout ***/
  const logoutElement = await page.$('a[href="/logout"]');
  await logoutElement.click();
  await page.screenshot({path: 'logout.png', fullPage: true});
  await page.plainText().then( text => console.log("############# LOGOUT text="+text) );


  /*** 5 register ***/
  const registerElement = await page.$('a[href="/register"]');
  await registerElement.click();
  await page.screenshot({path: 'register.png', fullPage: true});
  await page.plainText().then( text => console.log("############# REGISTER text="+text) );
  //await page.content().then( content => console.log("************* REGISTER content="+content) );

  /*** 6 home again ***/
  const homeElement = await page.$('button[type=button]');
  await homeElement.click();
  await page.waitFor(5000);
  await page.screenshot({path: 'home2.png', fullPage: true});
  await page.plainText().then( text => console.log("############# HOME2 text="+text) );

  browser.close();

  assert(fs.existsSync('home1.png'));
  assert(fs.existsSync('login.png'));
  assert(fs.existsSync('logined.png'));
  assert(fs.existsSync('logout.png'));
  assert(fs.existsSync('register.png'));
  assert(fs.existsSync('home2.png'));

  console.log('### Helooooooo');
})();

 npm一発で環境が作れて、最新のChromeでReactアプリを自動操作できるのは、結構爽快です。キャプチャーした各画面のpng画像も実際にブラウザを立ち上げて操作した画面と同じでしたし、コンソール出力のテキストも同じものでした。他のツールは環境作りが大変で、ようやく作ってもJavaScriptアプリはうまく扱えなかったりするのですが、その辺はだいぶ解消されている印象を持ちました。今後のWebアプリのE2Eテストの本命ですかね。