Headless Chromeを使用しVue.jsで出力したHTMLをv.Nuでチェックする

ミツエーリンクスさんの「フロントエンドBlog」に掲載された『Headless Chromeでのスクリーンショット取得とGoogle Chrome 60の搭載予定機能』を拝見して、僕もHeadless Chromeを試してみたくなりました。

スクリーンショットの取得以外に何かできないか考えてみたのですが、「JavaScriptが実行され、現在レンダリングされているHTMLをThe Nu Html Checker (v.Nu)でチェックできないか?」というお題にしました。というのも、最近Vue.jsを扱っていて「HTMLの出力に間違いはないだろうか?」と感じていたためです。Vueで処理して出力する部分はHTMLファイル上では<div id="app"></div>しか書かないため、普通にHTMLチェッカーを通してもチェックすることはできません。

プログラムの検討

フロントエンドBlogの記事で紹介されていた『ヘッドレス Chrome ことはじめ 』を参考に開発を進めました。JavaScriptの動作が完了した後のHTMLを取得したいので、記事の後半にある「ページの情報を取得する」のサンプルコードを使うと目的が達成できそうです。

問題はonPageLoad関数でどのような処理をするかですが、「Chrome DevTools Protocol Viewer - DOM」を見ていくと、DOM.getDocumentDOM.getOuterHTMLを利用することでVue.jsのアプリケーションが動作した後のHTMLが取得できました。
取得したHTMLをターミナルに出力した様子

HTMLが取得できたのでコマンドを実行してv.NuにHTMLを渡し、チェック結果を出力させます。v.NuはGitHubのリポジトリからダウンロードしていますので、echo '<!doctype html><title>...' | java -jar ~/vnu.jar -のように標準入力でHTMLを渡すことにしました。

ただ、window.onloadの時に必ずしもJavaScriptの実行が完了しているとは言えないことに今気付きました。他の言語にあるsleep()で適当に待つしかないのかな、などと考えています。(ちなみにsleep()の実装が「JavaScript(Node.js) で sleep() アラカルト」で実験されています。)

実行結果

実験には「vue-webpack-boilerplate」で最初に表示される画面を使用しました。

動作させたところ、HTMLに改行がないことからどこにエラーがあるのか分かりづらいことに気付きました。そこで「pretty」でHTMLを整形すると共に、v.Nuのチェック結果もJSONで出力されるようにして「prettyjson」で整形して表示するように工夫してみました。

これでひとまずどの辺りにエラーがあるのか把握できるようになりました。今のところエラーがあると次のようにエラー情報が表示されます。
v.Nuのチェック結果をターミナルに出力した様子

まとめ

JavaScriptの実行が完了するまで待つ処理が必要なこと、またチェック結果の出力に課題があるものの、Vue等で記述したアプリケーションのHTMLのチェックができることが分かりました。さらに作り込むと画面上の要素をクリックしてDOMが書き換えられた後にチェックをかけることもできるのかなと考えています。この実験がアプリケーションの品質向上につながればと思います。

コードと必要なモジュール

モジュール

以下のモジュールをnpmもしくはyarnでインストールしてください。

  • chrome-launcher
  • chrome-remote-interface
  • pretty
  • prettyjson

コード

const chromeLauncher = require('chrome-launcher');
const chrome = require('chrome-remote-interface');
const exec = require('child_process').exec;
const prettyhtml = require('pretty');
const prettyjson = require('prettyjson');

const url = 'http://localhost:8080/';
const vnuPath = '~/cli_tools/vnu/vnu.jar';

function onPageLoad(DOM) {
  return DOM.getDocument().then(node => {
    return DOM.getOuterHTML({nodeId: node.root.nodeId}).then(res => {
      exec('echo \'' + prettyhtml(res.outerHTML) + '\' | java -jar ' + vnuPath + ' --format "json" -', (err, stdout, stderr) => {
        if (stdout) {
          console.log('[stdout]\n' + stdout);
        }
        if (stderr) {
          const data = JSON.parse(stderr);
          console.log(prettyhtml(res.outerHTML) + '\n');
          console.log('[stderr]\n' + prettyjson.render(data));
        }
        if (!stderr && err !== null) {
          console.log('[Exec error]\n' + err);
        }
      })
    });
  });
}

chromeLauncher.launch({
  port: 9222,
  chromeFlags: ['--headless', '--disable-gpu']
}).then(launcher => {
  console.log(`Chrome debugging port running on ${launcher.port}`);
  chrome(protocol => {
    // DevTools プロトコルから、必要なタスク部分を抽出する。
    // API ドキュメンテーション: https://chromedevtools.github.io/devtools-protocol/
    const {Page, DOM} = protocol;

    // まず、使用する Page ドメインを有効にする。
     Page.enable().then(() => {
      Page.navigate({url: url});

      // window.onload を待つ。
      Page.loadEventFired(() => {
        onPageLoad(DOM).then(() => {
          protocol.close();
          launcher.kill(); // Chrome を終了させる。
        });
      });
    });

  }).on('error', err => {
    throw Error('Cannot connect to Chrome:' + err);
  });
});