argus-eyes
Version:
A lightweight commandline tool for visual regression testing of UI components.
350 lines (308 loc) • 9.27 kB
JavaScript
var async = require('async');
var fs = require('fs');
// 10s timeout on this script
setTimeout(function() {
console.log('Global timeout (10s per page) has expired.');
return phantom.exit(1);
}, 10 * 1000);
// PhantomJS API
var system = require('system');
var page = require('webpage').create();
// Configuration
var maxTries = 50;
var tryTimeout = 100;
var invoker = _invoker(maxTries, tryTimeout);
// CLI Arguments
var basePath = system.args[1];
var url = system.args[2];
var pageBase = system.args[3];
var size = system.args[4].split('x');
var userPage = JSON.parse(system.args[5]);
var components = JSON.parse(system.args[6]);
var delayScript = system.args[7];
var delayMs = parseInt(system.args[8], 10);
var userScript = system.args[9];
var credentials = system.args[10].split(':');
page.viewportSize = {
width: size[0],
height: size[1]
};
if (credentials.length === 2) {
page.settings.userName = credentials[0];
page.settings.password = credentials[1];
}
page.onConsoleMessage = function(msg) {
console.log('console: ' + msg);
};
/**
* PhantomJS script
*/
page.open(url, function(status) {
if (status !== 'success') {
console.log('Unable to load the address: ' + url);
return phantom.exit(1);
}
async.waterfall([
waitForLoad,
waitForScript,
waitForDelay,
runUserScript,
tryRemoveIgnores,
tryClipRect
], function(err) {
if (err) {
console.log(err);
phantom.exit(1);
}
phantom.exit();
});
});
/**
* Wait for the ready state 'complete' (window.onload)
*/
function waitForLoad(cb) {
invoker(_isLoaded, function(err) {
if (err) {
return cb('document.readyState not \'completed\', timed out after ' + (maxTries * tryTimeout) + 'ms.');
}
cb();
});
}
/**
* Wait for the wait-for-script
*/
function waitForScript(cb) {
var tasks = [];
if (delayScript) {
tasks.push(function(cb) {
invoker(_callUserScript(delayScript), function(err) {
if (err) {
return cb('wait-for-script (global-level) still not returning a truthy value, timed out after ' +
(maxTries * tryTimeout) + ' ms.');
}
cb();
});
});
}
if (userPage['wait-for-script']) {
tasks.push(function(cb) {
invoker(_callUserScript(userPage['wait-for-script']), function(err) {
if (err) {
return cb('wait-for-script (page-level) still not returning a truthy value, timed out after ' +
(maxTries * tryTimeout) + ' ms.');
}
cb();
});
});
}
components.forEach(function(component) {
if (!component['wait-for-script']) return;
tasks.push(function(cb) {
invoker(_callUserScript(component['wait-for-script']), function(err) {
if (err) {
return cb('wait-for-script (component-level) still not returning a truthy value for component ' +
'\'' + component.name + '\', timed out after ' + (maxTries * tryTimeout) + ' ms.');
}
cb();
});
});
});
async.waterfall(tasks, function(err) { cb(err); });
}
/**
* Wait for the wait-for-delay (in milliseconds)
*/
function waitForDelay(cb) {
var timeouts = [];
// Wait for - level global
timeouts.push(delayMs);
// Wait for - level page
var delay = parseInt(userPage['wait-for-delay'], 10) || 0;
timeouts.push(delay);
// Wait for - level components
components.forEach(function(component) {
var delay = parseInt(component['wait-for-delay'], 10) || 0;
timeouts.push(delay);
});
var timeout = Math.max.apply(null, timeouts);
setTimeout(cb, timeout);
}
/**
* Run a user script
*/
function runUserScript(cb) {
if (userScript) {
_callUserScript(userScript)();
}
if (userPage['run-script']) {
_callUserScript(userPage['run-script'])();
}
components.forEach(function(component) {
if (component['run-script']) {
_callUserScript(component['run-script'])();
}
});
cb();
}
/**
* Removes any ignored elements from the DOM
*/
function tryRemoveIgnores(cb) {
async.parallel(components.map(function(component) {
return function(cb) {
if (!component.ignore || !component.ignore.length) return cb();
invoker(_isRemoved(component), function(err) {
if (err) {
return cb('Unable to hide DOM element: \'' + component.ignore + '\'' +
', timed out after ' + (maxTries * tryTimeout) + ' ms.');
}
cb();
});
};
}), function(err) {
cb(err);
});
}
/**
* Clip element and save as .png file
*/
function tryClipRect(cb) {
async.parallel(components.map(function(component) {
return function(cb) {
invoker(_clipRect(component), function(err) {
if (err) {
return cb('Unable to clip component \'' + component.name + '\'' +
', timed out after ' + (maxTries * tryTimeout / 1e3) + ' ms.');
}
cb();
});
};
}), function(err) {
cb(err);
});
}
/**
* Returns whether window.onload has fired
*
* @private
* @returns {Boolean}
*/
function _isLoaded() {
var readyState = page.evaluate(function() {
return document.readyState;
});
return readyState === 'complete';
}
/**
* Call a user script inside a function
*
* @private
* @param {String} scriptFilename
* @returns {Function}
*/
function _callUserScript(scriptFilename) {
var scriptPath = scriptFilename;
if (!fs.isAbsolute(scriptPath)) {
scriptPath = basePath + '/' + scriptPath;
}
try {
var script = fs.read(scriptPath);
} catch (e) {
console.log('Could not read file: ' + scriptFilename);
phantom.exit(1);
}
return function() {
return page.evaluate(function(script) {
return (Function(script))();
}, script);
};
}
/**
* Returns a function that tries to hide a HTMLElement
*
* @private
* @param {{name: String, selector: String, ignore: String[]}} component
* @returns {Function}
*/
function _isRemoved(component) {
return function() {
return page.evaluate(function(baseSelector, ignore) {
try {
ignore.forEach(function(selector) {
var element = document.querySelector(baseSelector + ' ' + selector);
element.style.display = 'none';
});
return true;
} catch (e) {
return false;
}
}, component.selector, component.ignore);
};
}
/**
* Sets the clipping rectangle for element screenshots
*
* @private
* @param {{name: String, selector: String, ignore: String[]}}component
* @returns {Function}
*/
function _clipRect(component) {
return function() {
var clipRect = page.evaluate(function(component) {
try {
var rect = document.querySelector(component.selector).getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
return false;
}
return {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height
};
} catch (e) {
return false;
}
}, component);
if (!clipRect) return false;
page.clipRect = clipRect;
try {
page.render(pageBase + '/' + component.name + '.png', {
format: 'png',
quality: '0' // quality is file compression since png is lossless, 0 is maximum compression
});
} catch (e) {
return false;
}
return true;
};
}
/**
* Invoker
* Repeatedly invokes a function until it returns a truthy value
*
* @see https://gist.github.com/branneman/53b820be519b54bfc30a
* @private
* @param {Number} limit - The amount of total calls before we timeout
* @param {Number} interval - The amount of milliseconds between calls
* @param {Function} fn - The function to execute, must return a truthy value to indicate it's finished
* @param {Function} cb - The callback for when we're finished. Receives 2 arguments: `error` and `result`
*/
function _invoker(limit, interval) {
return function(fn, cb) {
var current = 0;
var _fn = function() {
current++;
var result = fn();
if (result) {
cb(null, result);
} else if (current < limit) {
setTimeout(_fn, interval);
} else {
cb(new Error('Limit exceeded!'), null);
cb = function() {};
}
};
_fn();
};
}