DevTools Protocolを使ってChromeを操作＆CSSカバレッジを取得する

※本記事は [Classi Advent Calendar 2020](https://qiita.com/advent-calendar/2020/classi) の3日目の記事です

こんにちは。Classi プロダクト開発部 エンジニアの中村です。

直近の業務で社内ツール（Webアプリケーション）のE2Eテスト＆CI整備を行いました。そこでDevTools Protocolを使ったブラウザオートメーションについて調べたところ、今後のCIに活用できそうなネタが見つかったのでその紹介をしたいと思います。

## ご存知ですか？ DevTools Protocol

ブラウザオートメーションと言えば [Selenium](https://www.selenium.dev/) の名前を思い浮かべる方は多いと思います。 Selenium 2 からの実装である [WebDriver](https://ja.wikipedia.org/wiki/Selenium_(%E3%82%BD%E3%83%95%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A2)#Selenium_WebDriver) は [W3C で標準化が進められおり](https://www.w3.org/TR/webdriver/)、ブラウザオートメーションの標準規格として存在しているものだと（少なくとも私は）捉えていました。

しかし近年リリースされた [Puppeteer](https://github.com/puppeteer/puppeteer) や [Cypress](https://www.cypress.io/) などをみるに 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](https://github.com/cyrus-and/chrome-remote-interface)

READMEに書いてある通りのサンプルコードを実行してみましょう。

```:chrome-remote-interfaceのインストール
$ npm install --save chrome-remote-interface
```

```js:READMEにあるサンプルコードそのままコピー
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();
```

```:(MacOS)--remote-debugging-port=9222を指定して起動
$ /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()` で両ドメインを有効化する必要があります。

```js:有効化とCSSカバレッジの開始
await DOM.enable();
await CSS.enable();
await CSS.startRuleUsageTracking();
```

CSSカバレッジ情報にはCSSの情報（URLなど）はなく、 `styleSheetId` で引っ張ってあげる必要があります。CSSの情報は `CSS.styleSheetAdded` イベントで取得できるので保持しておきましょう。

```js:読み込んだCSSのヘッダーを保持しておく。
let cssHeaders = {};
CSS.styleSheetAdded(({header}) => cssHeaders[header.styleSheetId] = header);
```

ページの読み込みと、読み込み完了後にCSSカバレッジデータを処理しましょう。

```js:ページ読み込みと読み込み完了での処理
await Page.enable();
await Page.navigate({url: 'https://classi.jp'});
await Page.loadEventFired(async () => {
  // ここでCSSカバレッジデータを処理する
});
```

CSSカバレッジデータを取得するのは `CSS.stopRuleUsageTracking()` です。

```js:CSSカバレッジデータ取得
let {ruleUsage} = await CSS.stopRuleUsageTracking();
```

レスポンスの `ruleUsage` プロパティは `ruleUsage` 型のデータが配列で格納されています。単一の `ruleUsage` は以下のようなデータになっています。

```json:単一のruleUsage
{
  styleSheetId: '8169.0',
  startOffset: 119910,
  endOffset: 120037,
  used: true
}
```

ここでCSSの情報を使いつつ ruleUsage をURL毎に振り分けます。HTMLに含まれるインラインCSSも含まれるため、今回はそれを含まないようにしています。

```js:ruleUsageをURL毎に集約
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()` で取得できます。

```js:CSSテキストの取得
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で装飾、可視化してみましょう。

```js:CSSテキストを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);
```


## 実装例と結果

```js:上記全てを含んだ実装例
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 以下のスタイルが未使用で出てきています。狙い通りに未使用スタイルルールが検知できていると言えそうです。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38598/52e3a704-0837-330b-fe59-38903e1fc47b.png" width=400>

## まとめ

CDP を利用することで DevTools で計測・取得できる様々な情報が利用できそうです。CIに組み込むならば、

- JS/CSSカバレッジ
- JavaScriptコンソールエラーの有無チェック
- サブリソースリクエストの監視（意図しない 本番/ステージング/開発 環境へのリクエストが発生していないか、など）
- etc...

といったものが実現できそうです。

ただ今回は素朴に CSS カバレッジを出力してみましたが、実際の Web アプリケーションであればトランスパイルや minify された JS/CSS リソースを扱っており、 [SourceMap](https://developer.mozilla.org/ja/docs/Tools/Debugger/How_to/Use_a_source_map) によるオリジナルソースへの解決が必要そうです。それを含めた JS/CSS カバレッジを CI へ組み込む方法を現在模索していました。そこらへんの情報がまとまったらまたレポートしてみたいと思います。

以上、Classi Advent Calendar 2020 の3日目の記事でした。
明日の担当は @hhhhhhiroko_ さんです。