先日「Headless Chrome を使ったフロントエンドのテスト」という記事を公開しました。
この記事の中で公開しているテストコードで require されている fs が使われていないことに気づいた方はいますか?
実はこれ、スクリーンショットを保存しながら動作確認をしていたときのコードを消し忘れていたものです。
Headless Chrome は名前のとおり Headless で動作します。
そのため、動作中の画面を確認は、保存したスクリーンショットで行う必要があります。
もちろん開発中は Chrome を通常起動させるという方法もあります。
しかし、今回のように Vagrant などで環境を構築して開発している場合、そのためだけに GUI の環境を用意するのはちょっと大袈裟です。
そこで、今回は Headless Chrome でスクリーンショットを保存する方法を紹介したいと思います。

画面に表示されている内容を保存する

Headless Chrome の起動時に window-size というオプションをつけることで、画面のサイズを指定することができます。
この例でのウィンドウサイズは横 1024 ピクセル、縦 768 ピクセルになります。
chromium-browser --headless --disable-gpu --remote-debugging-port=9222 --window-size="1024x768"
この領域に表示されている内容のスクリーンショットを保存する方法は、Wiki の「Take page screenshot」で紹介されています。
私はこんな感じでスクリーンショットを保存するメソッドを作って利用していました。
テストコードに残っていた fs はここで利用していたものです。
Page.navigate と組み合わせれば、URL を開いて、そのファーストビューのスクリーンショットを保存することができます。
const screenshot = async (client, filename, options) => {
    const result = await client.Page.captureScreenshot(Object.assign({
        format: 'png'
    }, options));

    fs.writeFileSync(filename, Buffer.from(result.data, 'base64'));
};

画面をスクロールする

スクリーンショットを保存しようと思ったときに、保存したい部分はファーストビューではなくスクロールした先にあることの方が多いのではないでしょうか。
Headless Chrome でスクロールイベントを発生させるには Input.synthesizeScrollGesture を使います。
特定の要素まで画面をスクロールしたい場合は、次のようなコードになります。
const scroll = async (client, nodeId, options) => {
    nodeId = (nodeId instanceof Object) ? nodeId : {nodeId: nodeId};
    const document = await client.DOM.getBoxModel({nodeId: 1}),
        element = await client.DOM.getBoxModel(nodeId),
        documentCenter = Math.ceil(document.model.height / 2),
        elementCenter = Math.ceil(element.model.height / 2),
        distance = element.model.content[1] - documentCenter + elementCenter;

    return client.Input.synthesizeScrollGesture({
        x: Math.ceil(document.model.content[0] / 2),
        y: 0,
        xDistance: 0,
        yDistance: distance * -1
    });
};
ただし、このやり方はスクロール量に比例して実行時間が長くなってしまいます。
1 秒間にスクロールされる距離は、オプションにある speed(default: 800pixel/second)で指定可能です。
また、スクロールイベントを発生させる必要が無い場合には、Viewport を変更するという方法もあります。
Viewport の変更には Emulation.forceViewport を使います。
先ほどのコードをこちらのやり方で書き直すと、次ののようになります。
この方法なら指定された位置にすぐに移動しますし、移動前の位置に戻ることも簡単にできるので、こちらの方法がお勧めです。
const setViewport = async (client, nodeId, options) => {
    nodeId = (nodeId instanceof Object) ? nodeId : {nodeId: nodeId};
    const document = await client.DOM.getBoxModel({nodeId: 1}),
        element = await client.DOM.getBoxModel(nodeId),
        documentCenter = Math.ceil(document.model.height / 2),
        elementCenter = Math.ceil(element.model.height / 2),
        distance = element.model.content[1] - documentCenter + elementCenter;

    return client.Emulation.forceViewport({
        x: 0,
        y: distance,
        scale: 1
    });
};

要素やページ全体を保存する

特定の要素まで画面を移動させたとしても、要素が大きければ画面からはみ出してしまいます。
また、ページ全体を保存したいというケースもあるでしょう。
ページ全体のスクリーンショットを保存する方法は「Using headless Chrome as an automated screenshot tool」という記事で紹介されています。
このページは GitHub の Wiki のページ からもリンクされています。
なお、上のブログでは Emulation.setDeviceMetricsOverride なども使われていますが、私が試した限りでは Emulation.setVisibleSize を変更するだけで問題なさそうでした。
ここでは body タグを指定してページ全体の高さを取得しています。
任意のセレクタを指定すれば、その要素の高さが取得できるので Viewport の変更などと組み合わせれば、その要素全体を含むスクリーンショットを保存することもできます。
const screenshotFull = async (client, filename, options) => {
    const original = await client.DOM.getBoxModel({nodeId: 1}),
        boxmodel = await client.DOM.getBoxModel(await client.DOM.querySelector({
            nodeId: 1,
            selector: 'body'
        }));

    await client.Emulation.setVisibleSize({
        width: boxmodel.model.width,
        height: boxmodel.model.height
    });

    const result = await client.Page.captureScreenshot(Object.assign({
        format: 'png'
    }, options));
    fs.writeFileSync(filename, Buffer.from(result.data, 'base64'));

    return client.Emulation.setVisibleSize({
        width: original.model.width,
        height: original.model.height
    });
};

