徒然技術日記

Object.prototype.__noSuchMethod__

Nightmare v2.1.0 以降では type() を文字入力のために使ってはいけない

github.com

TL;DR

  • Nightmare は v2.1.0 から type() の挙動が変わったので,文字列入力のために用いてはいけない.
  • 使用する場合は,キーボードイベントの発火が完了したことが保証されないので,適切に wait する必要がある.

詳細

Nightmare の type(selector, text) は文字の入力を行うための基本的な API の一つであり, 公式サイトのサンプルでも一番初めにこのような例が出てきます:

var Nightmare = require('nightmare');
var vo = require('vo');

vo(function* () {
  var nightmare = Nightmare({ show: true });
  var link = yield nightmare
    .goto('http://yahoo.com')
    .type('input[title="Search"]', 'github nightmare')
    .click('.searchsubmit')
    .wait('.ac-21th')
    .evaluate(function () {
      return document.getElementsByClassName('ac-21th')[0].href;
    });
  yield nightmare.end();
  return link;
})(function (err, result) {
  if (err) return console.log(err);
  console.log(result);
});

Search という title 属性がついた入力欄に github nightmare という文字列を入力するため, type 関数を使用しています.

しかし,type() 関数の挙動が,一週間前くらいにリリースされた v2.1.0 から変化しており上記例の通りにテストを書いたところハマってしまったので,注意喚起も込めて書きます.

以前の実装では,要素の value プロパティに与えられたテキストを代入するという実装でした.以下は v2.0.9 時点での type() 関数の実装です.

exports.type = function(selector, text, done) {
  debug('.type() %s into %s', text, selector);
  this.evaluate_now(function (selector, text) {
    var elem = document.querySelector(selector);
    elem.focus();
    elem.value = text;
    elem.blur();
  }, done, selector, text);
};

(https://github.com/segmentio/nightmare/blob/d203ec44deac766d7ea4dd7d1f1713441770f21b/lib/actions.js#L120 より引用)

この実装では keyup などのキーボード関連イベントが発火しないという問題があり,Electron の sendInputEvent を使うようにする pull request により v2.1.0 では以下のように実装が変わりました.

exports.type = function(selector, text, done) {
  debug('.type() %s into %s', text, selector);
  var child = this.child;

  this.evaluate_now(function (selector) {
    document.querySelector(selector).focus();
  }, function() {
    child.once('type', done);
    child.emit('type', text);
  }, selector);
};

(https://github.com/segmentio/nightmare/blob/6afdc411d1d8d979624870caa4e57e2922c2037e/lib/actions.js#L120 より引用)

type イベントが発火されると,Nightmare は Electron に対して sendInputEvent というイベントを発行します.

この sendInputEvent というのがくせもので,こいつは Electron の renderer に対して「イベントを発行せよ」という通信は行うものの,どうやら実際に発火したかまでは待たないようです.すなわち,上記のコードに変化したことによって,type() 関数では文字列が確実に入力されたかどうかが保証されなくなりました

実験

実際,type に長い文字列を渡してみると,入力しきれない場合が出てきます. 挙動説明のために以下のサンプルアプリを作成しました. npm test すると Nightmare を使ってテストを実施します.

github.com

テキストエリアに入力して Submit すると,その内容が上に表示されるというだけの簡単なアプリケーションですが,1000 文字入力しようとするだけで type が追いつかず,テストが失敗してしまうことが分かります.

  1) nightmare can type a long string into a textarea:

      AssertionError:   # test/index.js:21
  
  assert(message === MESSAGE)
         |       |   |       
         |       |   "0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789"
         |       false       
         "01234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890"
  
  --- [string] MESSAGE
  +++ [string] message
  @@ -878,123 +878,4 @@
   7890
  -12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789

環境によってはもっと短い文字(10文字程度)でも発生することがあり,例えばログインページのテストでは ID が最後まで入力できなくてログインが成功したりしなかったりするなど,影響はかなり大きいと言えます.

対処

v2.0.9 以前の element.value = text 方式であれば,同期的にテキストを入れるのでこのようなことは起こりません.

幸い,Nightmare は Nightmare.action() を使うことでカスタムアクションを定義できますので,v2.0.9 方式を再現する以下のようなアクションを追加すれば問題ありません.

Nightmare.action('populate', function(selector, text, done) {
  debug('populate() %s into %s', text, selector);
  this.evaluate_now(function(selector, text) {
    var element = document.querySelector(selector);
    element.value = text;
  }, done, selector, text);
});

また,この件については Issue に登録し,上記アクションを追加する Pull Request も投げておきました.うまく行けば次のバージョンでは populate アクションが追加されるでしょう.

github.com

結論

  • v2.1.0 以降の type() は,キーボードショートカットやインクリメンタルサーチのテストなど,キーイベントを発火したいときに使うべきで,文字入力には適さない.
  • 以前の挙動に戻すカスタムアクションを定義して,そっちを使うべき.
  • 運がよければ次バージョンで文字入力のためのアクションが追加されるかも.