シェルスクリプトでブラウザを自動操作する Selenium/WebDriver bindings を作りました

## はじめに

シェルスクリプトから簡単にブラウザを自動操作できるシェルスクリプト用ライブラリ「[Selenium/WebDriver bindings for shell script](https://github.com/shellspec/sh-webdriver)」を作りました。このライブラリを使うと最小の手順と短いコードで、お手軽なシェルスクリプトからお手軽にテストやスクレイピングを行うことができます。

ネタとしては @Rasukarusan さんの「[ShellでSeleniumを作る ~Shellnium~](https://qiita.com/Rasukarusan/items/70a54bd38c71a07ff7bd)」とかぶっており、最初はこちらのプロジェクトをフォークして開発しようと考えていたのですが、開発方針の違い（bash や zsh だけでなく POSIX シェルに対応させる）、Chrome 以外への対応、オブジェクト指向風のインターフェースへの変更、エラー処理等を加えると全く別物になってしまうため、フォークではなくゼロから実装しました。とはいえ WebDriver API の呼び出し方法など多くの点で参考にさせていただきましたので、この場を借りて御礼を申し上げます。

なお検索上の理由で Selenium と入れていますが、実際には W3C で標準化された WebDriver API を呼び出してるだけで、その WebDriver API を実装している WebDriver もブラウザベンダがリリースしているわけで Selenium 成分は一切でてきません。

## なぜ作ったのか？

最終的にやりたいことは、シェルスクリプト、つまり最小限の依存でブラウザを使った E2E テストをしたかったからです。W3C で標準化されている WebDriver API をシェルスクリプトから HTTP プロトコルで呼び出してるだけなので、各ブラウザ用の WebDriver と `curl`、`jq`、`base64` コマンドがあれば動作します。その他の言語の場合、言語自体と WebDriver 用のライブラリをインストールする必要がありますが、このライブラリの場合、インストールするのは `curl` と `jq` だけで、あとは `webdriver.sh` ファイル一つさえあればすぐに使えるので最小のセットアップ時間でブラウザの自動操作（そしてテスト）ができるようになります。

Selenium はテストツールとして有名ですが、実際にはブラウザの自動操作を行ってるだけでテストフレームワークが別に必要になります。シェルスクリプトでも同様にテストフレームワークが必要になります。私はシェルスクリプト用のテストフレームワーク [ShellSpec](https://github.com/shellspec/shellspec) を開発しているのですが、これはユニットテスト用であり E2E テスト用としては設計していません。将来的に ShellSpec に統合するかはまだ決めかねていますが、少なくともシェルスクリプトからブラウザを操作できないと話にならないのでその前段階の作業です。

なお当然のことながら自動操作するコードをシェルスクリプトで書くというだけで、操作対象のウェブシステムはシェルスクリプトで作られている必要はありません。（行指向でないデータ<sup>*1</sup>でストリーミング処理以外<sup>*2</sup>を行うウェブシステムをシェルスクリプトで作るのは効率が悪くありえないと言いたいところですが、世の中にはそういう無茶をする例もあるようなので一応）

<sup>*1 改行が含まれるデータ、JSON、データベース、フラットではないオブジェクトデータなど</sup>
<sup>*2 データへのランダムアクセスが必要な処理、バッチ処理以外</sup>

## 使い方

まだプレビューリリースなので未実装の機能や変更する可能性がありますが、以下のようなコードで動作します。（※ 今の所 ChromeDriver でしか動作確認していません。）

```sh
#!/bin/sh
set -eu

. ./lib/webdriver.sh

chrome_options() {
  echo '{ "args": [] }'
  # echo '{ "args": ["--headless"] }'
}

# You need to run the `chromedriver` beforehand
WebDriver driver="$(ChromeDriver chrome_options "http://localhost:9515")"
driver get "https://www.google.com"

WebElement element="$(driver find_element "css selector:[name=q]")"
element send_keys "WebDriver" :enter

# **Shorthand**
# driver find_element "css selector:[name=q]" send_keys "WebDriver" :enter

for element_id in $(driver find_elements "css selector:a"); do
  WebElement element="$element_id"
  element attribute "text"
done

driver quit
unset -f element driver
```

## 解説

特徴的なのはシェルスクリプトでありながらオブジェクト指向の概念を取り入れている所です。上記のコードの `WebDriver`、`ChromeDriver`、`WebElement`がオブジェクト指向でいうクラスに相当します。そして `driver` や `element` がインスタンスです。インスタンスは実際には（特定の情報が埋め込まれた）関数です。関数なのでインスタンスの削除には `unset -f` を用います。メソッドはインスタンスをコマンドに見立てて、そのサブコマンドという形で表現しています。

WebDriver は Chrome 以外のブラウザでも使えます。複数のブラウザに対応する場合、その違いを吸収するために継承とポリモーフィズムを使って実装するのがオブジェクト指向言語でよく用いられるパターンです。これと同じことをシェルスクリプトで実現しています。インスタンスもあるので複数のブラウザを同時に起動したり、複数の要素を同時に参照するのも容易に行うことが出来ます。この設計により他の言語の WebDriver bindings と同じような書き方ができ拡張性を持たせることが出来ました。

シェルスクリプトによるオブジェクト指向については「[POSIX準拠シェルスクリプトでオブジェクト指向プログラミング](https://qiita.com/ko1nksm/items/61b71a4ec0e3b2984e5b)」で解説しています。（POSIX準拠の）シェルスクリプトにはクラスも配列もなくデータ型も文字型だけなのでプログラミング言語としてみれば貧弱ですが、動的な関数定義と `eval` があるので工夫次第でどうとでもなります（笑）

## ちょっとお願い

実を言うと私は Selenium を使った E2E テストはあまり好きではありません。テストに時間がかかるからです。そのためユニットテストをメインで行っています。もちろん E2E テストも重要なことに違いはないのですが、出来る限り避けているので Selenium を使ったテストの経験は殆どありません。W3C で標準化された API に基づいて開発していますが、古いバージョンなど現実の WebDriver やブラウザでは問題があるはずです。また Selenium Grid は環境を準備する必要があるので検証も大変になるでしょう。そこで、もしこのプロジェクトに興味がありましたら開発を手伝っていただけると助かります。バグ報告や修正のプルリクエストなどぜひお願いします！

## 参考

- W3C
  - [WebDriver W3C Recommendation 05 June 2018](https://www.w3.org/TR/webdriver1/)
  - [WebDriver Level 2 W3C Working Draft 24 August 2020](https://www.w3.org/TR/webdriver2/)
  - [WebDriver W3C Editor's Draft 15 March 2021](https://w3c.github.io/webdriver/)
- Selenium
  - https://www.selenium.dev/documentation/
  - https://www.selenium.dev/selenium/docs/api/rb/Selenium.html
- Mozilla
  - https://developer.mozilla.org/docs/Web/WebDriver
- 他
  - https://www.seleniumqref.com/index.html
  - https://selenium-python.readthedocs.io/index.html