注意点

この記事を書くに当たって実際にコードを書いて検証していたところ、いくつか注意の必要な部分がありましたので、最後にまとめておこうと思います。

2 回実行するとスクロール位置がずれる

特定の要素まで画面をスクロールしてスクリーンショットを保存する部分を書いていたときのことです。
同じプログラムを 2 回、実行すると、なぜか 2 回目のスクリーンショットでフッタの部分までスクロールされてしまっていました。
Chrome でページを開いて途中までスクロールした後にリロードしたり、もう一度同じ URL を入力すると、直前に見ていた位置が表示されます。
Headless Chrome はヘッドレスなだけで Chrome に変わりはありませんから、同じような動作をしていると考えられます。
そのため、スクリーンショットを保存した後は再度、ページのトップまでスクロールしておいた方がいいでしょう。
このとき実際に画面をスクロールしていた場合、ページトップまでの長さを計算してスクロールする必要があり少々面倒ですし、スクロールをする時間もかかります。
しかし、Emulation.forceViewport で Viewport を変更していた場合は Emulation.reserViewport を使うことで、すぐに元に戻すことができます。
スクロールの方法を説明している際に Viewport を指定する方法が、お勧めだといったのは、このためです。
画面をスクロールする話で紹介した setViewport メソッドを使ってスクリーンショットを保存したあと、Viewport を元に戻す処理は、こんな感じになります。
const document = await client.DOM.getDocument(),
    element = await client.DOM.querySelector({
        nodeId: document.root.nodeId,
        selector: 'div[app-run="todo.html"]'
    });
await setViewport(client, element);
await screenshot(client, './screenshot.png', element);
await client.Emulation.resetViewport();

DOM.getBoxModel ができない

これは画面をスクロールさせる方法のところで紹介した setViewport というメソッドの一部です。
const setViewport = async (client, nodeId, options) => {
    nodeId = (nodeId instanceof Object) ? nodeId : {nodeId: nodeId};
    const document = await client.DOM.getBoxModel({nodeId: 1}),
        element = await client.DOM.getBoxModel(nodeId),
この部分のコードは、もともと、こんな感じのコードでした。
しかしこのコードでは element の内容を取得する部分で「Error: Could not find node with given id」というエラーが発生してしまっていました。
const setViewport = async (client, nodeId, options) => {
    nodeId = (nodeId instanceof Object) ? nodeId : {nodeId: nodeId};
    const document = await client.DOM.getDocument(),
        window = await client.DOM.getBoxModel(document.root),
        element = await client.DOM.getBoxModel(nodeId),
// ...
詳しく調査をしてはいないのですが、メソッドの中で DOM.getDocument を呼んでいるのが原因のようでした。
DOM.getDocument を呼んだ時点ではルートの情報しか持っておらず、DOM.querySelector などが呼ばれる度に対象の情報が追加されていくのではないでしょうか。
そして、それらの情報は DOM.getDocument を呼ぶ度にリセットされているように見えます。
なので setViewport では、すでに DOM.getDocument は呼ばれているという前提で、root の nodeId である 1 を決め打ちしています。
setViewport メソッドは nodeId を渡す仕様としたためなのですが、セレクタの文字列を渡してメソッド内で DOM.querySelector を呼ぶという方法もあると思います。

Emulation.setVisibleSize した内容が残る

ページ全体のスクリーンショット保存するために Emulation.setVisibleSize を指定した後、元に戻さない限り、この設定が残り続けます。
これは、テストコード中で Headless Chrome の起動と終了をしている場合には問題にならりません。
しかし nohup などを使って Headless Chrome を立ち上げっぱなしにしている場合、意図しないサイズのスクリーンショットが保存されてしまうこともありますので、注意してください。
また Emulation.forceViewport とは違い、Emulation.setVisibleSize は簡単に元に戻せる仕組みもないため、次のように、どこかに元のサイズを保存しておく必要がありそうです。
const original = await client.DOM.getBoxModel({nodeId: 1}),
    boxmodel = await client.DOM.getBoxModel(await client.DOM.querySelector({
        nodeId: 1,
        selector: 'body'
    }));

コメントの投稿