Google Chrome拡張をCircleCIでテストできる環境を作った


## はじめに

自分は[ゴシップサイトブロッカー](https://gsb.ikemo3.com/)(以下GSB)というGoogle Chrome / Firefox拡張を作成しています。ですが、最近メンテナンスリリースしかできていません。

環境が変わったのもありますが、一番の理由はCI環境が整備されていないので変更がつらいという点にあります。これは何とかせねばということで、CircleCIを使ってGoogle Chrome拡張をテストできる環境を作りました。

・・・といってもGSBはそれなりに規模が大きくなっているので、代わりに別の拡張機能のテストができる環境を先に作りました。
GSBもやってることは一緒なので大丈夫なはず。

## 「期間を指定」の書式変更

元にしたのは以下の拡張機能です。

- [「期間を指定」の書式変更 - Chrome ウェブストア](https://chrome.google.com/webstore/detail/hamifmjgokbeimmcbgfihojhogeeeipd)
- [ikemo3/google-search-datepicker-fix](https://github.com/ikemo3/google-search-datepicker-fix)

これは何かというと、Google検索で期間を指定する際に「mm/dd/yyyy」というアメリカンな仕様を強制されるのが嫌でたまらなかったので、yyyy/mm/dd形式でも入力できるようにしたものです。それだけの拡張ですがまあ割と便利です。このテストをCircleCIを使って対応しました。

## 対応方法

### CircleCI CLIを入れる

毎回pushして結果を待つのはだるいので、CLIを入れます。

- [Using the CircleCI Local CLI - CircleCI](https://circleci.com/docs/2.0/local-cli/)

以下の2つのコマンドが使えればとりあえず何とかなります。

- `circleci config validate`: 設定ファイルのバリデーション
- `circleci local execute`: ローカルで実行

### Node.jsを12にする

開発中に何度もエラーが出たのですが、スタックトレースが不完全で、
どこの行でエラーが出たのかが分かりませんでした。
これは困ったというわけで調べてみたところ、
Node.js 12から入った `--async-stack-traces` というオプションを追加すると、
いい感じにスタックトレースが出てくれます。

- [Missing stack traces from async functions after the first await · Issue #11865 · nodejs/node](https://github.com/nodejs/node/issues/11865)

CircleCIのDockerイメージには `circleci/node:12-browsers` と指定します。

### CLIでChrome拡張作成

CLIでChrome拡張を作成する必要がありますが、そのために使ったのはcrxというコマンドです。

- [oncletom/crx: A node.js command line app for packing Google Chrome extensions.](https://github.com/oncletom/crx)

こんな感じで使います。

```sh
mkdir -p /tmp/workspace
./node_modules/.bin/crx pack -o /tmp/workspace/google-search-datepicker.crx apps
```

### Selenium WebDriverでテスト書き

定番のSelenium WebDriverでテストを書きました。

- [APIドキュメント](https://seleniumhq.github.io/selenium/docs/api/javascript/index.html)

なお、テスト書いている途中で[puppeteer][]の存在を知りましたが、Chrome専用なので今回はSelenium WebDriverでいいかなと判断して採用を見送りました。

まず初期化です。

```javascript
const { Builder, By, Capabilities } = require("selenium-webdriver");
const chrome = require("selenium-webdriver/chrome");
const fs = require("fs");
const assert = require("assert");

const extension = fs.readFileSync(
  "/tmp/workspace/google-search-datepicker.crx",
  "base64",
);
const options = new chrome.Options()
  .addExtensions(extension)
  .windowSize({ width: 1280, height: 800 });

const capabilities = Capabilities.chrome();
capabilities.set("chromeOptions", {
  args: [
    "--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.100 Safari/537.36",
  ],
});
```

2点引っかかったポイントがあります。

まず拡張機能の読みこみがうまくいきませんでした。
`.addExtensions` にはファイルパスが指定できると書かれてましたが、
なぜかうまくいかなかったので、base64文字列として読み込んだ後に渡しています。

次に、Google検索の結果が取得できませんでした。
User Agentの指定がないとダメなようです。

### テストを書く

そしてテストを書きます。具体的にはこんな感じになりました。
所々 `pause()` が入っているのはタイミングの問題で動かなかったためです。

```javascript
(async () => {
  const driver = await new Builder()
    .withCapabilities(capabilities)
    .setChromeOptions(options)
    .build();

  await driver.get("https://www.google.com/search?q=test");

  // click 'Tools'
  const tool = driver.findElement(By.linkText("ツール"));
  await tool.click();

  // click 'Any time'
  const any_time = driver.findElement(By.css("[aria-label='期間指定なし']"));
  await driver.actions().pause(500).click(any_time).perform();

  // click 'Custom range...'
  const custom_range = driver.findElement(By.id("cdrlnk"));
  await driver.actions().pause(500).click(custom_range).perform();

  // set 'From'
  const cdr_min = driver.findElement(By.id("cdr_min"));
  await driver
    .actions()
    .pause(500)
    .click(cdr_min)
    .sendKeys("2019/01/02")
    .perform();

  // click 'Go'
  const go_button = driver.findElement(By.css("#cdr_frm input[value='選択']"));
  await driver.actions().pause(500).click(go_button).perform();

  // assert time range label.
  const time_range_label = driver.findElement(By.className("hdtb-tsel"));
  assert.strictEqual(
    await time_range_label.getAttribute("aria-label"),
    "2019年1月2日 – 今日",
  );
})();
```

### ヘッドレスで動かない件

これでテストも動くようになったのですが、Chromeをヘッドレスで動かそうとすると動かないことが分かりました。
調べてみたところ、サポート外だそうです。

- [706008 - Extensions support in headless Chrome - chromium - Monorail](https://bugs.chromium.org/p/chromium/issues/detail?id=706008#c5)

仕方ないので他の方法を探してXvfbを使えばいいと知って試そうと思ったのですが・・・
CircleCIのDockerイメージに最初からXvfbが入っているようです。
何も設定変更せずに動きました。

## 参考資料

- [Testing Chrome Extensions with Selenium - DEV Community 👩‍💻👨‍💻](https://dev.to/razgandeanu/testing-chrome-extensions-with-selenium-491b)
  - Python + Selenium WebDriverを使ったChrome拡張のテスト。
- [GoogleChrome/puppeteer: Headless Chrome Node API](https://github.com/GoogleChrome/puppeteer)
  - 今回は使わなかったけど、便利そうなAPI
- [Faster async functions and promises · V8](https://v8.dev/blog/fast-async#improved-developer-experience)
  - Node.js 12から使えるようになった `async-stack-traces` についての記述。

[puppeteer]: https://github.com/GoogleChrome/puppeteer
[Actions]: https://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/lib/input_exports_Actions.html