これは ドリコム Advent Calendar 2020 の7日目です。

tl;dr

  • CDPのscreencastFrameで動画を撮ってみるサンプル
  • puppeteer[“Page”][“screenshot”]で動画撮るのはパフォーマンスに難がありそう

対象読者

  • puppeteerを使っていて(使ってみたくて)、動画を撮りたい人
  • puppeteerでE2Eテストを行っていて(行いたくて)、デバッグの手段を増やしたい人

本記事で扱うソフトウェア

  • puppeteer: https://github.com/puppeteer/puppeteer
    • Chrome(Chromium)を操作するAPIを提供するNode.jsライブラリです、devtools相当のことができます
    • 今回は、v5.5.0を使用します
  • node.js: https://nodejs.org/
    • ブラウザー外で動くJavaScript実行環境です
    • 今回は、v12.20.0を使用します
  • typescript: https://www.typescriptlang.org/
    • 型を導入したJavaScriptのsupersetです
    • 今回は、v4.1.2を使用します
  • ffmpeg: https://ffmpeg.org/
    • 動画・音声を記録・編集・再生するためのソフトウェア群です、色々変換できて便利
    • 今回は、version 4.3.1を使用します

はじめに

おはようございます、こんにちは、こんばんは。
enzaプラットフォーム事業本部のフロントエンドエンジニアのmasakijです。

最近、再演してたミュージカルビューティフルを観劇してきたのですが、久しぶりの生音の生舞台、心躍りましたね。きらめく音と光の世界で聞けるキャロル・キング & ジェリー・ゴフィン、バリー・マン & シンシア・ワイルの名曲は、まるでブロードウェイのイメージで(ニューヨークにすら行ったことないですが)、まさに珠玉のエンターテイメントでした。そんなエンターテイメントに関われたら思う11月でした。今回の記事とは、関係ない話でしたが、平日に3回ミュージカルを見に行けるくらいには、勤務に自由が効くチームだったりするので、プラットフォームやエンターテイメントに興味がある人には、弊事業本部も選択肢に入れていただけたら幸いです。

そんなわけで、今回はE2Eテストのデバッグ関連の話です。

E2Eテストについて

E2Eテストは、End to Endテストの略でフロントエンドからバックエンドまで結合された状態でユーザーインターフェース(ブラウザー)からの操作をユーザーインターフェース(ブラウザー)自体で確認して検証して行く結合テストの一種です。一般的には、ユーザーインターフェースの操作を自動化するツールなど使って結合フェーズのテストシナリオを実行する自動テストのことを指すことが多いと思います。

enzaプラットフォームにおいても、品質改善・機能開発時の不具合検出の効率化のため主要機能を中心に導入を行っています。E2Eテストを運用するに当たっての課題の一つに、テストの壊れやすさ・不安定さがあります。我々のテストケースにおいてもなぜかCIがコケたりすることがありますが、特にリモートのCI上(CircleCI)のE2Eテストのデバッグが難しかったりします。ローカルであれば、headlessを解除したり、テストケースが失敗しそうな前後でdevtoolsを立ち上げて詳細に確認することができますが、リモートでは画面を表示したりページ内の要素の詳細を確認するのは困難です(また、リモートではなぜか落ちるなどもあったり…)。そこで、デバッグを容易にするために、操作単位や失敗時のスクリーンショットの取得やブラウザーのコンソールログ、ページ内の通信内容のログ、domのsnapshotなどの種々のログを事前に仕込んでいたりします。ただ、スクリーンショットがたくさんあると探すのが億劫だったり、ログの取得間隔の間に何が起きてるのか確認したいことがあります。こういった時、映像としてログが取得できていると便利だと思うのですが、我々が現在使っているpuppeteer(ブラウザー操作ツール)では、録画機能はありません(CypressやAutifyのようなE2E専用サービスではあるのでしょうが…)。

そこで、今回はE2E中の描画を連続して確認できるように、CI上のE2Eテストの動画を撮影できるようにしてみたいと思います。

puppeteerで動画を撮れそうなAPIを調べてみる

E2Eテストで動画を撮る方法を考えてみると、実現性がありそうなものは大きくは次の2つかなと思います。

  • 仮想フレームバッファを設定して、仮想フレームバッファ上に描画されたブラウザーの画面を録画する
  • puppeteerやブラウザーのdevtoolsの機能を使って録画する

まず、仮想フレームバッファを使う場合ですが、次のようなpuppeteerをheadlessで動かす利点が損なわれてしまうと考えたので、今回は検証を行っていません。

  • (seleniumとかを使っている場合は流用できるでしょうが)環境のセットアップコストが上がってしまう
    • 実行環境の制約が強くなる
  • 仮想フレームバッファに描画しないといけないのでheadlessで実行できない
    • 実行コストがあがりそう
  • 仮想フレームバッファ毎に一つのページしか実行できない
    • ウィンドウが重なった場合、下のウィンドウが録画できなさそう
    • テストシナリオの並列化がしづらい

puppeteerやブラウザーの機能で動画を録画したいので、ひとまずpuppeteerやブラウザーのAPIで、動画撮影に使えそうなAPIがないか探してみます。

