pix-diff
Version:
Protractor plugin for image comparison
781 lines (716 loc) • 25.1 kB
JavaScript
'use strict';
const assert = require('assert'),
PixelDiff = require('pixel-diff'),
camelCase = require('./lib/camelCase'),
fs = require('fs-extra'),
path = require('path'),
PNGImage = require('png-image');
/**
* Pix-diff protractor plugin class
*
* @constructor
* @class PixDiff
* @param {object} options
* @param {string} options.basePath Path to baseline images folder
* @param {string} options.diffPath Path to difference folder
* @param {boolean} options.baseline Save images not found in baseline
* @param {int} options.width Width of browser
* @param {int} options.height Height of browser
* @param {object} options.formatImageOptions Custom variables for Image Name
* @param {string} options.formatImageName Custom format image name
* @param {object} options.offsets Mobile iOS/Android offsets required for obtaining element position
*
* @property {string} basePath Directory where baseline images are read/saved
* @property {string} diffPath Directory where difference images are saved
* @property {boolean} baseline Toggle saving baseline imags if not found
* @property {int} width Width of browser
* @property {int} height Height of browser
* @property {object} formatOptions Flat object that holds custom options for formatString
* @property {string} formatString Customizable image filename naming convention
* @property {object} offsets Object with statusBar, addressBar and toolBar key/values
* @property {int} devicePixelRatio Ratio of the (vertical) size of one physical pixel on the current display device to the size of one device independent pixels(dips)
* @property {int} innerHeight Viewport height
* @property {int} pageWidth Full page width
* @property {int} pageHeight Full page height
* @property {string} browserName Browser name from the WebDriver capabilities
* @property {string} logName Log name from WebDriver capabilities
* @property {string} name Name from WebDriver capabilities
* @property {string} platformName Platform name from WebDriver capabilities
* @property {string} deviceName Device name from WebDriver capabilities
* @property {boolean} nativeWebScreenshot Android native screenshot from WebDriver capabilities
*/
class PixDiff {
constructor(options) {
assert.ok(options.basePath, 'Image baseline path not given.');
assert.ok(options.diffPath, 'Image difference path not given.');
this.basePath = path.normalize(options.basePath);
this.diffPath = path.join(path.normalize(options.diffPath), 'diff');
this.baseline = options.baseline || false;
this.width = options.width;
this.height = options.height;
this.formatOptions = options.formatImageOptions || {};
this.formatString = options.formatImageName || '{tag}-{browserName}-{width}x{height}-dpr-{dpr}';
this.offsets = options.offsets || {};
this.devicePixelRatio = 1;
this.innerHeight = 0;
this.pageWidth = 0;
this.pageHeight = 0;
this.offsets.ios = Object.assign({statusBar: 20, addressBar: 44}, this.offsets.ios);
this.offsets.android = Object.assign({statusBar: 24, addressBar: 56, toolBar: 48}, this.offsets.android);
fs.ensureDirSync(this.basePath);
fs.ensureDirSync(this.diffPath);
if (this.width && this.height) {
assert.ok(Number.isInteger(this.width), 'Option width not an Integer.');
assert.ok(Number.isInteger(this.height), 'Option height not an Integer.');
browser.driver.manage().window().setSize(this.width, this.height);
}
this._formatCapabilities();
}
/**
* Load matchers for pix-diff
*
* @static
* @method loadMatchers
* @example
* const pixDiff = require('pix-diff');
* pixDiff.loadMatchers();
* @public
*/
static loadMatchers() {
return browser.getProcessedConfig().then(_ => {
let framework = _.framework ? _.framework.toLowerCase() : 'jasmine2';
if (['jasmine', 'jasmine2', 'mocha'].indexOf(framework) >= 0) {
require(path.resolve(__dirname, 'framework', framework));
}
});
}
/**
* Unknown result of the comparison
*
* @static
* @property RESULT_UNKNOWN
* @type {int}
* @public
*/
static get RESULT_UNKNOWN() {
return PixelDiff.RESULT_UNKNOWN;
}
/**
* The images are too different
*
* @static
* @property RESULT_DIFFERENT
* @type {int}
* @public
*/
static get RESULT_DIFFERENT() {
return PixelDiff.RESULT_DIFFERENT;
}
/**
* The images are very similar, but still below the threshold
*
* @static
* @property RESULT_SIMILAR
* @type {int}
* @public
*/
static get RESULT_SIMILAR() {
return PixelDiff.RESULT_SIMILAR;
}
/**
* The images are identical (or near identical)
*
* @static
* @property RESULT_IDENTICAL
* @type {int}
* @public
*/
static get RESULT_IDENTICAL() {
return PixelDiff.RESULT_IDENTICAL;
}
/**
* Threshold-type for pixel
*
* @static
* @property THRESHOLD_PIXEL
* @type {string}
* @public
*/
static get THRESHOLD_PIXEL() {
return PixelDiff.THRESHOLD_PIXEL;
}
/**
* Threshold-type for percent of all pixels
*
* @static
* @property THRESHOLD_PERCENT
* @type {string}
* @public
*/
static get THRESHOLD_PERCENT() {
return PixelDiff.THRESHOLD_PERCENT;
}
/**
* Create output when images are different
*
* @static
* @property OUTPUT_DIFFERENT
* @type {int}
* @public
*/
static get OUTPUT_DIFFERENT() {
return PixelDiff.OUTPUT_DIFFERENT;
}
/**
* Create output when images are similar or different
*
* @static
* @property OUTPUT_SIMILAR
* @type {int}
* @public
*/
static get OUTPUT_SIMILAR() {
return PixelDiff.OUTPUT_SIMILAR;
}
/**
* Force output of all comparisons
*
* @static
* @property OUTPUT_ALL
* @type {int}
* @public
*/
static get OUTPUT_ALL() {
return PixelDiff.OUTPUT_ALL;
}
/**
* Merges non-default options from optionsB into optionsA
*
* @method _mergeDefaultOptions
* @param {object} optionsA
* @param {object} optionsB
* @returns {object}
* @private
*/
_mergeDefaultOptions(optionsA, optionsB = {}) {
Object.keys(optionsB).forEach(value => {
if (!optionsA.hasOwnProperty(value)) {
optionsA[value] = optionsB[value];
}
});
return optionsA;
}
/**
* Formatted image name with description and capabilities
*
* @method _formatFileName
* @param {string} tag
* @returns {string}
* @private
*/
_formatFileName(tag) {
let defaults = {
'tag': camelCase(tag),
'browserName': this.browserName,
'deviceName': this.deviceName,
'logName': this.logName,
'name': this.name,
'dpr': this.devicePixelRatio,
'width': this.width,
'height': this.height
},
formatString = this.formatString;
defaults = this._mergeDefaultOptions(defaults, this.formatOptions);
Object.keys(defaults).forEach(value => {
formatString = formatString.replace('{' + value + '}', defaults[value]);
});
return formatString + '.png';
}
/**
* Formats the webdriver capabilities and sets defaults
*
* @method _formatCapabilities
* @private
*/
_formatCapabilities() {
return browser.getProcessedConfig().then(_ => {
this.browserName = _.capabilities.browserName ? camelCase(_.capabilities.browserName) : '';
this.name = _.capabilities.name ? camelCase(_.capabilities.name) : '';
this.logName = _.capabilities.logName ? camelCase(_.capabilities.logName) : '';
this.platformName = _.capabilities.platformName ? _.capabilities.platformName.toLowerCase() : '';
this.deviceName = _.capabilities.deviceName ? camelCase(_.capabilities.deviceName) : '';
this.nativeWebScreenshot = _.capabilities.nativeWebScreenshot || false;
});
}
/**
* Promise sleep for pre-determined time
*
* @method _sleep
* @param {int} timeout in milliseconds (default: 1000)
* @returns {Promise}
* @private
*/
_sleep(timeout = 1000) {
return new Promise(resolve => setTimeout(resolve, timeout));
}
/**
* Check if browser is firefox
*
* @method _isFirefox
* @returns {boolean}
* @private
*/
_isFirefox() {
return this.browserName === 'firefox';
}
/**
* Check if browser is internet explorer
*
* @method _isInternetExplorer
* @returns {boolean}
* @private
*/
_isInternetExplorer() {
return this.browserName === 'internetExplorer';
}
/**
* Check if platformName is Android
*
* @method _isAndroid
* @returns {boolean}
* @private
*/
_isAndroid() {
return this.platformName === 'android';
}
/**
* Check if platformName is iOS
*
* @method _isIOS
* @returns {boolean}
* @private
*/
_isIOS() {
return this.platformName === 'ios';
}
/**
* Check if Mobile
*
* @method _isMobile
* @returns {boolean}
* @private
*/
_isMobile() {
return this.deviceName !== '';
}
/**
* Get the position of the element
* Firefox and IE take a screenshot of the complete page. Chrome takes a screenshot of the viewport.
*
* @method _getElementPosition
* @param {Promise} element
* @returns {Promise}
* @private
*/
_getElementPosition(element) {
if (this._isIOS()) {
return this._getElementPositionIOS(element);
} else if (this._isAndroid() && this.nativeWebScreenshot) {
this._getElementPositionAndroid(element);
} else if (this._isFirefox() || this._isInternetExplorer()) {
return this._getElementPositionTopPage(element);
}
return this._getElementPositionTopWindow(element);
}
/**
* Get the position of a given element according to the TOP of the PAGE
*
* @method _getElementPositionTopPage
* @param {Promise} element
* @returns {Promise}
* @private
*/
_getElementPositionTopPage(element) {
return element.getLocation()
.then(point => {
return {x: point.x, y: point.y};
});
}
/**
* Get the position of a given element according to the TOP of the WINDOW
*
* @method _getElementPositionTopWindow
* @param {Promise} element
* @returns {Promise}
* @private
*/
_getElementPositionTopWindow(element) {
return browser.driver.executeScript('return arguments[0].getBoundingClientRect();', element.getWebElement())
.then(position => {
return {x: position.left, y: position.top};
});
}
/**
* Get the position of a given element for the IOS Safari browser
*
* @method _getElementPositionIOS
* @param {Promise} element
* @returns {Promise}
* @private
*/
_getElementPositionIOS(element) {
function getDataObject(element, addressBar, statusBar) {
var screenHeight = window.screen.height,
screenWidth = window.screen.width,
windowInnerHeight = window.innerHeight,
rotatedScreenHeight = screenHeight > screenWidth ? screenWidth : screenHeight,
elementPosition = element.getBoundingClientRect(),
y = statusBar + addressBar + elementPosition.top;
if (screenHeight === windowInnerHeight || rotatedScreenHeight === windowInnerHeight) {
y = elementPosition.top;
}
return {
x: elementPosition.left,
y: y
};
}
return browser.driver.executeScript(getDataObject, element.getWebElement(),
this.offsets.ios.addressBar, this.offsets.ios.statusBar);
}
/**
* Get the position of a given element for the Android devices browser
*
* @method _getElementPositionAndroid
* @param {Promise} element
* @returns {Promise}
* @private
*/
_getElementPositionAndroid(element) {
function getDataObject(element, statusBarHeight, addressBarHeight, toolBarHeight) {
var elementPosition = element.getBoundingClientRect(),
screenHeight = window.screen.height,
windowInnerHeight = window.innerHeight,
addressBarCurrentHeight = 0;
if (screenHeight === (statusBarHeight + addressBarHeight + windowInnerHeight + toolBarHeight)) {
addressBarCurrentHeight = addressBarHeight;
} else if (screenHeight === (statusBarHeight + addressBarHeight + windowInnerHeight)) {
addressBarCurrentHeight = addressBarHeight;
}
return {
x: elementPosition.left,
y: statusBarHeight + addressBarCurrentHeight + elementPosition.top
};
}
return browser.driver.executeScript(getDataObject, element.getWebElement(),
this.offsets.android.statusBar, this.offsets.android.addressBar, this.offsets.android.toolBar);
}
/**
* Checks if image exists as a baseline image
*
* @method _checkImageExists
* @param {string} tag
* @returns {Promise}
* @private
*/
_checkImageExists(tag) {
return new Promise((resolve, reject) => {
fs.access(path.join(this.basePath, this._formatFileName(tag)), fs.F_OK, error => {
if (error) {
reject(!this.baseline ? error : new Error('Image not found, saving current image as new baseline.'));
} else {
resolve();
}
});
});
}
/**
* Get browser instance data
*
* @method _getBrowserData
* @returns {Promise}
* @private
*/
_getBrowserData() {
function getDataObject (isMobile) {
return {
devicePixelRatio: window.devicePixelRatio,
height: (isMobile) ? window.screen.height : window.outerHeight,
width: (isMobile) ? window.screen.width : window.outerWidth,
innerHeight: window.innerHeight,
scrollHeight: document.body.scrollHeight,
scrollWidth: document.body.scrollWidth
};
}
return browser.executeScript(getDataObject, this._isMobile())
.then(screen => {
this.devicePixelRatio = this._isFirefox() ? this.devicePixelRatio : screen.devicePixelRatio;
this.width = screen.width * this.devicePixelRatio;
this.height = screen.height * this.devicePixelRatio;
this.innerHeight = screen.innerHeight * this.devicePixelRatio;
this.pageWidth = screen.scrollWidth * this.devicePixelRatio;
this.pageHeight = screen.scrollHeight * this.devicePixelRatio;
});
}
/**
* Determine the rectangles conform the correct browser / devicePixelRatio
*
* @method _getElementRectangle
* @param {Promise} element The ElementFinder to get the rectangles of
* @returns {object} returns the correct rectangles rectangles
* @private
*/
_getElementRectangle(element) {
let rect,
size;
return element.getSize()
.then(elementSize => {
size = elementSize;
return this._getElementPosition(element);
})
.then(point => {
rect = {
height: size.height,
width: size.width,
x: Math.floor(point.x),
y: Math.floor(point.y)
};
Object.keys(rect).map(value => {
rect[value] *= this.devicePixelRatio;
});
return rect;
});
}
/**
* Scrolls the page and takes screenshots
*
* @method _takeScreenshots
* @param {int} scrollTimeout Time between scrolls in milliseconds (default: 1000)
* @returns {Promise}
* @private
*/
_takeScreenshots(scrollTimeout) {
let screens = [];
return this._getBrowserData()
.then(() => {
return [...Array(Math.ceil(this.pageHeight / this.innerHeight)).keys()].reduce((promise, i) => {
return promise.then(() => browser.driver.executeScript(`window.scrollTo(0, + ${(this.innerHeight / this.devicePixelRatio) * i});`))
.then(() => this._sleep(scrollTimeout))
.then(() => browser.takeScreenshot())
.then(image => {
screens.push(new Buffer(image, 'base64'));
});
}, Promise.resolve());
})
.then(() => Promise.resolve(screens));
}
/**
* Runs comparison against baseline screenshot with defaults
*
* @method _runComparison
* @param {string} tag Baseline image name
* @param {PNGImage} image Screenshot image
* @param {object} defaults Default Pixel-Diff options
* @param {object} options Non-default Pixel-Diff options
* @returns {Promise}
* @private
*/
_runComparison(tag, image, defaults = {}, options = {}) {
defaults = Object.assign({
imageAPath: path.join(this.basePath, tag),
imageB: new Buffer(image, 'base64'),
imageOutputPath: path.join(this.diffPath, path.basename(tag))
}, defaults);
options = Object.assign({
imageOutputLimit: PixelDiff.OUTPUT_DIFFERENT
}, options);
return new PixelDiff(this._mergeDefaultOptions(defaults, options)).runWithPromise();
}
/**
* Saves an image of the whole page
*
* @method savePage
* @example
* browser.pixdiff.savePage('imageA');
*
* @param {string} tag Baseline image name
* @param {int} scrollTimeout Time between scrolls in milliseconds (default: 1000)
* @returns {Promise}
* @reject {Error}
* @fulfil {null}
* @public
*/
savePage(tag, scrollTimeout) {
if (this._isFirefox() || this._isInternetExplorer()) {
return this.saveScreen(tag);
}
return this._takeScreenshots(scrollTimeout)
.then(screens => {
this.width = this.pageWidth;
this.height = this.pageHeight;
let png = new PNGImage({
imagePath: screens,
imageOutputPath: path.join(this.basePath, this._formatFileName(tag)),
composeOffset: (this.innerHeight * screens.length) - this.pageHeight
});
return png.compose().then(png.writeImage);
});
}
/**
* Saves an image of the screen
*
* @method saveScreen
* @example
* browser.pixdiff.saveScreen('imageA');
*
* @param {string} tag Baseline image name
* @returns {Promise}
* @reject {Error}
* @fulfil {null}
* @public
*/
saveScreen(tag) {
return this._getBrowserData()
.then(() => browser.takeScreenshot())
.then(image => {
return new PNGImage({
imagePath: new Buffer(image, 'base64'),
imageOutputPath: path.join(this.basePath, this._formatFileName(tag))
}).runWithPromise();
});
}
/**
* Saves an image of the screen region
*
* @method saveRegion
* @example
* browser.pixdiff.saveRegion(element(By.id('elementId')), 'imageA');
*
* @param {Promise} element The ElementFinder for element lookup
* @param {string} tag Baseline image name
* @returns {Promise}
* @reject {Error}
* @fulfil {null}
* @public
*/
saveRegion(element, tag) {
let rect;
return this._getBrowserData()
.then(() => this._getElementRectangle(element))
.then(elementRect => {
rect = elementRect;
return browser.takeScreenshot();
})
.then(image => {
return new PNGImage({
imagePath: new Buffer(image, 'base64'),
imageOutputPath: path.join(this.basePath, this._formatFileName(tag)),
cropImage: rect
}).runWithPromise();
});
}
/**
* Runs the comparison against the page
*
* @method checkPage
* @example
* browser.pixdiff.checkPage('imageA')
* .then(result => { console.log(result.code); });
*
* @param {string} tag Baseline image name
* @param {object} options Non-default Pixel-Diff options
* @returns {object} result
* @reject {Error} - Baseline image not found
* @fulfil {object} - PixelDiff result.code
*
* - `RESULT_UNKNOWN`: 0
* - `RESULT_DIFFERENT`: 1
* - `RESULT_SIMILAR`: 7
* - `RESULT_IDENTICAL`: 5
* @public
*/
checkPage(tag, options = {}) {
if (this._isFirefox() || this._isInternetExplorer()) {
return this.checkScreen(tag);
}
return this._takeScreenshots()
.then(screens => {
this.width = this.pageWidth;
this.height = this.pageHeight;
let png = new PNGImage({
imagePath: screens,
composeOffset: (this.innerHeight * screens.length) - this.pageHeight
});
return png.compose().then(png.toBuffer);
})
.then(image => this._runComparison(this._formatFileName(tag), image, options));
}
/**
* Runs the comparison against the screen
*
* @method checkScreen
* @example
* browser.pixdiff.checkScreen('imageA', {blockOut: [{x: 0, y: 0, width: 1366, height: 30}]})
* .then(result => { console.log(result.code); });
*
* @param {string} tag Baseline image name
* @param {object} options Non-default Pixel-Diff options
* @returns {object} result
* @reject {Error} - Baseline image not found
* @fulfil {object} - PixelDiff result.code
*
* - `RESULT_UNKNOWN`: 0
* - `RESULT_DIFFERENT`: 1
* - `RESULT_SIMILAR`: 7
* - `RESULT_IDENTICAL`: 5
* @public
*/
checkScreen(tag, options = {}) {
return this._getBrowserData()
.then(() => this._checkImageExists(tag))
.then(() => browser.takeScreenshot(), error => {
if (this.baseline) {
return this.saveScreen(tag).then(() => { throw error; });
}
throw error;
})
.then(image => this._runComparison(this._formatFileName(tag), image, options));
}
/**
* Runs the comparison against a region
*
* @method checkRegion
* @example
* browser.pixdiff.checkRegion(element(By.id('elementId')), 'imageA', {debug: true})
* .then(result => { console.log(result.code); });
*
* @param {Promise} element The ElementFinder for element lookup
* @param {string} tag Baseline image name
* @param {object} options Non-default Pixel-Diff options
* @returns {object} result
* @reject {Error} - Baseline image not found
* @fulfil {object} - PixelDiff `result.code`
*
* - `RESULT_UNKNOWN`: `0`
* - `RESULT_DIFFERENT`: `1`
* - `RESULT_SIMILAR`: `7`
* - `RESULT_IDENTICAL`: `5`
* @public
*/
checkRegion(element, tag, options = {}) {
let defaults = {};
return this._getBrowserData()
.then(() => this._checkImageExists(tag))
.then(() => this._getElementRectangle(element), (error) => {
if (this.baseline) {
return this.saveRegion(element, tag).then(() => { throw error; });
}
throw error;
})
.then(rect => {
defaults.cropImageB = rect;
return browser.takeScreenshot();
})
.then(image => this._runComparison(this._formatFileName(tag), image, defaults, options));
}
}
module.exports = PixDiff;