Selenium revised #2

以前書いた通り、wasavi をテストする Selenium の構成は

  • Selenium server
  • Selenium client (java)
  • junit
  • chromedriver
  • operadriver

といった感じで、それぞれの jar あるいは exe を必要とする。テストを java で書くのは wasavi のビルドを ant に頼っていて、また ant から junit のテストを呼び出すのが簡単だからだ。

chrome
chrome の場合は chromedriver.exe というものが chrome と Selenium の間に立つブリッジになる。これを最新のもので更新したところ、いきなり動かなくなった。というのは、テスト用に立ち上げた chrome に wasavi が読み込まれないのである。この場合の wasavi は、開発者モードとして読み込まれる必要がある。これはつまりエクステンションのアーカイブではなく、ファイルシステム上のソースをそのまま参照してねモードだ。このモードを指定するには、chrome の起動時のコマンドラインオプションで [cci]–load-extension=C:/path/to/extension[/cci] てな感じのを追加する。

で、それがどうやら効いていないようなのだ。テスト用に立ち上げた chrome で chrome://version を開いてコマンドラインを参照してみると、wasavi 以外に selenium とのやりとりのためのエクステンションが指定されている。どうやらこれと競合しているらしい。–load-extension って複数指定できないんだっけ。

というわけで、テスト用 chrome に対してプロファイルディレクトリを明示して指定するようにして([cci]–user-data-dir=C:/path/to/profile[/cci])、そのプロファイルに対して予め開発者モードの wasavi を登録しておくようにした。

その他は特に問題なし。

opera
opera の場合、operadriver.jar が opera と Selenium の間を取り持つ。operadriver.jar は Selenium client に付属しているので、基本的には別途取得する必要はない、のだけど。たとえば Selenium 2.35.0 に付属の operadriver-1.4.jar だと、うまく opera 12.16 が起動してくれない。数日前にリリースされた operadriver-1.5.jar を代わりに使う必要がある。

このように、Selenium とブラウザと付随するライブラリのそれぞれのバージョンによって動いたり動かなかったり、一部の機能が動かなかったりすることがけっこう多い。これは困ったものです。特に最近のブラウザはえらい勢いでバージョンが上がるので、なおのことその危険性は高い。

ブラウザのオートメーションというものは、このようになんだかずいぶん脆弱な仕組みの上にかろうじて成り立っているように思える。この辺りの動作こそブラウザベンダ同士が協働して仕様やプロトコルをきっちり決めたらいいんじゃなかろうか。

ちなみに opera の場合もプロファイルを明示して、そのプロファイルに対して開発者モードの wasavi を登録しておく必要がある。

firefox
firefox については特別な jar ファイルとかは必要ないのだけど、その代わりなんか動作が変だ。たとえば shift キーとかのモディファイアを指定したキーストロークを送出することができない。wasavi は起動するためにデフォルトで ctrl+enter のショートカットを使うので、これはなかなか致命傷なのである。

あと、全体的に遅い。chrome で全テストを終えるのにだいたい 1 時間かかるのだけど、firefox で行うとだいたい 1.5 倍くらいの時間がかかる。

また、firefox にはエクステンションを開発者モードで読み込む的な機能がない(知らないだけであるのかもしれないが)ので、テスト前に一旦アーカイブをビルドする必要があるのが地味に面倒くさい。

 * * *

というわけで一応 3 ブラウザでテストを行えるようにはなりつつあるのだけど、実行時間が問題だ。先に chrome でさえテストに 1 時間かかると書いたが、これはすべてのテストをシーケンシャルに行っているからだ。テスト自体は編集のテストとか、ex コマンドのテストとか機能毎に独立したソースになっているので、すべてを並列にとは言わないが、せめて同時に 3 枚くらいウィンドウ出して並行して行えばスピードアップできるのである。そんな機能ないのかなー。

また特に firefox の場合、テスト用に立ち上がる firefox が必ずフォアグラウンドな状態じゃないとテストが失敗するという意味の分からない動作をする。つまりテストの間 pc はそれに専念する専用マシンと化してしまうのだ。

なに、それ。テスト専用 pc を複数用意しろというのか。

Selenium revised

Firefox 版の wasavi を AMO にフルレビューしてくれと提出してもう 2 ヶ月くらい経つのだが、キャンセルした。というのはあまりにレビューが遅いからではなくて、なんとびっくりすることに Firefox 版の 0.5.329 では http://wasavi.appsweets.net/ を開いた時に自動的に wasavi が起動する機能がバグってて動かなかったのだ。

