codeceptjs-pixelmatchhelper
Version:
Pixelmatch helper for CodeceptJS, with support for Playwright, Webdriver, TestCafe, Puppeteer & Appium
1,141 lines (984 loc) • 30.2 kB
JavaScript
const fs = require('fs');
const PNG = require('pngjs').PNG;
const pixelmatch = require('pixelmatch');
const path = require('path');
const Helper = require('@codeceptjs/helper');
/**
* Helper class that integrates pixelmatch into CodeceptJS for visual regression
* tests.
*
* helpers: {
* PixelmatchHelper: {
* require: "codeceptjs-pixelmatchhelper",
* dirExpected: "./tests/screenshots/base/",
* dirDiff: "./tests/screenshots/diff/",
* dirActual: "./tests/output/", // Optional. Defaults to global.output_dir.
* diffPrefix: "Diff_" // Optional. Defaults to "Diff_"
* tolerance: 1.5,
* threshold: 0.1,
* dumpIntermediateImage: false,
* captureActual: true,
* captureExpected: true
* }
* }
*
* @author Philipp Stracker
*/
class PixelmatchHelper extends Helper {
/**
* Relative path to the folder that contains relevant images.
*
* @type {{expected: string, actual: string, diff: string}}
*/
globalDir = {
expected: '',
actual: '',
diff: ''
};
/**
* Default tolserance level for comparisons.
*
* @type {float}
*/
globalTolerance = 0;
/**
* Default threshold for all comparisons.
*
* @type {float}
*/
globalThreshold = 0.05;
/**
* Filename prefix for generated difference files.
* @type {string}
*/
globalDiffPrefix = 'Diff_';
/**
* Whether to save the intermediate images to the global output folder,
* after applying the bounds and ignore-boxes.
*
* Useful for debugging tests, but not recommended for production usage.
*
* @type {boolean}
*/
globalDumpIntermediateImage = false;
/**
* Whether to capture a new screenshot and use it as actual image, instead
* of loading the image from the `dirActual` folder.
*
* The new screenshot is saved to the `dirActual` folder before comparison,
* and will replace an existing file with the same name!
*
* @type {boolean|'missing'}
*/
globalCaptureActual = 'missing';
/**
* Whether to update the expected base image with a current screenshot
* before starting the comparison.
*
* The new screenshot is saved to the `dirExpected` folder, and will
* replace an existing file with the same name!
*
* @type {boolean|'missing'}
*/
globalCaptureExpected = 'missing';
/**
* Contains the image paths for the current test.
*
* @type {{expected: string, actual: string, diff: string}}
*/
path = {
expected: '',
actual: '',
diff: ''
};
/**
* Comparison options.
*
* @type {object}
*/
options = {
// Percentage of pixels that are allowed to differ between both images.
tolerance: 0,
// Defines a custom comparison image name.
compareWith: '',
// Only compare a single HTML element. Used to calculate a bounding box.
element: '',
// Only used, when element is not set. Only pixels inside this box are compared.
bounds: {
left: 0,
top: 0,
width: 0,
height: 0
},
// List of boxes to ignore. Each box is an object with {left, top, width, height}.
ignore: [],
// Arguments that are passed to the pixelmatch library.
args: {
threshold: 0.1,
alpha: 0.5,
includeAA: false,
diffMask: false,
aaColor: [128, 128, 128],
diffColor: [255, 0, 0],
diffColorAlt: null
},
// Whether to dump intermediate images before comparing them.
dumpIntermediateImage: false,
// Whether to take a screenshot for the actual image before comparison.
captureActual: 'missing',
// Whether to take a screenshot for the expected image before comparison.
captureExpected: 'missing'
};
/**
* Name of the image to compare.
*
* @type {string}
*/
imageName = '';
/**
* Holds comparison results.
*
* @type {{match: boolean, difference: float, diffImage: string, diffPixels: integer,
* totalPixels: integer, relevantPixels: integer}}
*/
result = {
match: true,
difference: 0,
diffImage: '',
diffPixels: 0,
totalPixels: 0,
relevantPixels: 0,
variation: '',
variations: []
};
/**
* Constructor that initializes the helper.
* Called internally by CodeceptJS.
*
* @param {object} config
*/
constructor(config) {
super(config);
if (config.dirExpected) {
this.globalDir.expected = this._resolvePath(config.dirExpected);
} else {
this.globalDir.expected = this._resolvePath('./tests/screenshots/base/');
}
if ('undefined' !== typeof config.dirDiff) {
if (config.dirDiff) {
this.globalDir.diff = this._resolvePath(config.dirDiff);
} else {
this.globalDir.diff = false;
}
} else {
this.globalDir.diff = this._resolvePath('./tests/screenshots/diff/');
}
if (config.dirActual) {
this.globalDir.actual = this._resolvePath(config.dirActual);
} else {
this.globalDir.actual = global.output_dir + '/';
}
this.globalDir.output = global.output_dir + '/';
if ('undefined' !== typeof config.tolerance) {
this.globalTolerance = Math.min(100, Math.max(0, parseFloat(config.tolerance)));
}
if ('undefined' !== typeof config.threshold) {
this.globalThreshold = Math.min(1, Math.max(0, parseFloat(config.threshold)));
}
this.globalDiffPrefix = config.diffPrefix ? config.diffPrefix : 'Diff_';
this.globalDumpIntermediateImage = this._toBool(config.dumpIntermediateImage);
if ('undefined' !== typeof config.captureActual) {
this.globalCaptureActual = this._toBool(config.captureActual, ['missing']);
}
if ('undefined' !== typeof config.captureExpected) {
this.globalCaptureExpected = this._toBool(config.captureExpected, ['missing']);
}
}
/**
* Compares the given screenshot with the expected image. When too many
* differences are detected, the test will fail.
*
* I.checkVisualDifferences('dashboard.png');
* I.checkVisualDifferences('dashboard.png', { screenshot: true });
*
* @param {string} image - Name of the input image to compare.
* @param {object} options - Optional options for the comparison.
* @return {Promise}
*/
checkVisualDifferences(image, options) {
return new Promise(async (resolve, reject) => {
try {
await this.getVisualDifferences(image, options);
} catch (err) {
reject(err);
}
const res = this.result;
this.debug(`Difference: ${res.difference}% | ${res.diffPixels} / ${res.relevantPixels} pixels`);
if (res.match) {
resolve(res);
} else {
const msg = [];
msg.push(`Images are different by ${res.difference}%`);
if (res.diffImage) {
msg.push(`differences are displayed in '${res.diffImage}'`);
}
reject(msg.join(' - '));
}
});
}
/**
* Compares the given screenshot with the expected image and updates the
* class member `this.result` with details. This function does to trigger an
* assertion but can throw an error, when the images cannot be compared.
*
* @param {string} image - Name of the input image to compare.
* @param {object} options - Optional options for the comparison.
* @return {{match: boolean, difference: float}} Comparison details.
*/
async getVisualDifferences(image, options) {
await this._setupTest(image, options);
const opts = this.options;
const res = this.result;
this.debug(`Check differences in ${image} ...`);
await this._maybeCaptureImage('actual', opts.captureActual);
await this._maybeCaptureImage('expected', opts.captureExpected);
const expectedImages = this._getExpectedImagePaths();
if (!expectedImages.length) {
throw new Error('No expected base image found');
}
const imgActual = this._loadPngImage('actual');
if (!imgActual.height) {
throw new Error('Current screenshot is empty (zero height)');
}
const width = imgActual.width;
const height = imgActual.height;
const totalPixels = width * height;
const ignoredPixels = this._applyBounds(imgActual);
const results = [];
let bestIndex = 0;
let bestDifference = totalPixels;
let bestImgDiff;
const imgDiff = new PNG({
width,
height
});
if (opts.dumpIntermediateImage) {
this._savePngImage('output', imgActual, 'actual');
}
// Compare the actual image with every base image in the list.
for (let i = 0; i < expectedImages.length; i++) {
const imgPath = expectedImages[i];
const imgExpected = this._loadPngImage(imgPath);
if (imgExpected.width !== imgActual.width || imgExpected.height !== imgActual.height) {
throw new Error('Image sizes do not match');
}
this._applyBounds(imgExpected);
if (opts.dumpIntermediateImage) {
this._savePngImage('output', imgExpected, 'expected.' + (i ? i : ''));
}
results[i] = {};
results[i].diffPixels = pixelmatch(
imgExpected.data,
imgActual.data,
imgDiff.data,
width,
height,
opts.args
);
results[i].totalPixels = totalPixels;
results[i].relevantPixels = totalPixels - ignoredPixels;
const difference = 100 * results[i].diffPixels / results[i].relevantPixels;
results[i].difference = parseFloat(difference.toFixed(4));
results[i].match = results[i].difference <= opts.tolerance;
if (-1 !== imgPath.indexOf('~')) {
results[i].variation = imgPath.replace(/\.png$|^.*~/g, '');
} else {
results[i].variation = '';
}
if (!results[i].match && this.globalDir.diff) {
results[i].diffImage = this._getFileName('diff', i);
} else {
results[i].diffImage = '';
}
// Keep track of the best match.
if (results[i].diffPixels < bestDifference) {
// Remember the serialized PNG, because the imgDiff object is
// a reference that might be updated before the loop ends.
bestImgDiff = PNG.sync.write(imgDiff);
bestDifference = results[i].diffPixels;
bestIndex = i;
}
}
// Use the best match as return value.
for (const key in res) {
if (!res.hasOwnProperty(key)) {
continue;
}
res[key] = results[bestIndex][key];
}
// Add the dynamic property `variations` that lists all comparisons.
res.variations = results;
// Only create a diff-image of the best-matching variation.
if (!res.match) {
this._savePngImage('diff', bestImgDiff, res.variation);
}
return res;
}
/**
* Take screenshot of individual element.
*
* @param {string} name - Name of the output image.
* @param {'actual'|'expected'} which - Optional. Whether the screenshot is
* the expected bas eimage, or an actual image for comparison.
* Defaults to 'actual'.
* @param {string} element - Optional. Selector of the element to
* screenshot, or empty to screenshot current viewport.
* @returns {Promise}
*/
async takeScreenshot(name, which, element) {
await this._setupTest(name);
if (element) {
await this._takeElementScreenshot(name, which, element);
} else {
await this._takeScreenshot(name, which);
}
}
/**
* Takes a screenshot of the entire viewport and saves it as either an
* actual image, or an expected base-image.
*
* @param {string} name - Name of the output image.
* @param {'actual'|'expected'} which - Optional. Whether the screenshot is
* the expected bas eimage, or an actual image for comparison.
* Defaults to 'actual'.
* @param {string} element - Optional. Selector of the element to
* screenshot, or empty to screenshot current viewport.
* @private
*/
async _takeElementScreenshot(name, which, element) {
const driver = this._getDriver();
// The output path where the screenshot is saved to.
const outputFile = this._buildPath('expected' === which ? which : 'actual');
// Screenshot a single element.
await driver.waitForVisible(element);
const els = await driver._locate(element);
if ('TestCafe' === driver._which) {
if (!await els.count) {
throw new Error(`Element ${element} couldn't be located`);
}
await driver.t.takeElementScreenshot(els, outputFile);
} else {
if (!els.length) {
throw new Error(`Element ${element} couldn't be located`);
}
const el = els[0];
switch (driver._which) {
case 'Playwright':
case 'Puppeteer':
await el.screenshot({path: outputFile});
break;
case 'WebDriver':
case 'Appium':
await el.saveScreenshot(outputFile);
break;
}
}
}
/**
* Takes a screenshot of the entire viewport and saves it as either an
* actual image, or an expected base-image.
*
* @param {string} name - Name of the output image.
* @param {'actual'|'expected'} which - Optional. Whether the screenshot is
* the expected bas eimage, or an actual image for comparison.
* Defaults to 'actual'.
* @private
*/
async _takeScreenshot(name, which) {
const driver = this._getDriver();
// The output path where the screenshot is saved to.
const outputFile = this._buildPath('expected' === which ? which : 'actual');
// We need a dynamic temp-name here: When the helper is used with
// the `run-workers` option, multiple workers might access a temp
// file at the same time.
const uid = Math.random().toString(36).slice(-5);
const tempName = `~${uid}.temp.png`;
// Screenshot the current viewport into a temp file.
await driver.saveScreenshot(tempName);
this._deleteFile(outputFile);
// Move the temp file to the correct folder and rename the file.
fs.renameSync(global.output_dir + '/' + tempName, outputFile);
this._deleteFile(global.output_dir + '/' + tempName);
}
/**
* Clears pixels in the specified image that are outside the bounding rect
* or inside an ignored area.
*
* @param {PNG} png - The image to modify.
* @return {int} Number of cleared pixels.
* @private
*/
_applyBounds(png) {
const opts = this.options;
let cleared = 0;
const useBounds = opts.bounds.left
|| opts.bounds.top
|| opts.bounds.width
|| opts.bounds.height;
// Apply a bounding box to only compare a section of the image.
if (useBounds) {
this.debug(`Apply bounds to image ...`);
const box = {
x0: 0,
x1: opts.bounds.left,
x2: opts.bounds.left + opts.bounds.width,
x3: png.width,
y0: 0,
y1: opts.bounds.top,
y2: opts.bounds.top + opts.bounds.height,
y3: png.height
};
cleared += this._clearRect(png, box.x0, box.y0, box.x1, box.y3);
cleared += this._clearRect(png, box.x1, box.y0, box.x3, box.y1);
cleared += this._clearRect(png, box.x1, box.y2, box.x3, box.y3);
cleared += this._clearRect(png, box.x2, box.y1, box.x3, box.y2);
}
// Clear areas that are ignored.
for (let i = 0; i < opts.ignore.length; i++) {
cleared += this._clearRect(
png,
opts.ignore[i].left,
opts.ignore[i].top,
opts.ignore[i].left + opts.ignore[i].width,
opts.ignore[i].top + opts.ignore[i].height
);
}
return cleared;
}
/**
* Determines the bounding box of the given element on the current viewport.
*
* @param {string} selector - CSS|XPath|ID selector.
* @returns {Promise<{boundingBox: {left: int, top: int, right: int, bottom: int, width: int,
* height: int}}>}
*/
async _getBoundingBox(selector) {
const driver = this._getDriver();
await driver.waitForVisible(selector);
const els = await driver._locate(selector);
let location, size;
if ('TestCafe' === driver._which) {
if (await els.count != 1) {
throw new Error(`Element ${selector} couldn't be located or isn't unique on the page`);
}
} else if (!els.length) {
throw new Error(`Element ${selector} couldn't be located`);
}
const density = parseInt(await driver.executeScript(() => {
return window.devicePixelRatio;
})) || 1;
switch (driver._which) {
case 'Puppeteer':
case 'Playwright': {
const el = els[0];
const box = await el.boundingBox();
size = location = box;
}
break;
case 'WebDriver':
case 'Appium': {
const el = els[0];
location = await el.getLocation();
size = await el.getSize();
}
break;
case 'WebDriverIO':
location = await driver.browser.getLocation(selector);
size = await driver.browser.getElementSize(selector);
break;
case 'TestCafe': {
const box = await els.boundingClientRect;
location = {
x: box.left,
y: box.top
};
size = {
width: box.width,
height: box.height
};
}
}
if (!size) {
throw new Error('Cannot get element size!');
}
const boundingBox = {
left: density * location.x,
top: density * location.y,
right: density * (size.width + location.x),
bottom: density * (size.height + location.y),
width: density * size.width,
height: density * size.height
};
this.debugSection(`Bounding box of ${selector}:`, JSON.stringify(boundingBox));
return boundingBox;
}
/**
* Captures the expected or actual image, depending on the captureFlag.
*
* @param {string} which - Which image to capture: 'expected', 'actual'.
* @param {bool|string} captureFlag - Either true, false or 'missing'.
* @private
*/
async _maybeCaptureImage(which, captureFlag) {
if (false === captureFlag) {
return;
}
if ('missing' === captureFlag) {
const path = this._buildPath(which);
if (this._isFile(path, 'read')) {
// Not missing: Exact image match.
return;
}
if ('expected' === which && this._getExpectedImagePaths().length) {
// Not missing: Expected image variation(s) found.
return;
}
}
await this._takeScreenshot(this.imageName, which);
}
/**
* Sanitizes the given options and updates all relevant class members with
* either the new, sanitized value, or with a default value.
*
* @param {string} image - Name of the image to compare.
* @param {object|undefined} options - The new options to set.
* @private
*/
async _setupTest(image, options) {
// Set the name of the current image.
this.imageName = image.replace(/(~.+)?\.png$/, '');
// Reset the previous test results.
this.result = {
match: true,
difference: 0,
diffImage: '',
diffPixels: 0,
totalPixels: 0,
relevantPixels: 0,
variation: '',
variations: []
};
// Define the default options.
const newValues = {
tolerance: this.globalTolerance,
compareWith: '',
element: '',
bounds: {
left: 0,
top: 0,
width: 0,
height: 0
},
ignore: [],
args: {
threshold: this.globalThreshold,
alpha: 0.5,
includeAA: false,
diffMask: false,
aaColor: [128, 128, 128],
diffColor: [255, 0, 0],
diffColorAlt: null
},
dumpIntermediateImage: this.globalDumpIntermediateImage,
captureActual: this.globalCaptureActual,
captureExpected: this.globalCaptureExpected
};
if (options && 'object' === typeof options) {
// Sanitize the allowed tolerance [percent].
if ('undefined' !== typeof options.tolerance) {
newValues.tolerance = Math.max(0, parseFloat(options.tolerance));
}
// Maybe define a custom filename for the expected image file.
if ('undefined' !== typeof options.compareWith) {
newValues.compareWith = options.compareWith;
}
// Set bounding box, either via element selector or a rectangle.
if (options.element) {
const bounds = await this._getBoundingBox(options.element);
newValues.element = options.element;
newValues.bounds.left = bounds.left;
newValues.bounds.top = bounds.top;
newValues.bounds.width = bounds.width;
newValues.bounds.height = bounds.height;
} else if (options.bounds && 'object' === typeof options.bounds) {
newValues.bounds.left = parseInt(options.bounds.left);
newValues.bounds.top = parseInt(options.bounds.top);
newValues.bounds.width = parseInt(options.bounds.width);
newValues.bounds.height = parseInt(options.bounds.height);
}
// Sanitize ignored regions.
if (options.ignore) {
for (let i = 0; i < options.ignore.length; i++) {
const item = options.ignore[i];
if (
'object' === typeof item
&& 'undefined' !== typeof item.left
&& 'undefined' !== typeof item.top
&& 'undefined' !== typeof item.width
&& 'undefined' !== typeof item.height
) {
newValues.ignore.push({
left: parseInt(item.left),
top: parseInt(item.top),
width: parseInt(item.width),
height: parseInt(item.height)
});
}
}
}
// Add pixelmatch arguments.
if (options.args && 'object' === typeof options.args) {
for (const key in options.args) {
if (!options.args.hasOwnProperty(key)) {
continue;
}
newValues.args[key] = options.args[key];
}
}
// Debug: Dump intermediate images.
if ('undefined' !== typeof options.dumpIntermediateImage) {
newValues.dumpIntermediateImage = this._toBool(options.dumpIntermediateImage);
}
// Capture screenshots before comparison?
if ('undefined' !== typeof options.captureActual) {
newValues.captureActual = this._toBool(options.captureActual, ['missing']);
}
if ('undefined' !== typeof options.captureExpected) {
newValues.captureExpected = this._toBool(options.captureExpected, ['missing']);
}
}
this.options = newValues;
// Prepare paths for the current operation.
this.path.expected = this._buildPath('expected');
this.path.actual = this._buildPath('actual');
// Diff-image generation might be disabled.
if (this.globalDir.diff) {
this.path.diff = this._buildPath('diff');
}
}
/**
* Returns the instance of the current browser driver.
*
* @return {Puppeteer|WebDriver|Appium|WebDriverIO|TestCafe}
* @private
*/
_getDriver() {
let driver = null;
if (this.helpers['Puppeteer']) {
driver = this.helpers['Puppeteer'];
driver._which = 'Puppeteer';
} else if (this.helpers['WebDriver']) {
driver = this.helpers['WebDriver'];
driver._which = 'WebDriver';
} else if (this.helpers['Appium']) {
driver = this.helpers['Appium'];
driver._which = 'Appium';
} else if (this.helpers['WebDriverIO']) {
driver = this.helpers['WebDriverIO'];
driver._which = 'WebDriverIO';
} else if (this.helpers['TestCafe']) {
driver = this.helpers['TestCafe'];
driver._which = 'TestCafe';
} else if (this.helpers['Playwright']) {
driver = this.helpers['Playwright'];
driver._which = 'Playwright';
} else {
throw new Error(
'Unsupported driver. The pixelmatch helper supports [Playwright|WebDriver|Appium|Puppeteer|TestCafe]');
}
return driver;
}
/**
* Recursively creates the specified directory.
*
* @param dir
* @private
*/
_mkdirp(dir) {
fs.mkdirSync(dir, {recursive: true});
}
/**
* Deletes the specified file, if it exists.
*
* @param {string} file - The file to delete.
* @private
*/
_deleteFile(file) {
try {
if (this._isFile(file)) {
fs.unlinkSync(file);
}
} catch (err) {
throw new Error(`Could not delete target file "${file}" - is it read-only?`);
}
}
/**
* Tests, if the given file exists..
*
* @param {string} file - The file to check.
* @param {string} mode - Optional. Either empty, or 'read'/'write' to
* validate that the current user can either read or write the file.
* @private
*/
_isFile(file, mode) {
let accessFlag = fs.constants.F_OK;
if ('read' === mode) {
accessFlag |= fs.constants.R_OK;
} else if ('write' === mode) {
accessFlag |= fs.constants.W_OK;
}
try {
// If access permission fails, an error is thrown.
fs.accessSync(file, accessFlag);
return true;
} catch (err) {
if ('ENOENT' !== err.code) {
console.error(err.code + ': ' + file);
}
return false;
}
}
/**
* Builds the absolute path to a relative folder.
*
* @param {string} dir - The relative folder name.
* @returns {string}
* @private
*/
_resolvePath(dir) {
if (!path.isAbsolute(dir)) {
return path.resolve(global.codecept_dir, dir) + '/';
}
return dir;
}
/**
* Returns the filename of an image.
*
* @param {string} which - Which image to return (expected, actual, diff).
* @param {string} suffix - Optional. A suffix to append to the filename.
* @private
*/
_getFileName(which, suffix) {
let filename;
// Define a custom filename for the expected image.
if ('expected' === which && this.options.compareWith) {
filename = this.options.compareWith;
} else {
filename = this.imageName;
}
if ('.png' !== filename.substr(-4)) {
filename += '.png';
}
if ('diff' === which) {
const parts = filename.split(/[\/\\]/);
parts[parts.length - 1] = this.globalDiffPrefix + parts[parts.length - 1];
filename = parts.join(path.sep);
}
if (suffix) {
suffix = '.' + suffix.toString().replace(/(^\.+|\.+$)/g, '') + '.png';
filename = filename.substr(0, filename.length - 4) + suffix;
}
return filename;
}
/**
* Builds an image path using the current image name and the specified folder.
*
* @param {string} which - The image to load (expected, actual, diff).
* @param {string} suffix - Optional. A suffix to append to the filename.
* @returns {string} Path to the image.
* @private
*/
_buildPath(which, suffix) {
let fullPath;
const dir = this.globalDir[which];
if (!dir) {
if ('diff' === which) {
// Diff image generation is disabled.
return '';
}
if (path.isAbsolute(which) && this._isFile(which)) {
fullPath = which;
} else {
throw new Error(`No ${which}-folder defined.`);
}
} else {
fullPath = dir + this._getFileName(which, suffix);
this._mkdirp(path.dirname(fullPath));
}
return fullPath;
}
/**
* Returns a list of absolute image paths of base images for the comparison.
* All files in the returned list exist in the filesystem.
*
* Naming convention:
*
* Files that contain a trailing "~<num>" suffix are considered part of the
* matching list.
*
* For example:
*
* image: "google-home"
* files:
* "google-home.png" # exact match
* "google-home~1.png" # variation
* "google-home~83.png" # variation
*
* @return {string[]}
* @private
*/
_getExpectedImagePaths() {
const list = [];
const fullPath = this._buildPath('expected');
const dir = path.dirname(fullPath);
const file = path.basename(fullPath);
const re = new RegExp('^' + file.replace('.png', '(:?~.+)?\\.png') + '$');
this._mkdirp(dir);
fs.readdirSync(dir).map(fn => {
if (fn.match(re)) {
list.push(`${dir}/${fn}`);
}
});
return list;
}
/**
* Loads the specified image and returns a PNG blob.
*
* @param {string} which - The image to load (expected, actual, diff).
* @param {string} suffix - Optional. A suffix to append to the filename.
* @return {object} An PNG object.
* @private
*/
_loadPngImage(which, suffix) {
const path = this._buildPath(which, suffix);
if (!path) {
throw new Error(`No ${which}-image defined.`);
}
this.debug(`Load image from ${path} ...`);
if (!this._isFile(path, 'read')) {
throw new Error(`The ${which}-image does not exist at "${path}"`);
}
const data = fs.readFileSync(path);
return PNG.sync.read(data);
}
/**
* Saves the specified PNG image to the filesystem.
* .
* @param {string} which - The image to load (expected, actual, diff).
* @param {object} png - An PNG image object.
* @param {string} suffix - Optional. A suffix to append to the filename.
* @private
*/
_savePngImage(which, png, suffix) {
const path = this._buildPath(which, suffix);
if (!path) {
if ('diff' === which) {
// Diff generation can be disabled by setting the path to
// false/empty. This is not an error.
return;
} else {
throw new Error(`No ${which}-image defined.`);
}
}
this.debug(`Save image to ${path} ...`);
if (this._isFile(path) && !this._isFile(path, 'write')) {
throw new Error(`Cannot save the ${which}-image to ${path}. Maybe the file is read-only.`);
}
let data;
if (png instanceof PNG) {
data = PNG.sync.write(png);
} else if (png instanceof Buffer) {
data = png;
}
if (data && data instanceof Buffer) {
fs.writeFileSync(path, data);
}
}
/**
* Clears a rectangular area inside the given PNG image object. The change
* is only applied in-memory and does not affect the saved image.
*
* @param {object} png - The PNG object.
* @param {int} x0
* @param {int} y0
* @param {int} x1
* @param {int} y1
* @return {int} Number of cleared pixels.
* @private
*/
_clearRect(png, x0, y0, x1, y1) {
let count = 0;
x0 = Math.min(png.width, Math.max(0, parseInt(x0)));
x1 = Math.min(png.width, Math.max(0, parseInt(x1)));
y0 = Math.min(png.height, Math.max(0, parseInt(y0)));
y1 = Math.min(png.height, Math.max(0, parseInt(y1)));
if (x0 === x1 || y0 === y1) {
return 0;
}
if (x1 < x0) {
const xt = x1;
x1 = x0;
x0 = xt;
}
if (y1 < y0) {
const yt = y1;
y1 = y0;
y0 = yt;
}
const numBytes = 4 + (x1 - x0);
for (let y = y0; y < y1; y++) {
for (let x = x0; x < x1; x++) {
const k = 4 * (x + png.width * y);
if (png.data[k + 3] > 0) {
count++;
}
png.data.fill(0, k, k + 4);
}
}
this.debug(`Clear Rect ${x0}/${y0} - ${x1}/${y1} | ${count} pixels cleared`);
// Return the real number of cleared pixels.
return count;
}
/**
* Casts the given value into a boolean. Several string terms are translated
* to boolean true. If validTerms are specified, and the given value matches
* one of those validTerms, the term is returned instead of a boolean.
*
* Sample:
*
* _toBool('yes') --> true
* _toBool('n') --> false
* _toBool('any') --> false
* _toBool('ANY', ['any', 'all']) --> 'any'
*
* @param {any} value - The value to cast.
* @param {array} validTerms - List of terms that should not be cast to a
* boolean but returned directly.
* @return {bool|string} Either a boolean or a lowercase string.
* @private
*/
_toBool(value, validTerms) {
if (true === value || false === value) {
return value;
}
if (value && 'string' === typeof value) {
value = value.toLowerCase();
return -1 !== ['1', 'on', 'y', 'yes', 'true', 'always'].indexOf(value);
}
if (validTerms && Array.isArray(validTerms) && -1 !== validTerms.indexOf(value)) {
return value;
}
return !!value;
}
}
module.exports = PixelmatchHelper;