puppeteer document site: https://pptr.dev/

puppeteerのAPI自体には、2020年12月現在、動画を録画するAPIはないのですが、screenshotを撮るAPIがあります。このAPIを使って、静止画を連続して撮影していく方法も考えられますが、(実行時にredrawやrepaintが発生してそうで)パフォーマンスに難がありそうな気がするので、他のAPIもないか探してみます。
puppeteerは、Chromeのdevtools protocolを介してChromeを操作するソフトウェアであり、種々のAPIはこのprotocolを使って実行されてます。このprotocolを直接操作するAPIもあるので、そちらも調べてみます。

DevTools Protocol Viewer: https://chromedevtools.github.io/devtools-protocol/

探してみると、screencastFrameというeventがあるようで、このAPIを試してみたいと思います。

replで、どう使うのかとどう取れるのかを確認します(cliでnodeのreplを立ち上げます)。

$ node --experimental-repl-await

replで、次のコマンドを実行して、screencastされた101 frame目を書き出してみます。

> const pptr = require("puppeteer")
> const browser = await pptr.launch({})
> const page = await browser.newPage()
> await page.goto("https://drecom.co.jp")
> const client = await page.target().createCDPSession()
> // pageのeventを有効にする
> await client.send("Page.enable")
> const frames = []
> // screencast eventをlistenする
> client.on("Page.screencastFrame", async ({ data, sessionId }) => {
  console.log(Date.now(), sessionId);
  frame.push(data);
  // 今のframeのackを返す https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-screencastFrameAck
  await client.send("Page.screencastFrameAck", { sessionId });
  // screencastを止める https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-stopScreencast
  if (frames.length > 100)  await client.send("Page.stopScreencast");
})
> // screencastを始める https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-startScreencast
> await client.send("Page.startScreencast", { format: "jpeg" })
> const fs = require("fs")
> // base64で返ってきてるぽい
> await fs.promises.writeFile("./test.jpg", frame[100], "base64")

秒間数フレーム送れて来てくれるぽいので、puppeteerのscreenshot APIより良さそうなので、これで動画撮影処理を作ってみたいと思います。

動画撮影処理を作ってみる

連続した静止画が撮れるようになったので、動画撮影処理を作ってみたいと思います。動画の結合には、ffmpegを使います(別途環境にインストールされてる必要があります)。外部プロセスとして実行して処理させて動画が出力できるのか、nodeのreplで動作を見てみたいと思います。

> const cp = require("child_process")
> // stdinからbufferを受け取って実行するようにする。frame rateはなんとなく
> const proc = cp.spawn("ffmpeg", ["-f", "image2pipe", "-r", "3", "-i", "-", "-vcodec", "libx264", "output.mp4"]);
> // さっき取得した静止画の配列
> proc.stdin.write(Buffer.concat(frames.map((f) => Buffer.from(f, "base64"))))
> proc.stdin.end()

(色々パラメーターやらフォーマットをいじったりしましたが、)取得した静止画の配列で動画が作れることが確認できた(うまく再生できなかったり大変でしたが…)ので、puppeteerで画面操作時に並行して動画を撮れる処理を作ってます。

作る処理の要件は、こんな感じです。

  • なるべくテストコード側のパフォーマンスを阻害しない
  • 任意の時間から録画できて、任意の時間に終われる
  • 任意のファイル名で書き出せる
  • いい感じのフレームレートで出力できる

比較のために、screencast eventを使ったものとscreenshot apiを使ったものも一応作ってみます。

import cp from "child_process";
import pptr from "puppeteer";

class Recorder {
  // frame毎の静止画を都度fileに書き出すとパフォーマンスに影響がでそうなので、メモリに持つ
  frames: string[] = [];
  firstFrameTime?: number;
  lastFrameTime?: number;

  async writeFile(filename: string) {
    if (!this.firstFrameTime || !this.lastFrameTime) throw new Error("require recording");
    const duration = this.lastFrameTime - this.firstFrameTime;
    if (duration === 0 || !this.frames.length) throw new Error("require recording");
    const fps = Math.floor(this.frames.length / (duration / 1000));
    // 一時ファイルを作らずに、stdinに流す
    const proc = cp.spawn("ffmpeg", ["-f", "image2pipe", "-r", `${fps > 1 ? fps : 1}`, "-i", "-", "-vcodec", "libx264", "-y", filename]);
    // proc.stdout.on("data", (d) => console.log(d.toString()));
    // proc.stderr.on("data", (d) => console.error(d.toString()));
    await new Promise((resolve, reject) => {
      proc.stdin.write(
        Buffer.concat(this.frames.map((f) => Buffer.from(f, "base64"))),
        (err) => {
          if (err) reject(err);
          else resolve(true);
        },
      );
    });
    await new Promise((r) => proc.stdin.end(r));
  }
}

