「rust-headless-chrome」で、Google Chatへの通知機能付きログインテストツールを作ってみた

## はじめに
筆者には趣味で少しRustをかじった程度の知識・技術しかありません。そのため、下記に紹介するソースコードにはアンチパターンや問題のあるロジックが含まれている可能性があります。もし修正すべき箇所を見つけた場合は、ご指摘頂けますと幸いです。
## 作ったもの
　指定したディレクトリに下記のような形式のTOMLファイルを設置すると、そのWEBサイトにアクセステストを行うツールです。
```
access_url = "https://www.sample.com/"
find_selector = "div#target_selector"
[cookie]
name = "auth"
value = "xxxxxxxxxxxxxxx"
```
ログインが必要なWEBサイトの動作検証の場合はcookieを設定する仕様にしております。理由としては、監視したいWEBサービスの中に「認証情報をiframe内のフォームに記入する」ものがあったのですが、「rust-headless-chrome」を使用してiframe内の要素を取得する方法がどうしても分からなかったためです。（ご存じの方がいらっしゃたら、ご指摘頂けますと幸いです。）

そして、期待通りにアクセス出来ない場合はGoogle ChatのWebhookを利用してBotに通知させる仕組みにしております。

下記Gif画像は、開発したテストツールのヘッドレスモードを解除して実行した際の挙動になります。実際にアクセステストを行う際には、ヘッド「フル」なブラウザを立ち上げる必要は無いので、もう少し処理が速くなりそうです。
<img width="500" alt="38df5892d3de93dfb89a2496c67cf112_AdobeExpress.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1066161/484f9edb-ffa5-91cb-3645-0603afc6d2fb.gif">

また、下記リポジトリにて開発物を公開しております。もし使用される場合は、設定用のファイルを追加する必要があります。

https://github.com/yano-kentaro/chrome-access-test
## 開発動機
筆者の勤務先では、自社サービスや過去に制作したWEBサイトへ正常にアクセス出来ることを、とあるSaaSのブラウザテストで検証しておりました。しかし、全てのサイトを5分間隔で監視するようなブラウザテストを実装していたところ、気が付いたらかなりの額を請求されるようになってしまったため、何か代替措置は無いものかと探していた具合になります。

当初、SeleniumとPythonでアクセステストを行うDockerコンテナを、AWS LambdaやEventBridgeで定期実行する実装方法を考え、開発しておりました。しかし、ほぼ完成した段階で「たかがアクセステストなのに、こんなに大掛かりな装置が必要なものだろうか？」と冷静になったのと、「非技術者でも監視対象のサービスを増やせるような仕組みで実装出来ないものか」と考えるようになり、今回の構成に至りました。
## 「rust-headless-chrome」とは
https://github.com/atroche/rust-headless-chrome

上記リポジトリにはこのように書かれています。
>A high-level API to control headless Chrome or Chromium over the DevTools Protocol. It is the Rust equivalent of Puppeteer, a Node library maintained by the Chrome DevTools team.
It is not 100% feature compatible with Puppeteer, but there's enough here to satisfy most browser testing / web crawling use cases, and there are several 'advanced' features such as:
> - network request interception
> - JavaScript coverage monitoring
> - Opening incognito windows
> - taking screenshots of elements or the entire page
> - saving pages to PDF
> - 'headful' browsing
> - automatic downloading of 'known good' Chromium binaries for Linux / Mac / Windows
> - extension pre-loading

> （DeepL訳）
DevToolsプロトコル上でヘッドレスChromeやChromiumを制御するための高水準APIです。Chrome DevToolsチームによってメンテナンスされているNodeライブラリであるPuppeteerのRust版である。
Puppeteerと100%の機能互換性はありませんが、ほとんどのブラウザテスト/ウェブクローリングのユースケースを満たすのに十分であり、以下のようないくつかの「高度」な機能があります。
> - ネットワークリクエストの傍受
> - JavaScriptカバレッジの監視
> - シークレットウィンドウを開く
> - 要素やページ全体のスクリーンショットを撮る
> - ページのPDF保存
> - ヘッド「フル」ブラウジング
> - Linux / Mac / Windows用の「既知の良い」Chromiumバイナリの自動ダウンロード
> - 拡張機能のプリロード

要するに、Puppeteerに出来ることを大体再現したRustのライブラリのようですね。PuppeteerがSeleniumとはどのように違うのか、どのような棲み分けがあるのかは下記サイトの解説が非常に分かりやすかったです。

https://gihyo.jp/article/2022/09/rapid-learning-puppeteer-01

私にとって一番重要だったのは下記の箇所でした。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1066161/d601eb80-5ffd-5f90-0f4b-a9de62e64c3a.png)

要するに、SeleniumはWebDriverを使用することで、同一のスクリプトで様々なブラウザを操作可能なのに対し、PuppeteerはChromeに特化することでシンプルな実装を実現出来るという利点があるようです。
### 「Puppeteer」ではなく「rust-headless-chrome」を採用した理由
純粋に、「実用的なプログラムを一度Rustで書いてみたかった」というのが動機でした。また、一度Rustのプログラムのバイナリさえ作ってしまって、あとは設定ファイルを更新するだけで誰でも使えるようにしておけば、「今後監視対象のWEBサービスを増やしたい時に自分が保守せずとも対応出来るので、なかなか良いアイディアなのでは？」と一人で浮かれてました。
## ソースコードの解説
特に需要があるか分かりませんが、Rust素人が2日ハマりながら書いたソースを晒していきます。

