PHP Seleniumでのエラー対応メモ

# やりたいこと
### (1)動的コンテンツを含むページへの遷移完了を待つ
### (2)開いたページ上のボタンクリックでのエラーを解消したい

# 経緯
仕事で、Selenium＋PHPにより、あるページへのログインやフォーム入力、ファイルダウンロードなどを自動化する事になりました。
その中でハマった点を残しておきます。

※以下、urlやhtml要素のidなどは、本投稿上の適当な値です

# 環境
* docker for windows 
  * php（・apache）コンテナと、Seleniumコンテナ

※「Seleniumとは」や環境構築については、他の投稿やサイトをご参照ください。
（また後日、この投稿に追記するかも知れませんが）

# エラーと対応内容
## (1)動的コンテンツを含むページへの遷移完了を待つ
### 概要
　指定したurlを表示したり、ボタンクリックにより次ページへ遷移し、ページ表示完了を待つ場合は、次のような方法がよく紹介されています。
　私も最初はそうしていました。
* 遷移後のタイトルやurlを条件として、待機する方法（titleIs、urlIs）
```php
use Facebook\WebDriver\Chrome\ChromeDriver;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Facebook\WebDriver\WebDriverBy;
use Facebook\WebDriver\WebDriverExpectedCondition;

// selenium
$host = 'http://host.docker.internal:4444/wd/hub';
// 仮想ブラウザ（chrome）起動
$driver = RemoteWebDriver::create($host, DesiredCapabilities::chrome());

// ページを開く
$driver->get('http://this.is.sample.com');
// 最大10秒待つ
// 待機は、WebDriverWait.phpのuntil()を利用。第2引数は例外発生時のメッセージ（オプション引数）。
$driver->wait(10)->until(
    // 遷移後のページのtitle要素のテキストを指定する場合
    WebDriverExpectedCondition::titleIs('サンプル画面'),
    '画面を表示できません。'
);
```
```php
$driver->wait(10)->until(
    // 遷移後のページのurlを指定する場合
    WebDriverExpectedCondition::urlIs('http://this.is.sample.com'),
    '画面を表示できません。'
);
```

### 発生したエラー
　上記の方法で、ページ自体はエラーなく表示（ロード）されます。
　静的なページを扱う場合は問題ありません。

　しかし、今回アクセスするページでは、ページロード後に、Javascriptでコンテンツを動的に配置していました。
　PHP側でその動的コンテンツを利用する場合、上記の方法では、until()を抜けた後、使いたい要素を取得（$driver->findElement()）するところで、例外`NoSuchElementException`が発生する事がありました。

　おそらく、Seleniumによる操作に、仮想ブラウザの表示が追いついていないのが原因と思われます。
　（人がWebブラウザを使う場合、まだ表示されていないボタンをクリックしようとはしないが、Seleniumだとそういう事が起こり得る）

### 今回の対応方法（presenceOfElementLocated()を利用）
* 「遷移後のページで使う要素が、取得できるか」を条件として、待機する
結果的には、そりゃそうだという方法ですが、、
動的に配置される要素を使いたいなら、その要素が現れるのを待てばよいという話でした。
```php
$driver->get('http://this.is.sample.com');

// 例えば、遷移後のページで必要な項目を入力し、最後にボタンをクリックする場合
// 待機の条件に、titleIs()やurlIs()ではなく、presenceOfElementLocated()を使う
$driver->wait(10)->until(
    WebDriverExpectedCondition::presenceOfElementLocated(WebDriverBy::cssSelector('input#go_next_page')),
    '画面を表示できません。'
);
```
この対応で、今回作ったプログラムでは上記例外が発生する事はなくなりました。

## (2)開いたページ上のボタンクリックでのエラーを解消したい
### 実装イメージと、発生したエラー（is not clickable）
findElement()で対象要素を取得後、その要素をクリックするところで下記エラーが発生する事がありました（毎回ではない）。
* 実装イメージ
```php
// 要素を取得
$element = $driver->findElement(WebDriverBy::id('sample'));
// 要素をクリック（大抵はエラーなくクリックできる）
$element->click();
```

* エラー内容
```
[object] (Facebook\\WebDriver\\Exception\\UnknownServerException(code: 0): unknown error: Element <a id="sample" href="#">(sample)</a> is not clickable at point (191, 370). 
```
　Seleniumが起動している仮想ブラウザで、クリックしたいボタンが（DOMには存在するが）表示領域外にある場合に発生するそうです。
　（人がWebブラウザを使う場合、スクロールしないと表示されないボタンをクリックしようとはしないが、Seleniumだとそういう事が起こり得る）

### 調べた対応方法
* クリックする要素までスクロールさせる
```php
// 要素を取得
$element = $driver->findElement(WebDriverBy::id('sample'));
// 対象要素までスクロール（RemoteWebElement.phpのメソッドを利用）
$element->getLocationOnScreenOnceScrolledIntoView();
// 要素をクリック
$element->click();
```
* 上記の方法でもエラー発生頻度があまり変わらなかった為、更に、クリック可能になるまで待機する
```php
use Facebook\WebDriver\WebDriverExpectedCondition;

// 要素を取得
$element = $driver->findElement(WebDriverBy::id('sample'));
// 対象要素までスクロール（RemoteWebElement.phpのメソッドを利用）
$element->getLocationOnScreenOnceScrolledIntoView();

// 対象要素がクリック可能になるまで待つ
$driver->wait(10)->until(
    WebDriverExpectedCondition::elementToBeClickable(WebDriverBy::id('sample'))
);

// 要素をクリック
$element->click();
```

### 今回の対応内容
上記の2つ目の方法でもエラーが発生する事がありました。
その為、「上記2つ目の方法＋例外キャッチ＋リトライ」で対応しました。
```php
$retryCount = 0;
// リトライの最大回数
$retryMaxCount = 3;
while (true) {
    try {
        // 対象要素までスクロール
        $element->getLocationOnScreenOnceScrolledIntoView();

        // 対象要素がクリック可能になるまで待つ
        $driver->wait(10)->until(
            WebDriverExpectedCondition::elementToBeClickable(WebDriverBy::id('sample'))
        );

        $element->click();
        break;
    } catch (\Exception $ex) {
        // UnknownServerExceptionは非推奨となっており、適当な例外の型が分からなかった為、
        // ひとまずExceptionをキャッチするようにした
        $retryCount++;
        if ($retryMaxCount == $retryCount) {
            // リトライオーバー
            throw $ex;
        }
        // リトライ
        sleep(1);
        continue;
    }
}
```
この対応で、今回作ったプログラムでは上記例外が発生する事はなくなりました。

# 参考
* php-webdriverのソース（GitHub）
使えるメソッドやプロパティがないかを探すには、やはりソースを見るのが一番のようです。
（もちろん、他にもQiitaやスタックオーバーフローなどの情報も漁りました）
https://github.com/php-webdriver/php-webdriver