socket.ioを使って、実機でE2Eできるライブラリを作ってみた

# 背景
やんごとなき理由でメチャ古いAndroid OSに載っているブラウザでウェブアプリを表示してそのアプリでなんらかのデバイスを操作することになったとします。
なんらかのデバイスはWeb APIを提供していて、ブラウザおよびAndroid OSからだけいい感じにデバイスを操作できるようになっているとします。(デバイス外部からの通信は受け付けない)
ウェブアプリのUIテストは全部自動化させたいですよね。
でも、メチャ古いAndroid OSだし、なんらかのデバイスに組み込まれているので、外から操作することもできない。
TestCafeもブラウザが古くて、なんか動かなかった。
でも、実際にUIテストを使ってデバイスの機能が動くところまで確認したい。
要は、PCからウェブアプリにエレメントを操作する命令を送信できればよいわけです。
WebSocket使ったら簡単じゃない？と思ったのでやってみました。

## 追記
全然知らなかったのですが、これって Selenium RC の考え方に近いそうです。
Selenium RC はテストケースを書いたjsのソースコードをhtml側に埋め込み実行する形式ですが、こちらのアイデアはコアになる小さなソースコードだけhtmlに埋め込み、PCからテストコードを流しこむ形で実行します。

# 考え方
* ウェブアプリはWebSocketを使ってPCから命令を待ち受けます。
* PCはWebSocketを使って命令を送信します。
* なるべくウェブアプリ側で動作するコードは小さくします。
    * ウェブアプリには丸ごと `function` を送信し、ウェブアプリは `function` を `eval` します。
    * 例えば、`document.querySelector("#app")`とやればエレメントは取れますね。
* 安全性はあんまり考えていません。

# できた
* 本体: https://github.com/naoki-tomita/websocket-webdriver
* それを使ったテストサンプル: https://github.com/naoki-tomita/west

雑に作っているので、今はエラーにするとかできません。

# 使い方
## ウェブアプリ
HTMLにスクリプトを入れる

```html
<button id="btn"></button>
<div id="count">0</div>

<!-- main.jsはwebsocket-webdriver/dist下にある -->
<script src="main.js"></script>
```
## テストコード
```ts
import { element } from "webscoket-webdriver";

describe("foo", () => {
  it("should count up when btn clicked.", () => {
    await element("#btn").click();
    expect(await element("#count").getText()).toBe("1");
  });
});
```

※テストコードを実行するIPと、ウェブアプリを提供するIPは同じにする必要があります(いまのとこ)。何らかの方法でテストコードを実行するIPとウェブアプリを提供するIPを別にする必要があるでしょう。

## 実行

テストコードを実行すると、ブラウザーの接続待ちになります。
ブラウザーでウェブアプリを開きましょう。そしたらテストが始まります。

# API
## `element(selector: string): Element`
querySelectorで指定したエレメントを取得します。

## `Element`
ElementのAPIはすべて、エレメントが見つかるまで待つ処理を入れています。
Seleniumだとエレメントが表示される前にクリックカマして勝手にエラーになって地味にうざかったので、標準搭載してみました。
代わりに、`setTimeout(timeout: number)`というAPIを用意していて、`timeout` msec待ったけど見つからなかったときにはエラーにするようにしています。

### `click()`
エレメントをクリックします。

### `getText(): string`
エレメントのinnerTextを取得します。

### `sendKeys(keys: string)`
inputエレメントであるという前提で・・・
valueにテキストを突っ込みます。

### その他
* isHiddenとかisExistとか・・・
* ぶっちゃけ全然足りていないので、足さなきゃ。

## `browser`
### `captureScreenShot(path: string)`
スクリーンショットを撮ります。
html2canvasを使っています。
マジで撮れるので感動しました。

### `getUrl(url: string)`
指定したURLに遷移します。

### `reload(forceReload: boolean)`
ページをリロードします。

# メモ
古いブラウザーはWebSocketが使えなかったので、socket.ioを使いました。
socket.ioなら、WebSocketが使えなければロングポーリングとかを使ってなんとかしてくれるので、対応してるかどうかを気にする必要がなく、とてもよいです。