これはかなり恥ずかしい。wasavi をリリースする前準備として、Selenium を通した機能テストを行っている。666 項目のテストをパスしてからリリースするのである。といっても、実を言うと、そのテストは Chrome だけでしか通していないのであった。Opera と Firefox は通していない。

これは、wasavi の仕組みのせいなのか、Selenium の制限なのか、Selenium と Opera/Firefox をつなげるブリッジ部分のドライバの制限なのかよく分からないが、とにかく Opera と Firefox で動かすと wasavi をそもそも自動起動させられなかったり、キー入力が無視されたりしてテストにならなかったのだ。Chrome だけが素直にテストが通る。

しかしそういうわけにもいかない。3 ブラウザで完全にテストを通すようにしないといけない。そういうわけで Selenium のテストを見直すことにしよう。

watch a test

ググればたくさん情報は得られるが、とりあえずメモ。

junit でテストを書く際、今何のテストをしているのか……などのログを吐きたいことがある。例えば wasavi のテストで、何らかのミスがあって、WebDriverWait がタイムアウトするとする。タイムアウト自体は catch 節で受け取るので、そこで単に System.out.println(“timed out!”); すれば ant が保存するログに含まれる。が、どのテストで発生したかまではわからない。

そこで、テストケースで

@Test public void foo () {
System.out.println(Thread.currentThread().getStackTrace()[1].getMethodName());
}

などと書けばログにテスト中のメソッド名が書かれるので判別する材料になる。しかしこれはすべてのテストケースに埋め込まなければならないので、とても煩雑だ。

さて junit4 には TestWatcher というものが用意されていて、テスト開始・終了、あるいは成功時・失敗時に任意の処理をはさむことができる。開始・終了というのは @Before と @After と被っているが、これらはあくまでテスト視点での開始・終了なのに対して、TestWatcher のそれはテストランナーから見た、「テストメソッド」の開始・終了であり、テストメソッドの素性などのメタな情報を利用することができる。


import org.junit.*;
import org.junit.rules.TestRule;
import org.junit.rules.TestWatcher;
import org.junit.runner.Description;

public class WasaviTest {
protected String logText;

@Rule public TestRule watcher = new TestWatcher() {
protected void starting (Description d) {
System.out.println("Testcase: " + d.getMethodName());
}
protected void failed (Throwable e, Description d) {
System.out.println(d.getMethodName() + " FAILED\n" + logText);
}
};

@After
public void tearDown () {
logText = driver.findElement(By.id("test-log")).getAttribute("value");
}
}

こんな感じで自前のテストの基底クラスに仕込んでおけば、これを継承した個々のテストでは何も考えることなく勝手にテストケースの名前が出力される。

くわしくは javadoc を見れば全部書いてある。

asynchronous madness

Selenium でテストすると keypress イベントが発生したりしなかったりする件。

原因は wasavi 側にあった。G とはつまり指定の行へカーソルを移動させる vi コマンドだ。このとき、スクロールが発生するのだが(実際のビューのスクロール量が 0 であっても)、[cci]:set smooth[/cci] の状態だと、スクロールは非同期に行われる。すっかり忘れていた。スクロールが完了するまで、keydown 時に preventDefault() される。つまり、keypress は発生しない。

そして、G に後続するテスト用コマンドはまさにスクロールが完了するまでの狭間のタイミングに送信されていたのであった。こりゃーちゃんとテストできるわけないよ。

というわけで、G を送信した後はコマンドの実行が完了するまで待つことにして解決。

で、とりあえず、jsunit 版の基本編集のテスト editing.js の 51 テストを移植したのだけど。javascript のコードを java に移すというのは、メロンパンの中をくりぬいてメロンを詰め込むようななんともいえない気持ちになりますな。

to open a hole #5

keypress が正しく生成されない件。

Wasavi.send("1G1|y3lp");

といったコードでテストすると、”G” 以降のストロークについて keydown イベントは発生するものの、keypress が一切発生しなくなる。chromedriver_win_23.0.1240.0 で確認した。

これを、

Wasavi.send("gg1|y3lp");

にすると、期待通りのイベントが発生する。となるとやはり “G” が悪いのか。”G” に付随して、shift キーのプレスとリリースもエミュレートされるのだが、この辺が怪しい……? しかし興味深いことに、”|” も shift キー押下イベントが生成されるのだが、それによって keypress が脱落することはない。そうすると関係ないか。

issues に似たバグがないこともないのだけど、解決している様子はない。

実際にストロークを送信する部分は

private void sendStrokes (CharSequence[] strokes) {
Actions act = new Actions(driver);
for (CharSequence s: strokes) {
act.sendKeys(s);
}
act.perform();
}

