macaca-wd
Version:
Macaca webdirver API for Node.js
337 lines (294 loc) • 11 kB
JavaScript
;
const __slice = Array.prototype.slice;
const Q = require('q');
const _ = require('./lodash');
const EventEmitter = require('events').EventEmitter;
const slice = Array.prototype.slice.call.bind(Array.prototype.slice);
const utils = require('./utils');
// The method below returns no result, so we are able hijack the result to
// preserve the element scope.
// This alows for thing like: field.click().clear().input('hello').getValue()
const elementChainableMethods = [
'clear',
'click',
'doubleClick',
'doubleclick',
'flick',
'tap',
'sendKeys',
'submit',
'type',
'keys',
'moveTo',
'sleep',
'noop',
];
// gets the list of methods to be promisified.
function filterPromisedMethods(Obj) {
return _(Obj).functions().filter(function(fname) {
return !fname.match('^newElement$|^toJSON$|^toString$|^_') &&
!EventEmitter.prototype[fname];
})
.value();
}
module.exports = function(WebDriver, Element, chainable) {
// wraps element + browser call in an enriched promise.
// This is the same as in the first promise version, but enrichment +
// event logging were added.
function wrap(fn, fname) {
return function() {
const _this = this;
let callback;
const args = slice(arguments);
const deferred = Q.defer();
deferred.promise.then(function() {
_this.emit('promise', _this, fname, args, 'finished');
});
// Remove any undefined values from the end of the arguments array
// as these interfere with our callback detection below
for (let i = args.length - 1; i >= 0 && args[i] === undefined; i--) {
args.pop();
}
// If the last argument is a function assume that it's a callback
// (Based on the API as of 2012/12/1 this assumption is always correct)
if (typeof args[args.length - 1] === 'function') {
// Remove to replace it with our callback and then call it
// appropriately when the promise is resolved or rejected
callback = args.pop();
deferred.promise.then(function(value) {
callback(null, value);
}, function(error) {
callback(error);
});
}
args.push(deferred.makeNodeResolver());
_this.emit('promise', _this, fname, args, 'calling');
fn.apply(this, args);
if (chainable) {
return this._enrich(deferred.promise);
}
return deferred.promise;
};
}
// Element replacement.
const PromiseElement = function() {
const args = arguments.length >= 1 ? __slice.call(arguments, 0) : [];
return Element.apply(this, args);
};
PromiseElement.prototype = Object.create(Element.prototype);
PromiseElement.prototype.isPromised = true;
// WebDriver replacement.
const PromiseWebdriver = function() {
const args = arguments.length >= 1 ? __slice.call(arguments, 0) : [];
return WebDriver.apply(this, args);
};
PromiseWebdriver.prototype = Object.create(WebDriver.prototype);
PromiseWebdriver.prototype.isPromised = true;
PromiseWebdriver.prototype.defaultChainingScope = 'browser';
PromiseWebdriver.prototype.getDefaultChainingScope = function() {
return this.defaultChainingScope;
};
// wrapping browser methods with promises.
_(filterPromisedMethods(WebDriver.prototype)).each(function(fname) {
PromiseWebdriver.prototype[fname] = wrap(WebDriver.prototype[fname], fname);
}).value();
// wrapping element methods with promises.
_(filterPromisedMethods(Element.prototype)).each(function(fname) {
PromiseElement.prototype[fname] = wrap(Element.prototype[fname], fname);
}).value();
PromiseWebdriver.prototype.newElement = function(jsonWireElement) {
return new PromiseElement(jsonWireElement, this);
};
// enriches a promise with the browser + element methods.
PromiseWebdriver.prototype._enrich = function(obj, currentEl) {
const _this = this;
// There are cases were enrich may be called on non-promise objects.
// It is easier and safer to check within the method.
if (utils.isPromise(obj) && !obj.__wd_promise_enriched) {
const promise = obj;
// __wd_promise_enriched is there to avoid enriching twice.
promise.__wd_promise_enriched = true;
// making sure all the sub-promises are also enriched.
_(promise).functions().each(function(fname) {
const _orig = promise[fname];
promise[fname] = function() {
return this._enrich(
_orig.apply(this, __slice.call(arguments, 0)), currentEl);
};
})
.value();
// we get the list of methods dynamically.
const promisedMethods = filterPromisedMethods(Object.getPrototypeOf(_this));
_this.sampleElement = _this.sampleElement || _this.newElement(1);
const elementPromisedMethods = filterPromisedMethods(Object.getPrototypeOf(_this.sampleElement));
const allPromisedMethods = _.union(promisedMethods, elementPromisedMethods);
// adding browser + element methods to the current promise.
_(allPromisedMethods).each(function(fname) {
promise[fname] = function() {
let args = __slice.call(arguments, 0);
// This is a hint to figure out if we need to call a browser method or
// an element method.
// "<" --> browser method
// ">" --> element method
let scopeHint;
if (args && args[0] && typeof args[0] === 'string' && args[0].match(/^[<>]$/)) {
scopeHint = args[0];
args = _.rest(args);
}
return this.then(function(res) {
let el;
// if the result is an element it has priority
if (Element && res instanceof Element) {
el = res;
}
// if we are within an element
el = el || currentEl;
// testing the water for the next call scope
let isBrowserMethod =
_.indexOf(promisedMethods, fname) >= 0;
let isElementMethod =
el && _.indexOf(elementPromisedMethods, fname) >= 0;
if (!isBrowserMethod && !isElementMethod) {
// doesn't look good
throw new Error('Invalid method ' + fname);
}
if (isBrowserMethod && isElementMethod) {
// we need to resolve the conflict.
if (scopeHint === '<') {
isElementMethod = false;
} else if (scopeHint === '>') {
isBrowserMethod = false;
} else if (fname.match(/element/) || (Element && args[0] instanceof Element)) {
// method with element locators are browser scoped by default.
if (_this.defaultChainingScope === 'element') { isBrowserMethod = false; } else { isElementMethod = false; } // default
} else if (Element && args[0] instanceof Element) {
// When an element is passed, we are in the global scope.
isElementMethod = false;
} else {
// otherwise we stay in the element scope to allow sequential calls
isBrowserMethod = false;
}
}
if (isElementMethod) {
// element method case.
return el[fname].apply(el, args).then(function(res) {
if (_.indexOf(elementChainableMethods, fname) >= 0) {
// method like click, where no result is expected, we return
// the element to make it chainable
return el;
}
return res; // we have no choice but loosing the scope
});
}
// browser case.
return _this[fname].apply(_this, args);
});
};
}).value();
// transfering _enrich
promise._enrich = function(target) {
return _this._enrich(target, currentEl);
};
// gets the element at index (starting at 0)
promise.at = function(i) {
return _this._enrich(promise.then(function(vals) {
return vals[i];
}), currentEl);
};
// gets the element at index (starting at 0)
promise.last = function() {
return promise.then(function(vals) {
return vals[vals.length - 1];
});
};
// gets nth element (starting at 1)
promise.nth = function(i) {
return promise.at(i - 1);
};
// gets the first element
promise.first = function() {
return promise.nth(1);
};
// gets the first element
promise.second = function() {
return promise.nth(2);
};
// gets the first element
promise.third = function() {
return promise.nth(3);
};
// print error
promise.printError = function(prepend) {
prepend = prepend || '';
return _this._enrich(promise.catch(function(err) {
console.log(prepend + err);
throw err;
}), currentEl);
};
// print
promise.print = function(prepend) {
prepend = prepend || '';
return _this._enrich(promise.then(function(val) {
console.log(prepend + val);
}), currentEl);
};
}
return obj;
};
/**
* Starts the chain (promised driver only)
* browser.chain()
* element.chain()
*/
PromiseWebdriver.prototype.chain = PromiseWebdriver.prototype.noop;
PromiseElement.prototype.chain = PromiseElement.prototype.noop;
/**
* Resolves the promise (promised driver only)
* browser.resolve(promise)
* element.resolve(promise)
* @param promise
*/
PromiseWebdriver.prototype.resolve = function(promise) {
const qPromise = new Q(promise);
this._enrich(qPromise);
return qPromise;
};
PromiseElement.prototype.resolve = function(promise) {
const qPromise = new Q(promise);
this._enrich(qPromise);
return qPromise;
};
// used to by chai-as-promised and custom methods
PromiseElement.prototype._enrich = function(target) {
if (chainable) { return this.browser._enrich(target, this); }
};
// used to wrap custom methods
PromiseWebdriver._wrapAsync = wrap;
// helper to allow easier promise debugging.
PromiseWebdriver.prototype._debugPromise = function() {
this.on('promise', function(context, method, args, status) {
args = _.clone(args);
if (context instanceof PromiseWebdriver) {
context = '';
} else {
context = ' [element ' + context.value + ']';
}
if (typeof _.last(args) === 'function') {
args.pop();
}
args = ' ( ' + _(args).map(function(arg) {
if (arg instanceof Element) {
return arg.toString();
} else if (typeof arg === 'object') {
return JSON.stringify(arg);
}
return arg;
}).join(', ') + ' )';
console.log(' --> ' + status + context + ' ' + method + args);
});
};
return {
PromiseWebdriver,
PromiseElement,
};
};