/**********************************************************************
* This file is part of the PhantomFunctionalTest project.
*
* @Created: 01/28/2015
* @Author: Jason Holt Smith <bicarbon8@gmail.com>
* Copyright (c) 2015 Jason Holt Smith. PhantomFunctionalTest is
* distributed under the terms of the GNU General Public License.
*
* PhantomFunctionalTest is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PhantomFunctionalTest is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with PhantomFunctionalTest. If not, see <http://www.gnu.org/licenses/>.
**********************************************************************/
/** @namespace */
var PFT = {};
/**
* property specifying the delay between retries when waiting
* for selectors to be displayed
* @property {number} [PFT.POLLING_INTERVAL=1000] - the number of milliseconds
* between retries used in any polling like {@link PFT.BasePage.waitFor}
*/
PFT.POLLING_INTERVAL = 1000; // 1 second
/**
* property used as a default wait timeout in the 'PFT.tester' module
* @property {number} [PFT.DEFAULT_TIMEOUT=60000] - the number of milliseconds
* used by default for any waiting like {@link PFT.BasePage.waitFor}
*/
PFT.DEFAULT_TIMEOUT = 60000; // 1 minute
/**
* property defining the base directory used with 'PFT.renderPage' and
* 'PFT.BasePage.renderPage' calls
* @property {string} [PFT.IMAGES_DIR='./img/'] - the default directory where
* any images will be written
*/
PFT.IMAGES_DIR = './img/';
/**
* @property {boolean} [PFT.IGNORE_PAGE_ERRORS=false] - by default all page
* errors (script errors on the page) will result in an error and halting of
* current processing. if set to true these will be ignored
*/
PFT.IGNORE_PAGE_ERRORS = false;
/**
* function will create a new page in PhantomJs with the passed in
* viewport dimensions or a default of 1024x800 and using the passed
* in headers. If called within a PFT.tester.test the returned page
* will also be set as the PFT.tester.current.page object
* @param {Object} [viewport={width:1024,height:800}] - an object containing the 'width' and
* 'height' properties to use with the new page. Ex: { width: 1024,
* height: 768 }
* @param {Object[]} [headers] - an array of objects containing a 'name'
* and 'value' property which will be set as headers for all requests
* using the returned page. Ex: [{ name: "Accept-Language", value:
* "en-US" }]
* @param {string} headers[].name - the header key such as "Accept-Language"
* @param {string} headers[].value = the header value such as "en-US"
* @returns {PhantomJs.Webpage.Page} a new page object
*/
PFT.createPage = function(viewport, headers) {
PFT.debug("generating new page...");
var page = null;
page = require('webpage').create();
if(!viewport) {
viewport = { width: 1024, height: 800 };
}
PFT.resizeViewport(page, viewport);
if (headers) {
PFT.addHeaders(page, headers);
}
return page;
};
/**
* function will resize the passed in PhantomJs.Webpage.Page to
* the specified dimensions
* @param {PhantomJs.Webpage.Page} page - the Page object to resize
* @param {Object} dimensions - an Object containing a width and
* height property set to values greater than 0
*/
PFT.resizeViewport = function (page, dimensions) {
PFT.debug("setting viewport to: " + JSON.stringify(dimensions));
if (page && dimensions && dimensions.width && dimensions.height) {
page.viewportSize = dimensions;
}
};
/**
* function will add headers to the passed in 'PhantomJs.Webpage.Page'
* object or overwrite them if they already exist
* @param {PhantomJs.Webpage.Page} page - the Page object to set headers
* on
* @param {Object[]} headers - an array of Objects containing a name and
* value
* @param {string} headers[].name - the header key such as "Accept-Language"
* @param {string} headers[].value = the header value such as "en-US"
*/
PFT.addHeaders = function (page, headers) {
if (page && headers && headers.length > 0) {
headers.forEach(function (header) {
if (header.name && header.value) {
PFT.addHeader(page, header.name, header.value);
}
});
}
};
/**
* function will add a header to the passed in 'PhantomJs.Webpage.Page'
* object or overwrite an existing header with the passed in value
* @param {PhantomJs.Webpage.Page} page - the Page object to set headers
* on
* @param {string} name - the name of the header such as "Accept-Language"
* @param {string} value - the value of the header such as "en-US"
*/
PFT.addHeader = function(page, name, value) {
PFT.debug("setting header of '" + name + "' to: " + value);
var headers = page.customHeaders;
if(!headers) {
headers = {};
}
headers[name] = value;
page.customHeaders = headers;
};
/**
* function will return the value of the specified Cookie if it exists
* @param {string} cookieName - the name of the Cookie to be retrieved
* @returns {string} the value of the specified Cookie or undefined if
* not found
*/
PFT.getCookieValue = function(cookieName) {
PFT.debug("checking for cookie '"+cookieName+"' in cookies...");
for(var key in phantom.cookies) {
var cookie = phantom.cookies[key];
if(cookie.name.toLowerCase() == cookieName.toLowerCase()) {
PFT.debug("found '"+cookieName+"' cookie with value of: '"+cookie.value+"'");
return cookie.value;
}
}
};
/**
* convenience method for {@link PFT.logger.log} with a value of
* {@link PFT.logger.TRACE} passed as the first parameter
*/
PFT.trace = function(message) {
PFT.logger.log(PFT.logger.TRACE, message);
};
/**
* convenience method for {@link PFT.logger.log} with a value of
* {@link PFT.logger.DEBUG} passed as the first parameter
*/
PFT.debug = function(message) {
PFT.logger.log(PFT.logger.DEBUG, message);
};
/**
* convenience method for {@link PFT.logger.log} with a value of
* {@link PFT.logger.INFO} passed as the first parameter
*/
PFT.info = function(message) {
PFT.logger.log(PFT.logger.INFO, message);
};
/**
* convenience method for {@link PFT.logger.log} with a value of
* {@link PFT.logger.WARN} passed as the first parameter
*/
PFT.warn = function(message) {
PFT.logger.log(PFT.logger.WARN, message);
};
/**
* convenience method for {@link PFT.logger.log} with a value of
* {@link PFT.logger.ERROR} passed as the first parameter and a
* boolean value of 'true' passed as the last parameter
*/
PFT.error = function(message) {
PFT.logger.log(PFT.logger.ERROR, message, true);
};
/**
* function will generate a GUID or UUID complete with dashes
* between the sections
* @returns {string} a guid in the form of XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
*/
PFT.guid = function() {
function s4() {
return Math.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1);
}
return s4()+s4()+'-'+s4()+'-'+s4()+'-'+s4()+'-'+s4()+s4()+s4();
};
/**
* function will convert a passed in millisecond value to a more
* human-readable output format of HHH hours MM minutes SS seconds
* mmm milliseconds
* @param {Integer} milliseconds - the milliseconds to be converted
* @returns {string} a human-readable string representing the passed
* in value
*/
PFT.convertMsToHumanReadable = function (milliseconds) {
var date = new Date(milliseconds);
var h = date.getHours();
var m = date.getMinutes();
var s = date.getSeconds();
var ms = date.getMilliseconds();
var out = "";
if(h > 0) {
out+=h;
if(h==1) {
out+=" hour ";
} else {
out+=" hours ";
}
}
if(m > 0) {
out+=m;
if(m==1) {
out+=" minute ";
} else {
out+=" minutes ";
}
}
if(s > 0) {
out+=s;
if(s==1) {
out+=" second ";
} else {
out+=" seconds ";
}
}
if(ms > 0) {
out+=ms;
if(ms==1) {
out+=" millisecond";
} else {
out+=" milliseconds";
}
}
return out;
};
/**
* function will create a JPG with a quality of 50% using either
* the passed in name or the passed in 'PhantomJs.Webpage.Page'
* url if no name is passed. The image will be written to the
* {@link PFT.IMAGES_DIR} directory with the name formatted to remove
* spaces and illegal characters
* @param {PhantomJs.Webpage.Page} page - the Page object to be rendered
* @param {string} [name=page.url] - a name to be used for the image
*/
PFT.renderPage = function (page, name) {
if (page) {
if (!name) {
name = page.url;
}
name = name.replace(/\//g,'_').replace(/([%:?&\[\]{}\s\W\\])/g,'');
name = PFT.IMAGES_DIR + name + "." + new Date().getTime() + ".jpg";
PFT.info("capturing page image: " + name);
try {
page.render(name, { quality: '50' });
} catch (e) {
PFT.error("could not render image due to: " + e);
}
}
};
/**
* function hook allowing for "listening" to any console messages sent
* by the 'PhantomJs.Webpage.Page' objects
*/
PFT.onPageConsoleMessage = function (details) {
// hook for handling page console messages
};
/** @ignore */
phantom.onError = function(msg, trace) {
// if any exceptions make it past the page handle them here
var stack = '';
if (trace && trace.length > 0) {
trace.forEach(function(t) {
stack += '\t-> ' + (t.file || t.sourceURL) + ': ' + t.line + (t.function ? ' (in function ' + t.function +')' : '') + '\n';
});
} else {
stack += '\n' + PFT.logger._getStackTrace();
}
msg += stack;
// only exit on unexpected errors
if (PFT.tester._tests.length > 0) {
PFT.tester.handleError(msg);
} else {
// exit immediately
MutexJs._reset();
PFT.logger.log(PFT.logger.ERROR, msg, false);
phantom.exit(1);
}
};
/** @ignore **/
var PFT_THREAD_ID = PFT_THREAD_ID || ''; // used for parallel execution
/**
* @namespace PFT.logger
* @memberof PFT
*/
PFT.logger = {
/**
* @property {string} [logLevel="info"] - the minimum level of logging to
* output
*/
logLevel: require('system').env.log_level || "info",
/** @ignore */
UNKNOWN: -1, // never show
/**
* @property {number} TRACE=0 - an enum used to indicate that the current
* message is very low criticality. this should be reserved for highly
* detailed debugging
*/
TRACE: 0,
/**
* @property {number} DEBUG=1 - representing a low criticality level of logging.
* this should be reserved for debugging
*/
DEBUG: 1,
/**
* @property {number} INFO=1 - representing the normal criticality level of logging.
* this should be reserved for helpful state change information
*/
INFO: 2,
/**
* @property {number} WARN=1 - representing the moderate criticality level of logging.
* this should be reserved for potentially problematic state changes
*/
WARN: 3,
/**
* @property {number} ERROR=1 - representing the maximum criticality level of logging.
* this should be reserved for problems with execution that will
* block functionality
*/
ERROR: 4,
/** @ignore */
TEST: 100, // always show
/** @ignore */
getLogLevelInt: function (levelStr) {
switch (levelStr.toLowerCase()) {
case "trace":
return PFT.logger.TRACE;
case "debug":
return PFT.logger.DEBUG;
case "info":
return PFT.logger.INFO;
case "warn":
return PFT.logger.WARN;
case "error":
return PFT.logger.ERROR;
case "qunit":
return PFT.logger.TEST;
default:
return PFT.logger.UNKNOWN;
}
},
/** @ignore */
getLogLevelStr: function (levelInt) {
switch (levelInt) {
case PFT.logger.TRACE:
return "TRACE";
case PFT.logger.DEBUG:
return "DEBUG";
case PFT.logger.INFO:
return "INFO";
case PFT.logger.WARN:
return "WARN";
case PFT.logger.ERROR:
return "ERROR";
case PFT.logger.TEST:
return "TEST";
default:
return "UNKNOWN";
}
},
/**
* function will log the passed in message to the console
* based on the {@link PFT.logger.logLevel} where higher
* criticality of levels will be output
* @param {Integer} levelInt - a value of {@link PFT.logger.TRACE},
* {@link PFT.logger.DEBUG}, {@link PFT.logger.INFO},
* {@link PFT.logger.WARN}, or {@link PFT.logger.ERROR} to specify
* the criticality of the message
* @param {string} message - the message to be output if the level
* is equal to or higher than {@link PFT.logger.logLevel}
* @param {boolean} [includeStackTrace=false] - if set to true the
* output message will be appended with the current execution stack
*/
log: function(levelInt, message, includeStackTrace) {
if (!isNaN(levelInt) && levelInt > PFT.logger.UNKNOWN && levelInt >= PFT.logger.getLogLevelInt(PFT.logger.logLevel)) {
if (includeStackTrace) {
message += "\n" + PFT.logger._getStackTrace();
}
var msg = PFT.logger.getLogLevelStr(levelInt) + ": " + message;
switch (levelInt) {
case PFT.logger.FATAL:
case PFT.logger.ERROR:
console.error(PFT_THREAD_ID + msg);
break;
case PFT.logger.WARN:
console.warn(PFT_THREAD_ID + msg);
break;
default:
console.log(PFT_THREAD_ID + msg);
}
}
},
/** @ignore */
_getStackTrace: function () {
var callstack = "";
var isCallstackPopulated = false;
try {
i.dont.exist+=0; //doesn't exist- that's the point
} catch(e) {
var lines,
i;
if (e.stack) {
lines = e.stack.split('\n');
for (i=2; i<lines.length; i++) {
callstack += lines[i] + "\n";
}
isCallstackPopulated = true;
}
}
if (!isCallstackPopulated) { //IE and Safari
var currentFunction = arguments.callee.caller;
while (currentFunction) {
var fn = currentFunction.toString();
var fname = fn.substring(fn.indexOf("function") + 8, fn.indexOf('')) || 'anonymous';
callstack += "\t" + fname + "\n";
currentFunction = currentFunction.caller;
}
}
return callstack;
}
};
/**
* a base object to be used for navigating and validating a site
* @param {PhantomJs.Webpage.Page} [page=PFT.createPage()] - an
* existing Page object to be used by this class
* @param {string} [baseUrl=""] - a URI root that can form the
* base for all extending classes
* @class PFT.BasePage - Represents a base object to extend from for UI testing.
* Ex:
* <p><code>
* function HomePage(page, url) {<br />
* PFT.BasePage.call(this, page, url);<br />
* this.HEADERLINK_CSS = '.header';<br />
* this.registerKeyElement(this.HEADERLINK_CSS);<br />
* }<br />
* HomePage.prototype = Object.create(PFT.BasePage.prototype);<br />
* HomePage.prototype.constructor = HomePage;<br />
* </code></p>
* @memberof PFT
*/
PFT.BasePage = function(page, baseUrl) {
this.page = page || PFT.createPage();
this.baseUrl = baseUrl || "";
this.keyElements = [];
// handle errors that happen on the actual website
this.page.onError = function (msg, trace) {
// capture errors and log
var msgStack = [msg];
if (trace && trace.length) {
trace.forEach(function(t) {
msgStack.push(' -> ' + (t.file || t.sourceURL) + ': ' + t.line + (t.function ? ' (in function ' + t.function +')' : ''));
});
}
PFT.trace(msgStack.join('\n'));
PFT.tester.onPageError({ message: msgStack.join('\n') });
if (!PFT.IGNORE_PAGE_ERRORS) {
phantom.onError(msg, trace);
}
};
this.page.onConsoleMessage = function (msg, lineNum, sourceId) {
PFT.trace('CONSOLE: ' + msg + ' (from line #' + lineNum + ' in "' + sourceId + '")');
var output = {
"message": msg,
"line": lineNum,
"source": sourceId
};
PFT.onPageConsoleMessage(output);
};
};
/**
* function will navigate to the 'baseUrl' specified on object creation
* @param {string} [urlParams=""] - a string of URL Encoded values to be appended
* to the 'baseUrl'
* @param {function} callback - the function to execute after the page is loaded.
* on success the callback will be passed a boolean of 'true' otherwise 'false' and
* an error message will be passed to the callback
*/
PFT.BasePage.prototype.open = function (urlParams, callback) {
if (urlParams && !callback && typeof(urlParams) === "function") {
callback = urlParams;
urlParams = undefined;
}
var p = urlParams || "";
this.page.open(this.baseUrl + p, function afterOpen(status) {
PFT.debug('opened page: ' + this.baseUrl + " = " + status);
if(status == 'success') {
callback.call(this, true);
} else {
callback.call(this, false, "opening '" + this.baseUrl + p + "' returned: " + status);
}
}.bind(this));
};
PFT.BasePage.prototype.close = function () {
this.page.clearCookies();
this.page.close();
for (var key in this) {
if (this.hasOwnProperty(key)) {
this[key] = undefined;
}
}
};
/**
* function allows for registering key selectors that must be present on the
* page. this can then be verified through the {@link PFT.BasePage.checkValidity}
* function
*/
PFT.BasePage.prototype.registerKeyElement = function (elementSelector) {
this.keyElements.push(elementSelector);
};
/**
* function will ensure that each of the key elements are present on the
* page by looping through all selectors passed to {@link PFT.BasePage.registerKeyElement}
* and passing a boolean of 'true' if all exist otherwise 'false' and an error
* message
*/
PFT.BasePage.prototype.checkValidity = function (callback) {
var selectors = this.keyElements;
this._verify(selectors, callback);
};
/** @ignore */
PFT.BasePage.prototype._verify = function (selectors, callback) {
if (selectors.length > 0) {
var selector = selectors.shift();
PFT.debug("verifying '" + selector + "' exists on page...");
this.waitFor(selector, function elementFound(success, msg) {
if (!success) {
callback.call(this, false, "unable to locate selector: " + msg);
} else {
if (selectors.length > 0) {
this._verify(selectors, callback);
} else {
callback.call(this, true);
}
}
}.bind(this), PFT.DEFAULT_TIMEOUT);
} else {
callback.call(this, false, "nothing to verify");
}
};
PFT.BasePage.prototype.withinPage = function(selector) {
PFT.debug("checking for: '"+selector+"' within page.");
try {
var pos = this.elementPosition(selector);
if(pos.left >=0 && pos.top >= 0 && pos.left <= this.page.viewportSize.width && pos.top <= this.page.viewportSize.height) {
return true;
} else {
return false;
}
} catch(e) {
return false;
}
};
PFT.BasePage.prototype.visible = function(selector) {
PFT.debug("checking for: '"+selector+"' visible.");
try {
/* jshint evil:true */
var display = this.eval(function getDisplay(s) {
var el = document.querySelector(s);
if (typeof window.getComputedStyle !== "undefined") {
return window.getComputedStyle(el, null).display;
} else if (el.currentStyle !== "undefined") {
return el.currentStyle.display;
}
}, selector);
if(display === "none") {
return false;
} else {
return true;
}
} catch(e) {
return false;
}
};
/**
* function will pass the current 'PhantomJs.Webpage.Page' to
* {@link PFT.renderPage}
* @param {string} [name=page.url] - an optional name to use for
* the image name
*/
PFT.BasePage.prototype.renderPage = function(name) {
PFT.renderPage(this.page, name);
};
/**
* function will verify the presence of the passed in selector by polling
* the page up to the 'maxMsWait' and will pass a boolean 'true' to the
* callback if found otherwise 'false' and an error message
* @param {string} selector - the CSS selector used to identify the element
* on the page
* @param {function} callback - a function to be called when the element is
* found or when the maximum wait time has passed
* @param {Integer} [maxMsWait=PFT.DEFAULT_TIMEOUT] - the maximum number of
* milliseconds to wait while attempting to locate the passed in selector
*/
PFT.BasePage.prototype.waitFor = function(selector, callback, maxMsWait) {
var wait = maxMsWait || PFT.DEFAULT_TIMEOUT;
if (isNaN(wait)) {
callback.call(this, false, "invalid value of '" + wait + "' passed to function");
} else {
PFT.debug("waiting for: '" + selector + "' or until " + wait + "ms has passed.");
var expiry = new Date().getTime() + wait;
this.waitUntil(selector, callback, expiry);
}
};
PFT.BasePage.prototype.waitUntil = function(selector, callback, timeInMilliseconds) {
PFT.debug("waiting for: '"+selector+"' or for "+(timeInMilliseconds-(new Date().getTime()))+" milliseconds");
if(this.exists(selector)) {
PFT.debug("selector found.");
callback.call(this, true);
} else {
PFT.debug("selector not found.");
// ensure we haven't (or won't have) exceeded our timeout
if((new Date().getTime() + PFT.POLLING_INTERVAL) < timeInMilliseconds) {
// wait for polling interval milliseconds then check again
PFT.debug("retrying...");
setTimeout(function() {
this.waitUntil(selector, callback, timeInMilliseconds);
}.bind(this), PFT.POLLING_INTERVAL);
} else {
PFT.info("timing out. '"+selector+"' not found by: "+new Date(timeInMilliseconds));
callback.call(this, false, "'"+selector+"' not found by: "+new Date(timeInMilliseconds));
}
}
};
/**
* function verifies that the passed in selector exists on the page
* @param {string} selector - the CSS selector used to identify the element
*/
PFT.BasePage.prototype.exists = function(selector) {
PFT.debug("checking for: '"+selector+"' on page.");
/* jshint evil:true */
var condition = this.eval(function(s) { return (document.querySelector(s) !== null); }, selector.toString()); // returns (true|false)
if (condition) {
PFT.debug("condition met.");
return true;
} else {
PFT.debug("condition failed.");
return false;
}
};
PFT.BasePage.prototype.elementPosition = function(selector) {
PFT.debug("retrieving: '"+selector+"' position page.");
/* jshint evil:true */
var pos = this.eval(function(s) { return document.querySelector(s).getBoundingClientRect(); }, selector.toString());
if (pos && (pos.left !== undefined || pos.left !== null) && (pos.top !== undefined || pos.top !== null)) {
PFT.debug("found '"+selector+"' at position: "+JSON.stringify(pos));
return pos;
} else {
throw "element could not be located on the page. pos: "+JSON.stringify(pos);
}
};
/**
* function will issue a PhantomJs click event on the specified selector
* @param {string} selector - the CSS selector to use in identifying which
* element to click
*/
PFT.BasePage.prototype.click = function(selector) {
PFT.debug("clicking on: '"+selector+"'...");
var pos = this.elementPosition(selector);
/* jshint evil:true */
this.eval(function(s) {
var ev = document.createEvent("MouseEvent");
ev.initMouseEvent(
"click",
true /* bubble */,
true /* cancelable */,
window,
null,
0, 0, 0, 0, /* coordinates */
false, false, false, false, /* modifier keys */
0 /*left*/,
null
);
document.querySelector(s).dispatchEvent(ev);
}, selector);
};
/**
* function will return the text content of the specified element.
* @param {string} selector - the CSS selector to use in identifying which
* element to return the text content from within. The returned text will be
* from the specified element and any child elements and will not include HTML
*/
PFT.BasePage.prototype.getText = function (selector) {
PFT.debug("getting textContent for: '" + selector + "'...");
/* jshint evil:true */
return this.eval(function(s) { return document.querySelector(s).textContent; }, selector.toString());
};
PFT.BasePage.prototype.getAttribute = function(selector, attribute) {
PFT.debug("returning href value for: '"+selector+"'...");
/* jshint evil:true */
return this.eval(function(s, a) { return document.querySelector(s).getAttribute(a); }, selector.toString(), attribute);
};
/**
* function will place focus on the specified selector and then send a keydown
* followed by a keyup event for each character of the passed in value string.
* when completed the callback will be called with a value of true
* @param {string} selector - the CSS selector to use in identifying which
* element will have focus for the keys
* @param {string} value - the text string to be typed
* @param {Function} callback - the callback function to be called after all
* characters have been sent
* @param {Integer} [keyDelay=25] - the delay in milliseconds between each
* keypress
*/
PFT.BasePage.prototype.sendKeys = function(selector, value, callback, keyDelay) {
var delay = keyDelay || 25;
try {
if(value) {
var character = value.substring(0,1);
PFT.trace("appending value of '" + character + "' to: '" + selector + "'");
value = value.substring(1, value.length);
/* jshint evil:true */
this.eval(function (s) {
document.querySelector(s).focus();
}, selector.toString());
this.page.sendEvent('keydown', character);
this.page.sendEvent('keyup', character);
if(value.length > 0) {
setTimeout(function afterSendKey() {
this.sendKeys(selector, value, callback);
}.bind(this), delay);
} else {
callback.call(this, true);
}
} else {
callback.call(this, false, "no value passed to method: "+value);
}
} catch(e) {
callback.call(this, false, e);
}
};
PFT.BasePage.prototype.setCheckboxState = function(selector, enabled) {
PFT.debug("setting state of checkbox for: '"+selector+"' to: "+enabled);
if(enabled) {
/* jshint evil:true */
this.eval(function(s) { document.querySelector(s).checked=true; }, selector.toString());
} else {
this.eval(function(s) { document.querySelector(s).checked=false; }, selector.toString());
}
};
/**
* function will evaluate the passed in javascript function within the page. Any
* arguments passed in after the function will be passed as arguments to the
* function
* @param {Function} script - the javascript function to be evaluated on the
* page
* @param {Object} arguments - an object to be passed to the function when
* executed
*/
/* jshint evil:true */
PFT.BasePage.prototype.eval = function() {
if (arguments && arguments.length > 0) {
PFT.debug("eval called with '" + arguments.length + "' arguments");
PFT.trace("evaluating: '" + JSON.stringify(arguments) + "' in page...");
var result;
try {
result = this.page.evaluate.apply(this.page, arguments);
} catch (e) {
PFT.warn(e);
}
return result;
}
};
/**
* function provides a MixIn ability for the {@link PFT.BasePage}
* and any subclasses which can be useful in splitting out sections
* of page functionality into separate modules such as those used
* to describe header, footer and body interactions within a page
* @param {Object} module - the javascript Object to be mixed in to
* this page
*/
PFT.BasePage.prototype.extend = function(module) {
for (var k in module) {
if (module.hasOwnProperty(k)) {
this[k] = module[k];
}
}
};
/**
* @namespace
* @memberof PFT
*/
PFT.tester = {
/**
* @property {number} [timeOutAfter=PFT.DEFAULT_TIMEOUT] - the default max
* amount of time allowed for tests to run before they are marked as a fail.
* this can be overridden for a specific test by passing a 'maxDuration'
* option to the {@link PFT.tester.test} function
*/
timeOutAfter: PFT.DEFAULT_TIMEOUT,
/** @ignore */
running: false,
/** @ignore */
exiting: false,
/** @ignore */
_suites: [],
/** @ignore */
_tests: [],
/** @ignore */
remainingCount: 0,
/** @ignore */
globalStartTime: null,
/** @ignore */
_reset: function () {
MutexJs._reset();
PFT.tester.running = false;
PFT.tester.exiting = false;
PFT.tester.globalStartTime = null;
PFT.tester.timeOutAfter = PFT.DEFAULT_TIMEOUT;
PFT.tester.remainingCount = 0;
PFT.tester._tests = [];
PFT.tester._suites = [];
PFT.tester.onTestStarted = function (details) {};
PFT.tester.onTestCompleted = function (details) {};
PFT.tester.onPageError = function (details) {};
PFT.tester.onError = function (details) {};
PFT.tester.onTimeout = function (details) {};
PFT.tester.onAssertionFailure = function (details) {};
PFT.tester.onExit = function (details) {};
},
/** @ignore */
suite: function (name, options) {
var o = options || {};
var s = {
name: name,
setup: o.setup,
teardown: o.teardown,
};
PFT.tester._suites.push(s);
},
/** @ignore */
test: function (name, callback, suite, timeout) {
return {
name: name,
timeout: timeout || PFT.DEFAULT_TIMEOUT,
page: null,
suite: suite,
passes: 0,
failures: [],
errors: [],
unlockId: null,
startTime: null,
duration: null,
};
},
/**
* function will get the current suite in use. this is primarily used for
* associating a suite with a test
*/
currentSuite: function () {
var s = null;
if (PFT.tester._suites.length > 0) {
s = PFT.tester._suites[PFT.tester._suites.length - 1];
}
return s;
},
/**
* function will get the currently executing test
*/
currentTest: function () {
var t = null;
if (PFT.tester._tests.length > 0) {
t = PFT.tester._tests[PFT.tester._tests.length - 1];
}
return t;
},
/** @ignore */
captureStartTime: function () {
if (PFT.tester.globalStartTime === null) {
PFT.tester.globalStartTime = new Date().getTime();
}
},
/**
* function will schedule the passed in {@link testCallback} for execution.
* When the test is complete it MUST call one of {@link PFT.tester.assert.done},
* {@link PFT.tester.assert.pass}, {@link PFT.tester.assert.fail} or an
* assertion must fail to indicate that the next test should proceed.
* @param {string} name - the name of the test
* @param {testCallback} callback - the function to execute as a test. when
* executed this function will be passed two arguments, a PhantomJs.Webpage.Page,
* and a {@link PFT.tester.assert} object referencing the test
* @param {Number} [timeout=PFT.DEFAULT_TIMEOUT] - the maximum time in
* milliseconds to allow the test to execute before it is marked as a fail.
* this time includes any setup and teardown that is specified
*/
run: function (name, callback, timeout) {
PFT.tester.captureStartTime();
PFT.tester.remainingCount++;
// get a test object
var t = PFT.tester.test(name, callback, PFT.tester.currentSuite(), timeout);
(function (testObj) {
// get a lock so we can run the test
MutexJs.lockFor("PFT.tester.test", function onStart(runUnlockId) {
testObj.runUnlockId = runUnlockId;
PFT.tester._tests.push(testObj);
var suite = "";
if (testObj.suite) {
if (testObj.suite.name) {
suite = testObj.suite.name + " - ";
}
}
var msg = "Starting: '" + suite + testObj.name + "'...";
PFT.logger.log(PFT.logger.TEST, msg);
var testId = PFT.guid();
// run setup
if (testObj.suite && testObj.suite.setup) {
MutexJs.lock(testId, function setup(unlockId) {
testObj.unlockId = unlockId;
var done = function () {
// release the current lock
MutexJs.release(testObj.unlockId);
};
testObj.suite.setup.call(this, done);
});
}
// run test
MutexJs.lock(testId, function test(unlockId) {
testObj.page = PFT.createPage();
testObj.unlockId = unlockId;
testObj.startTime = new Date().getTime();
PFT.tester.onTestStarted({ "test": testObj });
callback.call(this, testObj.page, new PFT.tester.assert(testObj));
});
// run teardown
if (testObj.suite && testObj.suite.teardown) {
MutexJs.lock(testId, function teardown(unlockId) {
testObj.unlockId = unlockId;
var done = function () {
// release the current lock
MutexJs.release(testObj.unlockId);
};
testObj.suite.teardown.call(this, done);
});
}
MutexJs.lock(testId, function done(unlockId) {
PFT.tester.closeTest(testObj);
MutexJs.release(unlockId);
MutexJs.release(runUnlockId);
});
}, testObj.timeout, function onTimeout() {
var msg = "Test '" + testObj.name + "' exceeded timeout of " + testObj.timeout;
PFT.tester.onTimeout({ "test": testObj, message: msg });
// ensure we can't unlock this step
testObj.unlockId = null;
setTimeout(function () {
// ensure any executing scripts are halted
throw msg;
}, 0);
});
})(t);
},
/**
* function will close out the currently running test objects, but any async
* tasks will continue running.
*/
closeTest: function (testObj) {
var duration = PFT.convertMsToHumanReadable(new Date().getTime() - testObj.startTime);
testObj.duration = duration;
var suite = "";
if (testObj.suite) {
if (testObj.suite.name) {
suite = testObj.suite.name + " - ";
}
}
var msg = "Completed: '" + suite + testObj.name + "' in " + duration + " with " + testObj.passes + " passes, " +
testObj.failures.length + " failures, " + testObj.errors.length + " errors.";
PFT.logger.log(PFT.logger.TEST, msg);
PFT.tester.onTestCompleted({ test: testObj });
try {
testObj.page.close();
} catch (e) {
PFT.warn(e);
}
PFT.tester.remainingCount--;
PFT.tester.exitIfDoneTesting();
},
/**
* @namespace PFT.tester.assert
* @memberof PFT.tester
*/
assert: function (testObj) {
return {
/**
* function to test the value of a passed in boolean is true
* and to signal a halt to the current test if it is not.
* function will also call {@link PFT.tester.assert.done} so that any
* subsequent tests can continue on failure. triggers the
* {@link PFT.tester.onAssertionFailure} function call if passed in
* value is false
* @param {boolean} value - the boolean value to be compared to 'true'
* @param {string} message - a message to display describing the failure in the
* case of a failed comparison. this message is referenced in the current test.failures
* @memberof PFT.tester.assert
*/
isTrue: function (value, message) {
if (!value) {
var m = message || "expected 'true' but was 'false'";
m = "'" + testObj.name + "'\n\t" + m;
testObj.failures.push(m);
PFT.logger.log(PFT.logger.TEST, "Assert failed - " + m);
PFT.tester.onAssertionFailure({ test: testObj, message: m });
this.done();
} else {
testObj.passes++;
}
},
/**
* alias of {@link PFT.tester.assert.isTrue}
* @param {boolean} value - the boolean value to be compared to 'true'
* @param {string} message - a message to display describing the failure in the
* case of a failed comparison. this message is referenced in the current.failures
* @memberof PFT.tester.assert
*/
ok: function (value, message) {
this.isTrue(value, message);
},
/**
* function to test the value of a passed in boolean is false
* and to signal a halt to the current test if it is not.
* function will also call {@link PFT.tester.assert.done} so that any
* subsequent tests can continue on failure. triggers the
* {@link PFT.tester.onAssertionFailure} function call if passed in
* value is true
* @param {boolean} value - the boolean value to be compared to 'false'
* @param {string} message - a message to display describing the failure in the
* case of a failed comparison. this message is referenced in the
* current test.failures
* @memberof PFT.tester.assert
*/
isFalse: function (value, message) {
var m = message || "expected 'false' but was 'true'";
this.isTrue(!value, message);
},
/**
* function to signal a successful completion of a test and increment
* the current number of passes by 1.
* function will also call {@link PFT.tester.assert.done} so that any
* subsequent tests can continue.
* @param {string} message - a message to display describing the pass.
* @memberof PFT.tester.assert
*/
pass: function (message) {
var m = message || testObj.name;
PFT.logger.log(PFT.logger.TEST, "PASS: " + m);
testObj.passes++;
this.done();
},
/**
* function to signal a failed completion of a test and increment
* the current number of failures by 1.
* function will also call {@link PFT.tester.assert.done} so that any
* subsequent tests can continue.
* @param {string} message - a message to display describing the pass.
* @memberof PFT.tester.assert
*/
fail: function (message) {
var m = message || testObj.name;
PFT.logger.log(PFT.logger.TEST, "FAIL: " + m, true);
testObj.failures.push(m);
PFT.tester.onAssertionFailure({ test: testObj, message: m });
this.done();
},
/**
* function to be called at the end of asynchronous test, setup and tearDown.
* This indicates that the next scheduled item can be executed. Only call this
* function when all tasks are complete within a {@link PFT.tester.run}
* @memberof PFT.tester.assert
*/
done: function () {
// release the current lock
MutexJs.release(testObj.unlockId);
},
};
},
/**
* function provides handling for expected exceptions thrown to halt
* the currently running script. typically this will be called from the
* phantom.onError function if tests are running.
*/
/** @ignore */
handleError: function (msg) {
// restart MutexJs in case exception caused fatal javascript halt
MutexJs.recover();
// unexpected exception so log in errors and move to next
var t = PFT.tester.currentTest();
t.errors.push(msg);
PFT.tester.onError({ test: t, message: msg });
PFT.tester.closeTest(t);
// move to next test
MutexJs.release(t.runUnlockId);
},
/**
* function will indicate that we should exit if no tests remain to be run
* and then it will call the {@link PFT.tester.exit} function
*/
/** @ignore */
exitIfDoneTesting: function () {
if (PFT.tester.remainingCount === 0) {
if (!PFT.tester.exiting) {
PFT.tester.exit();
}
}
},
/** @ignore */
exit: function () {
PFT.tester.exiting = true;
PFT.tester.running = false;
var duration = PFT.convertMsToHumanReadable(new Date().getTime() - PFT.tester.globalStartTime);
var i,
j,
wroteFailures = false,
wroteErrors = false,
passes = 0,
failures = 0,
errors = 0,
failuresMsg = "",
errorsMsg = "";
for (i=0; i<PFT.tester._tests.length; i++) {
passes += PFT.tester._tests[i].passes;
failures += PFT.tester._tests[i].failures.length;
errors += PFT.tester._tests[i].errors.length;
for (j=0; j<PFT.tester._tests[i].failures.length; j++) {
var failure = PFT.tester._tests[i].failures[j];
if (!wroteFailures) {
wroteFailures = true;
failuresMsg += "\nFAILURES:\n";
}
failuresMsg += "\t" + failure + "\n";
}
for (j=0; j<PFT.tester._tests[i].errors.length; j++) {
var error = PFT.tester._tests[i].errors[j];
if (!wroteErrors) {
wroteErrors = true;
errorsMsg += "\nERRORS:\n";
}
errorsMsg += "\t" + error + "\n";
}
}
var msg = "Completed '" + PFT.tester._tests.length +
"' tests in " + duration + " with " + passes + " passes, " +
failures + " failures, " + errors + " errors.\n";
msg += failuresMsg;
msg += errorsMsg;
PFT.logger.log(PFT.logger.TEST, msg);
PFT.tester.onExit({ message: msg });
var exitCode = errors + failures;
// ensure message gets out before exiting
setTimeout(function () {
phantom.exit(exitCode);
}, 1000);
},
/**
* function hook that is called when a new test is started
* @param {Object} details - an object containing a 'test' property for the
* currently started test object
*/
onTestStarted: function (details) {
// hook for testing
},
/**
* function hook that is called when a test completes. this
* includes anything resulting in {@link PFT.tester.done}
* being called
* @param {Object} details - an object containing a 'test' property for the
* currently started test object
*/
onTestCompleted: function (details) {
// hook for testing
},
/**
* function hook that is called when the underlying page
* experiences an error
*/
onPageError: function (details) {
// hook for testing
},
/**
* function hook that is called when there is a test error
*/
onError: function (details) {
// hook for testing
},
/**
* function hook that is called when a test times out
*/
onTimeout: function (details) {
// hook for testing
},
/**
* function hook that is called when an assertion fails
* @param {Object} details - an object containing a 'test' property for the
* currently started test object and a 'message' property for the failure
* message
*/
onAssertionFailure: function (details) {
// hook for testing
},
/**
* function hook that is called when program exits
*/
onExit: function (details) {
// hook for testing
},
};
/**
* This callback is executed as a test and will only be run when
* any previous test has completed. If Setup and TearDown methods
* are specified in the test options those will be run before and
* after the test
* @callback testCallback
* @param {PhantomJs.Webpage.Page} page - a PhantomJs Page object for use in
* testing
* @param {PFT.tester.assert} assert - the test controller that allows for
* indicating the test status including pass, fail, and done for async control
*/
// polyfill for .bind() call (not supported in PhantomJs)
if (!Function.prototype.bind) {
Function.prototype.bind = function(oThis) {
if (typeof this !== 'function') {
// closest thing possible to the ECMAScript 5
// internal IsCallable function
throw new TypeError('Function.prototype.bind - what is trying to be bound is not callable');
}
var aArgs = Array.prototype.slice.call(arguments, 1),
fToBind = this,
fNOP = function() {},
fBound = function() {
return fToBind.apply(this instanceof fNOP && oThis ? this : oThis,
aArgs.concat(Array.prototype.slice.call(arguments)));
};
fNOP.prototype = this.prototype;
fBound.prototype = new fNOP();
return fBound;
};
}
// polyfill for .trim() call
if (!String.prototype.trim) {
(function() {
// Make sure we trim BOM and NBSP
var rtrim = /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g;
String.prototype.trim = function() {
return this.replace(rtrim, '');
};
})();
}