という感じ。もしかしてこの辺に問題があるのだろうか。もしかしたらまとめてじゃなく細切れで perform() すれば sendKeys() の内部状態(があるとして)がそのたび初期化されてうまく行くかも:

for (CharSequence s: strokes) {
for (int i = 0, goal = s.length(); i < goal; i++) { Actions act = new Actions(driver); act.sendKeys(s.subSequence(i, i + 1)); act.perform(); } }

結果: 変わらず。Selenium 内の問題ではなく chromedriver.exe の中の問題なのか?

ちなみに Opera や Firefox ではどうなのかというと、Opera では textarea に対する sendKeys() 自体が変。"hello!" とかは自動入力できるのだが、Keys.CONTROL とか Keys.INSERT とかは無視される。つまり wasavi を起動させられない。Firefox は textarea に対しては sendKeys() は上手く動いて、wasavi を起動させられるのだが、起動した wasavi の iframe に対して sendKeys() が行われない。つまり wasavi を操作できない。

というわけで、結局まともにブラウザを操作できるのが、(うちの環境では)Chrome だけなのだ。うーん動かし方が悪いのか……?

とにかく Chrome が最後の砦なので、何とか正確にキーストロークを生成してもらわないと困る。

to open a hole #4

Selenium からブラウザに対して任意の javascript を実行させるのに、executeScript() が用意されている。このメソッドは、引数で指定された javascritpt コード をブラウザ上で匿名関数の中身として実行する。

javascript コードが、”return なんちゃら;” であった場合はどうなるのか? その点も抜かりはない、ということになっている。
http://selenium.googlecode.com/svn/trunk/docs/api/java/org/openqa/selenium/JavascriptExecutor.html
返された何者かの型によって、よきに計らってくれる。

ところでブラウザ上で実行される javascript が返す何かが JSON を文字列化した文字列であった場合。なんか一切文書化されていないが(ChromeDriver 特有の動作なのかもしれない)、自動的に Map ……というか google collections library の TransformedEntiresMap に変換されるような感じがする。

これはなかなかに余計なお世話なのではないか。wasavi の状態を返すのに、それを JSON 化した文字列を返している。文字列を返したのなら、java 側でも文字列として現れてほしい。Selenium 側ではやはりそれを JSON オブジェクト化した上で扱うつもりでコードを書いたら、まったくまるで動かないので悩んでしまったぞ。

しかたがないので、ブラウザ側の javascript を経由するのではなく直接 wasavi を保持する iframe の 属性を java 側から読むようにした。直接読むと、:quit 近辺のテストあたりが影響を受ける(状態を取得しようとした時点ですでに wasavi は消滅している)のでなかなか困るのだが。

to open a hole #3

Selenum は、実際に人がブラウジングする過程をなぞるのでブラウザの実動作を対象にテストできるというのがウリなのだと思うけど、ちょっと不思議な動作がある。

起動した wasavi に対してキーストロークを送る。

public void testPasteCharsForward () {
Wasavi.send("i", "foobar\nfoobar", Keys.ESCAPE);

Wasavi.send("1G1|y3lp", Keys.ESCAPE);
assertEquals("ffoooobar\nfoobar", Wasavi.getValue());
}

これを Chrome に対して動かす。Chrome 版 wasavi ではキー入力イベントは keydown と keypress 両方処理している。文字を示すキーは keypress で、機能を示すキーは keydown で扱う。

ところで実際に実行してみると 2 つめの Wasavi.send() で

keydown 27 (U+001B)
keydown 80 P (U+0050)
keydown 76 L (U+004C)
keydown 51 3 (U+0033)
keydown 89 Y (U+0059)
keydown 220 (U+00DC)
keydown 16 (Shift)
keydown 49 1 (U+0031)
keypress 71 G (U+0000)
keydown 71 G (U+0047)
keydown 16 (Shift)
keypress 49 1 (U+0000)
keydown 49 1 (U+0031)

こんな感じのキー入力がなされる(行はイベント種別、キーコード、対応する文字(あれば)、keyIdentifier の値。いちばん上が最新)。これ、変だ。”G” の入力以降、文字を示すキーに対応する keypress が発生していない。したがってテストも失敗する。

これは一体?

to open a hole #2

結局昨日の記事とはぜんぜん違う方法になりつつある。

まず wasavi 側は、コマンドが終了するたびにエージェント(wasavi が寄生する textarea 要素が属する文書に対して挿入される content script)にそれを通知する。通知の際に wasavi の状態も併せて送りつける。

エージェントでは、文書上の wasavi の実体である iframe の適当な属性に、wasavi の状態を json 文字列化した上で格納する。