# 工夫したところ
## 関数を手軽に送る方法
テキストで送りたい関数を書くのもいいですが、それだとTypeScriptの恩恵が受けられないので、関数オブジェクトを.toString()でテキストにしています。
やってませんが、この方法ならMTも書きやすいでしょう。
[ここらへん](https://github.com/naoki-tomita/websocket-webdriver/blob/master/src/scripts/server/FunctionEvaluator.ts#L4)

```ts
async function evaluate(fn: (...params: any[]) => any, ...params: any[]) {
   return send({
     function: `return (${fn.toString()})`,
    params: params,
  });
}
```

## 関数に引数を渡す
関数をテキスト化して送るため、クロージャは使えません。
なんらかの方法で引数をわたす必要がありますが、これは関数とは別のパラメータにして送り、ウェブアプリ側でevalするときに引数を渡すようにしました。
[ここらへん](https://github.com/naoki-tomita/websocket-webdriver/blob/master/src/scripts/client/models/Message.ts#L35)

```ts
// paramsが重要
return new Function(fn)().apply(window, params);
```

## サーバー側のコードもES5相当にトランスパイルする
ES5相当にトランスパイルする事で、古いブラウザでもasync/awaitを気軽に書いて送ることができるようになりました。
[ここらへん](https://github.com/naoki-tomita/websocket-webdriver/blob/master/tsconfig.json#L3)

## 非同期処理もPromiseっぽいインターフェースを用意して実行できるようにした
同期処理だけだとスクリーンショットが撮れなかったので、非同期処理も実行できるよう、Promiseっぽいインターフェースを実装しました。
[ここ](https://github.com/naoki-tomita/websocket-webdriver/blob/master/src/scripts/client/models/Message.ts#L43)と[ここ](https://github.com/naoki-tomita/websocket-webdriver/blob/master/src/scripts/server/FunctionEvaluator.ts#L13)

```ts
type AsyncResolver = (result?: any) => void;
async function evaluateAsync(
  fn: (result: AsyncResolver, ...params: any[]) => any, 
  ...params: any[],
) {
  return send({
    asyncFunction: `return (${fn.toString()})`,
    params: params,
  });
}
```

## html2canvasはウェブアプリ側のスクリプトに組み込んだ
でかいライブラリはとりあえずウェブアプリ側に組み込んでみています。
mode=devでwebpackすると、生成されるJSが1MB近くになってしまいましたが、socket.ioで送るよりはマシかなと思っています。
エレメントの指定をもっと柔軟にしたくなったらjQueryとかを入れてもいいかなと思っています。
[ここらへん](https://github.com/naoki-tomita/websocket-webdriver/blob/master/src/scripts/client/utils/MonkeyPatch.ts#L1)

## ウェブアプリ側はpolyfillも追加した
送るスクリプトをモダンな書き方でできるようにpolyfillも追加しています。
これによりPromiseが利用できるようになっています。
ほかは今は使ってないから、Promiseのみに絞るのもアリかもしれません。
[ここらへん](https://github.com/naoki-tomita/websocket-webdriver/blob/master/webpack.config.ts#L6)

## ページ遷移やリロードでも自動で再接続するようにした
ページ遷移やリロードを行うと、socket.ioのコネクションは切断されます。
これをサーバー側でsocket.ioの接続イベントや切断イベントをハンドリングして、自動で再接続するようにしています。
また、ウェブアプリとサーバーでそれぞれセッションIDを生成し共有することで誤って別のブラウザと接続するのを防いでいます。
ただ、実装は、だいぶアヤシイ。
[ここらへん](https://github.com/naoki-tomita/websocket-webdriver/blob/master/src/scripts/server/WebSocket.ts#L38)

# できていないこと
## エラーにする
ウェブアプリ側で実行したスクリプトがエラーを投げても無視しています。
エラーにしてテストコードを落とすのが望ましいでしょう。

## エレメントが重なっているとき、下のエレメントもクリックできてしまう
たとえばposition: absoluteで最前面をダイアログが覆っていても、下のエレメントはクリックできてしまいます。
これは、エレメントに対してクリックイベントを発火させているだけのためです。
Seleniumでは、エレメントの順序を考慮してクリックできたかをチェックしています。
どうやってやるかわかりませんが、チェックするべきでしょう。
エレメントの座標を割り出して、documentに対してクリックイベントを発火させるとできるのでしょうか。

## クリックするとき、スクロールしない
Seleniumでは、エレメントをクリックするときはそのエレメントが表示されているところまでスクロールします。
スクロールするべきでしょう。

以上です。よろしくお願いします。