```rust
extern crate serde_derive;
extern crate toml;

use anyhow::Result;
use curl::easy::{Easy, List};
use headless_chrome::{protocol::cdp::Network::CookieParam, Browser, LaunchOptions};
use serde_derive::Deserialize;
use std::fs;

#[derive(Deserialize)]
struct AccessConf {
    #[serde(default)]
    access_url: String,
    #[serde(default)]
    find_selector: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde(default)]
    cookie: Option<CookieParam>,
}

#[derive(Deserialize)]
struct GoogleChatConf {
    webhook_url: String,
    alert_message: String,
}

fn main() {
    let paths = fs::read_dir("./conf/service").unwrap();
    for path in paths { // ①
        let path = path.unwrap().path();
        let path_str = path.to_str().unwrap();
        let conf = parse_toml(path_str); // ②
        access_test(&conf).unwrap(); // ③
    }
}

// ②
fn parse_toml(path: &str) -> AccessConf {
    let toml_str = fs::read_to_string(path).unwrap();
    let toml_struct: AccessConf = toml::from_str(&toml_str).unwrap();
    toml_struct
}

// ③
fn access_test(conf: &AccessConf) -> Result<()> {
    let option = LaunchOptions {
        // If you want to see what's going on, comment in the line below.
        // headless: false,
        ..Default::default()
    };
    let browser = Browser::new(option)?;
    let tab = browser.wait_for_initial_tab()?;

    tab.navigate_to(&conf.access_url)?.wait_until_navigated()?;

    // ④
    if let Some(cookie) = &conf.cookie {
        tab.set_cookies(vec![cookie.clone()])?;
        tab.reload(false, None)?.wait_until_navigated()?;
    }

    // ⑤
    match tab.wait_for_element(&conf.find_selector) {
        Ok(_) => (),
        Err(_) => notify_google_chat(&conf.access_url),
    }

    Ok(())
}

// ⑤
fn notify_google_chat(url: &str) {
    let toml_str = fs::read_to_string("./conf/webhook/google_chat.toml").unwrap();
    let conf: GoogleChatConf = toml::from_str(&toml_str).unwrap();

    let mut handle = Easy::new();
    handle.url(&conf.webhook_url).unwrap();
    handle.post(true).unwrap();

    let mut list = List::new();
    list.append("Content-Type: application/json").unwrap();
    handle.http_headers(list).unwrap();

    let message = format!("{{\"text\": \"{} {}\"}}", url, conf.alert_message);
    let data = message.as_bytes();
    handle.post_fields_copy(data).unwrap();

    handle.perform().unwrap();
}
```

やっていることはシンプルで、
1. 「./conf/service」配下に設置されているファイルを全て順番に取得する
1. 構造体AccessConfに設定ファイルの内容をパースする
1. ブラウザやタブを作成し、検証したいWEBサービスのURLへ遷移する
1. ログインが必要な場合は、cookieをセットしてからリロードを行う
1. 検証したい要素が描画されていない場合は、Google Chatへ通知を行う

地味に苦戦したのは下記の3点になります。

### ①iFrame内の要素の取得方法が分からない
issueで「なるべく早くiFrameを扱えるようにしようと思う」というコメントを見つけたものの、その後どのようになっているのかが分かりませんでした。パッと探したところ目ぼしい機能が実装されているようには見えなかったので、iFrameにはなるべく干渉しないようにプログラムを書いていました。

ただ、ログイン検証を行いたいWEBサービスの一つに「何故か、認証情報入力欄がiFrameで実装されている」ものがあったため、ログインが必要な場合はcookieを仕込む方針に変更しました。

### ②「rust-headless-chrome」の使い方を解説している日本語記事がなかなか見当たらない
OSSの「example」やDevelopsersIOの記事以外に、参考になるものがなかなか見当たらない状況でした。

https://dev.classmethod.jp/articles/rust-headless-chrome/

そのため、set_cookiesなどの関数の使い方を調べる際は、「rust-headless-chrome」の自動テストのコードを見るようにしていました。下記のページを見れば、複雑ではない関数ならおおよその使い方が分かるかと思います。

https://github.com/atroche/rust-headless-chrome/blob/master/tests/simple.rs

### ③エラーハンドリングで非同期処理を使えない？
自分のRustへの理解不足でハマっていた点です。当初、アクセステストが失敗した場合の処理は「reqwest」ライブラリで実装する予定だったのですが、「エラーハンドリングの結果は()でなければならない」という類のコンパイルエラーに遭遇し、ここを突破することが出来ませんでした。恐らく、エラーハンドリングを抜けた後に非同期処理の結果が返ってくるような設計は、Rustでは許されていないのだろう…という理解です。

そのため、「reqwest」ではなく「curl」でGoogle ChatのWebhookを叩くように実装したところ、上手く処理を行うことが出来ました。

「curl」ライブラリを使用する際は、下記のような送信内容になるようリクエストを作成すると上手く行きました。
```
curl -X POST -H "Content-Type: application/json" -d "{'text':'hello webhook!'}" "https://chat.googleapis.com/v1/spaces/XXXXXXXXXX/messages?key=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
```
## 終わりに
「それPuppeteerで実装してればすぐ終わったんじゃない？」と思われた方が多いのではないかと思います。私も非常にそう思います。

正直、今のところわざわざ「rust-headless-chrome」を使用するメリットはさほど大きくなさそうに感じております。ただ、Rustで実務の開発を行なっている訳でもなく、純粋にRustで何かを開発する経験値を溜めたかった自分にとっては非常に丁度良い題材でしたので、今後も「それ〇〇で良くない？」と言われようともなるべくRustでプログラムを書いていきたいと思います。

また、もし「rust-headless-chrome」に需要があるようでしたら、非公式の日本語版ドキュメントの執筆などもやってみようかなと思います。