テストページでは、呼び出されるたび iframe に格納された wasavi の状態を単に返す関数 getWasaviState() を定義する。

Selenium 側では、wasavi へキーストロークを送信した直後、コマンドが終了するのを待ち、終了したら getWasaviSatate() を呼び出し、結果を取り込む。

という流れ。

 * * *

全然関係ないけど Thinkpad X1 carbon が発売開始された。いいなー欲しいなー誰か買ってくれないかな。

to open a hole

wasavi.js の動作テストを行う際、起動中の wasavi の状態を外から取得する必要がある。

そのために、wasavi はグローバルオブジェクトに対して Wasavi オブジェクトを公開している。それには Wasavi.value とか Wasavi.state、あるいは Wasavi.registers() といったものが用意されていて、外から状態を得ることができる。

これが可能なのは、jsUnit でテストするときは wasavi.js をページに属するスクリプトとして扱っているからだ。つまりそのコンテキストでのグローバルオブジェクトは window であって、外から見るのに何の制限もない。

さてテストの方法を Selenium を利用したものに移行しようとしているのだが、こちらの場合は実際にエクステンションとして動作する wasavi に対してテストを行う。この状態では、上記の Wasavi オブジェクトは外から見えないし、また見えては困る。しかしテストを行うためにこれに例外を設ける必要がある。

テストページとして、http://wasavi.appsweets.net/test_frame.html を用意している。Selenium はこのページを自動的にブラウザに開かせ、すべてのテストを行う。Selenium、このページ上のスクリプト、wasavi の 3 つの要素が連携して、制限されたパスを通して wasavi の状態を Selenium へ伝達したい。

まず Selenium 側。Selenium 側はつまり java である。Selenium の api に executeScript() というものがあるので、これを利用する。このメソッドはブラウザに対して任意の javascript を同期的に実行させ、値を返す。スクリプトはブラウザ上では匿名関数の中身として実行される。

JavascriptExecutor js = (JavascriptExecutor)driver;
Object result = js.executeScript("return getWasaviProperty('value');");

次にテストページ上のスクリプト。ここから起動中の wasavi へ何かしらの操作をし、何かしらの状態を取得することになる。wasavi の実態は iframe であり、テストページと同じドメインに存在する。しかし、いずれのブラウザでも wasavi.js は isolated world に存在しているので、単純に iframe#contentWindow を覗けばいいというふうにはいかない。

そこで、テストページから wasavi へ postMessage() することにする。また、wasavi 側からの返答を受けるために message イベントハンドラを登録しておく。

var buffer;
function getWasaviProperty (name) {
$('wasavi_frame').postMessage(name, 'http://wasavi.appsweets.net');
return buffer;
}
window.addEventListener('message', function (e) {
buffer = e.data;
}, false);

最後に wasavi 側。テストページからのメッセージを受け、値を返す。

window.addEventListener('message', function (e) {
e.source.postMessage(Wasavi[e.data], e.origin);
}, false);

なお上記コードはエラー処理とか省いている。特に origin の判定とかは実使用では絶対に必要なので注意。

というわけでとりあえず Chrome で動かしてみたのだけど、前にも書いたような気がするが、どうもやはり、ページ上に属する javascript と content script の間では cross messaging すらできないような。具体的には、wasavi 側でメッセージを受けるところまではいくのだけど、MessageEvent#source が undefined なのだ。

困ったー。

 * * *

iframe#contentWindow に対してではなく、MessageChannel に対してなら postMessage できるようだ。どうしてそういう動作をするのかはよく分からない。

brushing up the tests #3: ChromeDriver instantiation

ということでテストを書き始める。正確に言うとテストそのものは jsUnit でやってたものを変換するだけなので、土台を書く。

chrome へのオプションを指定しつつ ChromeDriver を生成するのに、

import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.remote.DesiredCapabilities;
:
DesiredCapabilities cap = DesiredCapabilities.chrome();
cap.setCapabikity("chrome.switches", Arrays.asList("--load-extension=/path/to/extension"));
driver = new ChromeDriver(cap);

と書いたのだが、ChromeDriver を上記のようなコンストラクタで生成するのは deprecated だよ、と怒られる。
これは

import org.openqa.selenium.chrome.ChromeDriverService;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.chrome.ChromeDriver;
:
ChromeDriverService service = ChromeDriverService.createDefaultService();
ChromeOptions options = new ChromeOptions();
options.addArguments("--load-extension=/path/to/extension");
driver = new ChromeDriver(service, options);

と書くのがナウいらしい。
参考: https://groups.google.com/forum/?fromgroups=#!topic/selenium-users/-shBLr8WTq0
ありがとうありがとう。