※本記事は Classi Advent Calendar 2020 の3日目の記事です
こんにちは。Classi プロダクト開発部 エンジニアの中村です。
直近の業務で社内ツール(Webアプリケーション)のE2Eテスト&CI整備を行いました。そこでDevTools Protocolを使ったブラウザオートメーションについて調べたところ、今後のCIに活用できそうなネタが見つかったのでその紹介をしたいと思います。
ご存知ですか? DevTools Protocol
ブラウザオートメーションと言えば Selenium の名前を思い浮かべる方は多いと思います。 Selenium 2 からの実装である WebDriver は W3C で標準化が進められおり、ブラウザオートメーションの標準規格として存在しているものだと(少なくとも私は)捉えていました。
しかし近年リリースされた Puppeteer や Cypress などをみるに WebDriver は利用されておらず、この DevTools Protocol が利用されています。 WebDriver と比較した特徴として以下が挙げられます。
- 動作が高速
- 安定
- 多機能
Chrome DevTools Protocol の API は以下ドキュメントが公開されており、
https://chromedevtools.github.io/devtools-protocol/
WebDriver では定義されていなかった DevTools の各種機能(JavaScrioptデバッガ、プロファイラ、パフォーマンスツール、etc...)が利用可能なことが読み取れます。
Chrome(&Chromium)だけしか使えないのでは?と思うところですが、WICGのリポジトリにリソースが集められており、各ブラウザでの実装状況もここから確認できます。
https://github.com/WICG/devtools-protocol
Firefox のものは Remote Debugging Protocol (RDP) と呼ぶらしいですね(紛らわしい...)
https://firefox-source-docs.mozilla.org/remote/index.html
つまり DevTools Protocol は WebDriver にとって代わる新しいブラウザオートメーションの仕様/実装ではないのかと言えそうです。
用語使い分け
「DevTools Protocol」と書いた場合は上記 WICG で議論されているような特定ブラウザに寄らないものを意図しています。「Chrome DevTools Protocol(以降、CDP)」と書いた場合は Chrome/Chromium における DevTools Protocol を意図しています。
CDP を Node.js から利用する
CDP のクライアントライブラリが公開されています。
cyrus-and/chrome-remote-interface
READMEに書いてある通りのサンプルコードを実行してみましょう。
$ npm install --save chrome-remote-interface
const CDP = require('chrome-remote-interface');
async function example() {
let client;
try {
// connect to endpoint
client = await CDP();
// extract domains
const {Network, Page} = client;
// setup handlers
Network.requestWillBeSent((params) => {
console.log(params.request.url);
});
// enable events then start!
await Network.enable();
await Page.enable();
await Page.navigate({url: 'https://github.com'});
await Page.loadEventFired();
} catch (err) {
console.error(err);
} finally {
if (client) {
await client.close();
}
}
}
example();
$ /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222
$ node example.js
https://github.com/
https://github.githubassets.com/assets/frameworks-dab626668288a951c68b47d6ec626b3f.css
https://github.githubassets.com/assets/site-1408bbc6a6d041fa692a04fe7551fbe9.css
https://github.githubassets.com/assets/github-1c120b6605b938c19e74dd12de6322d3.css
https://github.githubassets.com/assets/environment-f0adafbf.js
:
(リクエストログが出力される)
:
コマンドライン経由で開いた Chrome が GitHub TOP ページを表示することも確認できるでしょう。割と簡単に利用することができますね。
DevTools の CSS カバレッジ機能を CDP 経由で利用する
Chrome 59 で DevTools に CSS のカバレッジ取得が追加されています。
Find Unused JavaScript And CSS Code With The Coverage Tab In Chrome DevTools
https://developers.google.com/web/tools/chrome-devtools/coverage
また Chrome 73 でカバレッジ情報を Export することも可能になっています。
What's New In DevTools (Chrome 73) | Web | Google Developers
https://developers.google.com/web/updates/2019/01/devtools#coverage
これらを CDP 経由で利用し、CSS カバレッジの結果を可視化してみましょう。
CSS カバレッジを開始するには CSS.startRuleUsageTracking()
を呼び出します。その前に DOM.enable()
と CSS.enable()
で両ドメインを有効化する必要があります。
await DOM.enable();
await CSS.enable();
await CSS.startRuleUsageTracking();
CSSカバレッジ情報にはCSSの情報(URLなど)はなく、 styleSheetId
で引っ張ってあげる必要があります。CSSの情報は CSS.styleSheetAdded
イベントで取得できるので保持しておきましょう。
let cssHeaders = {};
CSS.styleSheetAdded(({header}) => cssHeaders[header.styleSheetId] = header);
ページの読み込みと、読み込み完了後にCSSカバレッジデータを処理しましょう。
await Page.enable();
await Page.navigate({url: 'https://classi.jp'});
await Page.loadEventFired(async () => {
// ここでCSSカバレッジデータを処理する
});
CSSカバレッジデータを取得するのは CSS.stopRuleUsageTracking()
です。
let {ruleUsage} = await CSS.stopRuleUsageTracking();
レスポンスの ruleUsage
プロパティは ruleUsage
型のデータが配列で格納されています。単一の ruleUsage
は以下のようなデータになっています。
{
styleSheetId: '8169.0',
startOffset: 119910,
endOffset: 120037,
used: true
}
ここでCSSの情報を使いつつ ruleUsage をURL毎に振り分けます。HTMLに含まれるインラインCSSも含まれるため、今回はそれを含まないようにしています。
let usages = ruleUsage.reduce((usages, ruleUsage) => {
let header = cssHeaders[ruleUsage.styleSheetId];
if (header) {
let {sourceURL, disabled, isInline} = header;
if (sourceURL && !disabled && !isInline) {
if (usages[sourceURL]) {
usages[sourceURL].usages.push(ruleUsage);
} else {
usages[sourceURL] = { header, usages: [ruleUsage] };
}
}
}
return usages;
}, {});
今回はサンプル実装なので、 top.css
というファイルのみをターゲットに処理します。CSSテキストは CSS.getStyleSheetText()
で取得できます。
let targetUrl = Object.keys(usages).filter(url => url.match(/top.css/))[0];
let target = usages[targetUrl];
let cssText = (await CSS.getStyleSheetText({ styleSheetId: target.header.styleSheetId })).text;
取得したCSSテキストを ruleUsage を使ってHTMLで装飾、可視化してみましょう。
let [buf, index] = target.usages.sort((a, b) => {
return a.startOffset - b.startOffset;
}).reduce(([buf, index], {startOffset, endOffset, used}) => {
buf.push(cssText.substring(index, startOffset));
buf.push(`<span style="color:${ used ? 'green' : 'red' };">`);
buf.push(cssText.substring(startOffset, endOffset));
buf.push(`</span>`);
index = endOffset;
return [buf, index];
}, [[], 0]);
buf.push(cssText.substring(index, cssText.length));
let html = `<pre style="color:red;">${buf.join('')}</pre>`;
require('fs').writeFileSync('usage.html', html);
実装例と結果
const CDP = require('chrome-remote-interface');
(async () => {
let client;
let cssHeaders = {};
try {
client = await CDP();
const {DOM, CSS,Page} = client;
await DOM.enable();
await CSS.enable();
CSS.styleSheetAdded(({header}) => cssHeaders[header.styleSheetId] = header);
await CSS.startRuleUsageTracking();
await Page.enable();
await Page.navigate({url: 'https://classi.jp'});
await Page.loadEventFired(async () => {
let {ruleUsage} = await CSS.stopRuleUsageTracking();
let usages = ruleUsage.reduce((usages, ruleUsage) => {
let header = cssHeaders[ruleUsage.styleSheetId];
if (header) {
let {sourceURL, disabled, isInline} = header;
if (sourceURL && !disabled && !isInline) {
if (usages[sourceURL]) {
usages[sourceURL].usages.push(ruleUsage);
} else {
usages[sourceURL] = { header, usages: [ruleUsage] };
}
}
}
return usages;
}, {});
let targetUrl = Object.keys(usages).filter(url => url.match(/top.css/))[0];
let target = usages[targetUrl];
let cssText = (await CSS.getStyleSheetText({ styleSheetId: target.header.styleSheetId })).text;
let [buf, index] = target.usages.sort((a, b) => {
return a.startOffset - b.startOffset;
}).reduce(([buf, index], {startOffset, endOffset, used}) => {
buf.push(cssText.substring(index, startOffset));
buf.push(`<span style="color:${ used ? 'green' : 'red' };">`);
buf.push(cssText.substring(startOffset, endOffset));
buf.push(`</span>`);
index = endOffset;
return [buf, index];
}, [[], 0]);
buf.push(cssText.substring(index, cssText.length));
let html = `<pre style="color:red;">${buf.join('')}</pre>`;
require('fs').writeFileSync('usage.html', html);
await client.close();
});
} catch (err) {
console.error(err);
if (client) {
await client.close();
}
}
})();
これで生成される HTML を表示してみたところ、ブレイクポイント 768px 以下のスタイルが未使用で出てきています。狙い通りに未使用スタイルルールが検知できていると言えそうです。
まとめ
CDP を利用することで DevTools で計測・取得できる様々な情報が利用できそうです。CIに組み込むならば、
- JS/CSSカバレッジ
- JavaScriptコンソールエラーの有無チェック
- サブリソースリクエストの監視(意図しない 本番/ステージング/開発 環境へのリクエストが発生していないか、など)
- etc...
といったものが実現できそうです。
ただ今回は素朴に CSS カバレッジを出力してみましたが、実際の Web アプリケーションであればトランスパイルや minify された JS/CSS リソースを扱っており、 SourceMap によるオリジナルソースへの解決が必要そうです。それを含めた JS/CSS カバレッジを CI へ組み込む方法を現在模索していました。そこらへんの情報がまとまったらまたレポートしてみたいと思います。
以上、Classi Advent Calendar 2020 の3日目の記事でした。
明日の担当は @hhhhhhiroko_ さんです。