class ScreencastRecorder extends Recorder {
  client?: pptr.CDPSession;
  async setup(page: pptr.Page) {
    const client = await page.target().createCDPSession();
    await client.send("Page.enable");
    client.on("Page.screencastFrame", async ({ data, sessionId }) => {
      if (!this.firstFrameTime) this.firstFrameTime = Date.now();
      this.frames.push(data);
      this.lastFrameTime = Date.now();
      await client.send("Page.screencastFrameAck", { sessionId });
    });
    this.client = client;
  }
  async start() {
    if (!this.client) throw new Error("require setup");
    await this.client.send("Page.startScreencast", { format: "jpeg" })
  }
  async stop() {
    if (!this.client) throw new Error("require setup");
    await this.client.send("Page.stopScreencast");
  }
}

class ScreenshotRecorder extends Recorder {
  page: pptr.Page;
  running: boolean = false;
  constructor(page: pptr.Page) {
    super();
    this.page = page;
  }
  loop = async () => {
    if (!this.running) return;
    this.frames.push(await this.page.screenshot({ type: "jpeg", encoding: "base64" }));
    if (!this.firstFrameTime) this.firstFrameTime = Date.now();
    this.lastFrameTime = Date.now();
    // ちょっと雑かな
    setImmediate(this.loop);
  }
  start() {
    this.running = true;
    this.loop();
  }
  stop() {
    this.running = false;
  }
}

試しにE2Eテスト中の動画を撮ってみる

作った動画作成処理を模擬E2Eテストで試してみます。テストシナリオは、ドリコムのトップページを表示したあとに、メニューのミッション・ビジョンのリンクをクリックすると、ミッション・ビジョンページに飛ぶのを確認します(アニメーション時の操作が面倒くさくて、ガチャガチャしてます…)。この時のpuppeteerの操作を動画に撮ってみます。

async function main() {
  const browser = await pptr.launch({});
  const page = await browser.newPage();
  const sr = new ScreencastRecorder();
  // ScreenshotRecorder使う時はこっちのコメントアウトを外す
  // const sr = new ScreenshotRecorder(page);
  try {
    await sr.setup(page);
    await page.goto("https://drecom.co.jp", { waitUntil: "load" });
    await sr.start();
    const menuBtn = await page.waitForSelector(".c-menu-button", { visible: true });
    // なんかアニメーション中消える
    await new Promise((resolve, reject) => {
      let count = 0;
      const loop = async () => {
        count += 1;
        const v = await menuBtn.isIntersectingViewport();
        if (v) {
          resolve(true);
        } else {
          if (count > 30) {
            reject(new Error("timeout"));
          } else {
            setTimeout(loop, 100);
          }
        }
      }
      loop();
    });
    // アニメーション中うまくクリックできない
    await new Promise((resolve, reject) => {
      let count = 0;
      const loop = async () => {
        try {
          count += 1;
          await menuBtn.click();
          await page.waitForSelector(".l-header.l-header--menu-open", { visible: true, timeout: 15_000 });
          resolve(true);
        } catch (e) {
          if (count > 3) {
            reject(e);
          } else {
            await loop();
          }
        }
      }
      loop();
    });
    const comBtn = await page.waitForXPath("//*[contains(@class, 'm-gnavi-title')]/*[text() = '企業情報']", { visible: true });
    await comBtn.click();
    const mvBtn = await page.waitForXPath("//*[@style = 'display: block;']/*[contains(@class, 'm-link-item')]/*[text() = 'ミッション・ビジョン']", { visible: true });
    await mvBtn.click();
    await page.waitForXPath("//body[contains(@class, 'isView') and contains(@class, 'loadDown')]//h1[text() = 'ミッション・ビジョン']", { visible: true });
  } catch (e) {
    console.error(e);
  } finally {
    await page.screenshot({ path: "finally.png" });
    await sr.stop();
    await sr.writeFile("output.mp4");
    await browser.close();
  }
}

main();

まず、screencastを使った場合ですが、動画中のアニメーションもそれなりに見れるようになっており、ffprobeで確認すると9 fpsくらいで撮影できてるようでした。(このミッション・ビジョンのページの内容を知ってると面接の時、印象がいいかも知れないです)

比較のために、screenshotを使った場合も実行してみましたが、テストコードの実行を阻害しているようで、メニューが開けずテストが最後まで完了しないようでした。また、フレームレートもよくて4 fpsほどで少しカクついてるようでした。screenshotの実行間隔を調整すれば、テストコードへの影響は低減できるかもしれないですが、フレームレートは上げようがないのではないかと思われるので、今の所実用は厳しいのではないかと思います。

まとめ

  • chrome devtools protocolのscreencast eventを使うことで、puppeteerを使ったE2Eテストでもそれなりのフレームレートの動画を撮れるようになりました
  • screenshot APIだと、E2Eテスト時の動画撮影はパフォーマンスが出ず、厳しそうなことを示しました

今回、puppeteer周りの調査・検証はすぐ終わったのですが、ffmpegでの動画結合処理と弊社サイトのスクレイピングが骨が折れました…

明日の記事は 本間寿々香 さんの「AdventCalendarかきおろしイラスト vol.1」です。


ドリコムでは一緒に働くメンバーを募集しています!
募集一覧はコチラを御覧ください!