GoogleChromeのDevToolsでブラウザ操作を記録・再生できるようになるので試した

（システム）テスト自動化エヴァンジェリストの@YoshikiItoです。

この記事を書く一年ほど前に、DevToolsでブラウザ操作を記録してPuppeteerのコードが吐ける機能がプレビューされていると知って試していました。

参考：[GoogleChromeのDevToolsでブラウザ操作を記録し、Puppeteerのコードを出力してみる - Qiita](https://qiita.com/YoshikiIto/items/d2e51c52404419c3a98d)

まだChromeの正式版には載っていませんが、かなり進化をしているようなニュースが先日リリースされています。

参考：[Record, replay and measure user flows - Chrome Developers](https://developer.chrome.com/docs/devtools/recorder/)

ということで、どれだけすごくなったのか、１年ぶりに試してみました。

## 前提

- Windows 10

## 準備

現行のChrome95では機能をONにする項目が見当たらず・・・上記Chrome Developersのページによると

> This is a preview feature in Chrome 97. Our team is actively working on this feature and we are looking for your feedback for further enhancements.

らしいので、Chrome 97（本記事を書いている時点でDev）を入れてみます。

ちなみにChromeの直近のリリーススケジュールはここから見られます。

[Chrome Platform Status](https://www.chromestatus.com/features/schedule)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/46492/f8f154fc-58c3-d5d9-483d-3d0f1049d30e.png)

Chrome 97をクリックすると、ダウンロードページに飛べます。今回はココ：[Google Chrome デベロッパー ツール - Google Chrome](https://www.google.com/chrome/dev/)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/46492/40572733-65b1-aa40-5254-5cc318f60e5d.png)

Chrome Devをダウンロードします。すぐにexeがダウンロードされるので、起動してインストール。

Chrome Devがインストールできました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/46492/91270946-b8b5-7048-15ad-b61d9c80d786.png)


## Recorderの有効化

■■■（縦）からMore Tools→Recorderを選択

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/46492/fb4dfff1-076e-b556-e635-2fcd98fd4b94.png)

Recorderタブが開きます。

大体の説明はここに載ってます。勝手にシステムテストやE2Eテストと呼ばれそうなテストレベルを想定してますが、

> Measure performance across an entire user journey

と書いてあるので、ネットワークのパフォーマンスなどそちらを測るのが主・・・にも見えつつ、③でPuppeteerのスクリプトが吐けるよと書いてあるので、どっちメインとかは無いのかも。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/46492/022ebb62-bbbe-672c-2b3e-5aa60e7f58e6.png)


## 操作を記録する

同画面の`Start new recording"ボタンを押して開始します。

ボタンを押すとRECORDING NAMEを入れろと言われるので、`Qiita login`とでもしておきます。

RECORDING NAMEを入れたらStart a new recordingボタンをクリック。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/46492/5d0a5db5-2637-8dab-fa9a-f35528f11dd7.png)


比較のため、[GoogleChromeのDevToolsでブラウザ操作を記録し、Puppeteerのコードを出力してみる - Qiita](https://qiita.com/YoshikiIto/items/d2e51c52404419c3a98d)で記録したのと同じ手順で操作してみます。

1. ユーザー名に`Hoge`を入力
1. メールアドレスに`hoge@hoge.com`を入力
1. パスワードに`hogehoge`を入力
1. 登録するボタンをクリック

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/46492/3b64fb26-ba22-d673-9bc8-4b0763335de4.png)

1年前にこの機能を触ってみたときと異なり、操作しているステップがリアルタイムに表示されます。

これはAutifyやmablなどのレコーダーに近い印象。個人的には好きです。

`End recording`をクリックして記録を停止します。

## 記録したテストを再生する

そのまま`Replay`ボタンがあるので押します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/46492/787e64a0-1422-710e-1435-c7a5d39bb21a.png)

無事再生されます。


## アサーションを入れる

ステップで▶をクリックしてみると、セレクタやx, y座標などの情報が見られます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/46492/aadc1a6d-cb23-8a2b-9400-3a5552614452.png)

ここに`Add assertedEvents`というボタンがあります。押してみると、

- type
- url
- title

を入力できます。

試しにAssertがFailするよう、わざとへんなURLを与えてみます。（ｉが一つ多い）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/46492/5791d802-54f1-1ed6-32a9-826ede6609ec.png)

この状態で案の定失敗するのですが、urlとtitleを正しい値にしてもFailしてしまいます。ﾅｾﾞ

公式の記事（[Record, replay and measure user flows - Chrome Developers](https://developer.chrome.com/docs/devtools/recorder/)）にも特にAssertionに関する記載がないので、まだ開発途中なのかもしれません。

## Puppeteerのコードを出力

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/46492/43f25519-4e97-58b0-77e5-6a026e09cb38.png)

上部にある↑のボタンを押すと、ファイルを保存するダイアログが出ます。
好きな名前で保存。

実際に出力されたコードがこちら。長いです。

``` js
const puppeteer = require('puppeteer');

(async () => {
    const browser = await puppeteer.launch();
    const page = await browser.newPage();

    async function waitForSelectors(selectors, frame) {
      for (const selector of selectors) {
        try {
          return await waitForSelector(selector, frame);
        } catch (err) {
          console.error(err);
        }
      }
      throw new Error('Could not find element for selectors: ' + JSON.stringify(selectors));
    }

    async function waitForSelector(selector, frame) {
      if (selector instanceof Array) {
        let element = null;
        for (const part of selector) {
          if (!element) {
            element = await frame.waitForSelector(part);
          } else {
            element = await element.$(part);
          }
          if (!element) {
            throw new Error('Could not find element: ' + part);
          }
          element = (await element.evaluateHandle(el => el.shadowRoot ? el.shadowRoot : el)).asElement();
        }
        if (!element) {
          throw new Error('Could not find element: ' + selector.join('|'));
        }
        return element;
      }
      const element = await frame.waitForSelector(selector);
      if (!element) {
        throw new Error('Could not find element: ' + selector);
      }
      return element;
    }

    async function waitForElement(step, frame) {
      const count = step.count || 1;
      const operator = step.operator || '>=';
      const comp = {
        '==': (a, b) => a === b,
        '>=': (a, b) => a >= b,
        '<=': (a, b) => a <= b,
      };
      const compFn = comp[operator];
      await waitForFunction(async () => {
        const elements = await querySelectorsAll(step.selectors, frame);
        return compFn(elements.length, count);
      });
    }

    async function querySelectorsAll(selectors, frame) {
      for (const selector of selectors) {
        const result = await querySelectorAll(selector, frame);
        if (result.length) {
          return result;
        }
      }
      return [];
    }

    async function querySelectorAll(selector, frame) {
      if (selector instanceof Array) {
        let elements = [];
        let i = 0;
        for (const part of selector) {
          if (i === 0) {
            elements = await frame.$$(part);
          } else {
            const tmpElements = elements;
            elements = [];
            for (const el of tmpElements) {
              elements.push(...(await el.$$(part)));
            }
          }
          if (elements.length === 0) {
            return [];
          }
          const tmpElements = [];
          for (const el of elements) {
            const newEl = (await el.evaluateHandle(el => el.shadowRoot ? el.shadowRoot : el)).asElement();
            if (newEl) {
              tmpElements.push(newEl);
            }
          }
          elements = tmpElements;
          i++;
        }
        return elements;
      }
      const element = await frame.$$(selector);
      if (!element) {
        throw new Error('Could not find element: ' + selector);
      }
      return element;
    }

    async function waitForFunction(fn) {
      let isActive = true;
      setTimeout(() => {
        isActive = false;
      }, 5000);
      while (isActive) {
        const result = await fn();
        if (result) {
          return;
        }
        await new Promise(resolve => setTimeout(resolve, 100));
      }
      throw new Error('Timed out');
    }
    {
        const targetPage = page;
        await targetPage.setViewport({"width":1012,"height":639})
    }
    {
        const targetPage = page;
        const promises = [];
        promises.push(targetPage.waitForNavigation());
        await targetPage.goto('https://qiita.com/');
        await Promise.all(promises);
    }
    {
        const targetPage = page;
        const element = await waitForSelectors([["aria/ユーザー名"],["#url_name"]], targetPage);
        await element.click({ offset: { x: 101.33331298828125, y: 12} });
    }
    {
        const targetPage = page;
        const element = await waitForSelectors([["aria/ユーザー名"],["#url_name"]], targetPage);
        const type = await element.evaluate(el => el.type);
        if (["textarea","select-one","text","url","tel","search","password","number","email"].includes(type)) {
          await element.type('Hoge');
        } else {
          await element.focus();
          await element.evaluate((el, value) => {
            el.value = value;
            el.dispatchEvent(new Event('input', { bubbles: true }));
            el.dispatchEvent(new Event('change', { bubbles: true }));
          }, "Hoge");
        }
    }
    {
        const targetPage = page;
        const element = await waitForSelectors([["aria/メールアドレス"],["#email"]], targetPage);
        await element.click({ offset: { x: 92.33331298828125, y: 30} });
    }
    {
        const targetPage = page;
        const element = await waitForSelectors([["aria/メールアドレス"],["#email"]], targetPage);
        const type = await element.evaluate(el => el.type);
        if (["textarea","select-one","text","url","tel","search","password","number","email"].includes(type)) {
          await element.type('hoge@hoge.com');
        } else {
          await element.focus();
          await element.evaluate((el, value) => {
            el.value = value;
            el.dispatchEvent(new Event('input', { bubbles: true }));
            el.dispatchEvent(new Event('change', { bubbles: true }));
          }, "hoge@hoge.com");
        }
    }
    {
        const targetPage = page;
        const element = await waitForSelectors([["aria/パスワード"],["#password"]], targetPage);
        await element.click({ offset: { x: 88.33331298828125, y: 12} });
    }
    {
        const targetPage = page;
        const element = await waitForSelectors([["aria/パスワード"],["#password"]], targetPage);
        const type = await element.evaluate(el => el.type);
        if (["textarea","select-one","text","url","tel","search","password","number","email"].includes(type)) {
          await element.type('hogehoge');
        } else {
          await element.focus();
          await element.evaluate((el, value) => {
            el.value = value;
            el.dispatchEvent(new Event('input', { bubbles: true }));
            el.dispatchEvent(new Event('change', { bubbles: true }));
          }, "hogehoge");
        }
    }
    {
        const targetPage = page;
        const element = await waitForSelectors([["aria/登録する"],["body > div.allWrapper > div.p-home.is-anonymous > div.nl-Hero > div > div.nl-Hero_end > form > div.nl-SignupForm_buttonArea > div > button"]], targetPage);
        await element.click({ offset: { x: 62.33331298828125, y: 18.6666259765625} });
    }

    await browser.close();
})();
```

クリック操作は座標まで保存されています。
登録するボタンなんかは座標でなくセレクタ何か使ってクリックしてほしいところですね・・・

## 所感

そもそもキャプチャ・リプレイ自体がそうなのですが、操作を記録してコード出力したからといってそのまま使えるわけではない、のは変わらずですね。
ただ、何もないところからいきなり書き始めるよりは、記録したものを修正するほうが楽、という方もいるので、その手の使いみちはありそうです。