UNPKG

@caplin/jest-image-snapshot

Version:

Jest matcher for image comparisons. Most commonly used for visual regression testing.

367 lines (332 loc) 12.6 kB
/* * Copyright (c) 2017 American Express Travel Related Services Company, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except * in compliance with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under the License * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express * or implied. See the License for the specific language governing permissions and limitations under * the License. */ const childProcess = require('child_process'); const fs = require('fs'); const path = require('path'); const mkdirp = require('mkdirp'); const pixelmatch = require('pixelmatch'); const ssim = require('ssim.js'); const { PNG } = require('pngjs'); const rimraf = require('rimraf'); const glur = require('glur'); const ImageComposer = require('./image-composer'); /** * Helper function to create reusable image resizer */ const createImageResizer = (width, height) => (source) => { const resized = new PNG({ width, height, fill: true }); PNG.bitblt(source, resized, 0, 0, source.width, source.height, 0, 0); return resized; }; /** * Fills diff area with black transparent color for meaningful diff */ /* eslint-disable no-plusplus, no-param-reassign, no-bitwise */ const fillSizeDifference = (width, height) => (image) => { const inArea = (x, y) => y > height || x > width; for (let y = 0; y < image.height; y++) { for (let x = 0; x < image.width; x++) { if (inArea(x, y)) { const idx = ((image.width * y) + x) << 2; image.data[idx] = 0; image.data[idx + 1] = 0; image.data[idx + 2] = 0; image.data[idx + 3] = 64; } } } return image; }; /* eslint-enabled */ /** * This was originally embedded in diffImageToSnapshot * when it only worked with pixelmatch. It has a default * threshold of 0.01 defined in terms of what it means to pixelmatch. * It has been moved here as part of the SSIM implementation to make it * a little easier to read and find. * More information about this can be found under the options section listed * in https://github.com/mapbox/pixelmatch/README.md and in the original pixelmatch * code. There is also some documentation on this in our README.md under the * customDiffConfig option. * @type {{threshold: number}} */ const defaultPixelmatchDiffConfig = { threshold: 0.01, }; /** * This is the default SSIM diff configuration * for the jest-image-snapshot's use of the ssim.js * library. Bezkrovny is a specific SSIM algorithm optimized * for speed by downsampling the origin image into a smaller image. * For the small loss in precision, it is roughly 9x faster than the * SSIM preset 'fast' -- which is modeled after the original SSIM whitepaper. * Wang, et al. 2004 on "Image Quality Assessment: From Error Visibility to Structural Similarity" * (https://github.com/obartra/ssim/blob/master/assets/ssim.pdf) * Most users will never need or want to change this -- unless -- * they want to get a better quality generated diff. * @type {{ssim: string}} */ const defaultSSIMDiffConfig = { ssim: 'bezkrovny' }; /** * Helper function for SSIM comparison that allows us to use the existing diff * config that works with jest-image-snapshot to pass parameters * that will work with SSIM. It also transforms the parameters to match the spec * required by the SSIM library. */ const ssimMatch = ( newImageData, baselineImageData, diffImageData, imageWidth, imageHeight, diffConfig ) => { const newImage = { data: newImageData, width: imageWidth, height: imageHeight }; const baselineImage = { data: baselineImageData, width: imageWidth, height: imageHeight }; // eslint-disable-next-line camelcase const { ssim_map, mssim } = ssim.ssim(newImage, baselineImage, diffConfig); // Converts the SSIM value to different pixels based on image width and height // conforms to how pixelmatch works. const diffPixels = (1 - mssim) * imageWidth * imageHeight; const diffRgbaPixels = new DataView(diffImageData.buffer, diffImageData.byteOffset); for (let ln = 0; ln !== imageHeight; ++ln) { for (let pos = 0; pos !== imageWidth; ++pos) { const rpos = (ln * imageWidth) + pos; // initial value is transparent. We'll add in the SSIM offset. // red (ff) green (00) blue (00) alpha (00) const diffValue = 0xff000000 + Math.floor(0xff * (1 - ssim_map.data[ // eslint-disable-next-line no-mixed-operators (ssim_map.width * Math.round(ssim_map.height * ln / imageHeight)) + // eslint-disable-next-line no-mixed-operators Math.round(ssim_map.width * pos / imageWidth)])); diffRgbaPixels.setUint32(rpos * 4, diffValue); } } return diffPixels; }; /** * Aligns images sizes to biggest common value * and fills new pixels with transparent pixels */ const alignImagesToSameSize = (firstImage, secondImage) => { // Keep original sizes to fill extended area later const firstImageWidth = firstImage.width; const firstImageHeight = firstImage.height; const secondImageWidth = secondImage.width; const secondImageHeight = secondImage.height; // Calculate biggest common values const resizeToSameSize = createImageResizer( Math.max(firstImageWidth, secondImageWidth), Math.max(firstImageHeight, secondImageHeight) ); // Resize both images const resizedFirst = resizeToSameSize(firstImage); const resizedSecond = resizeToSameSize(secondImage); // Fill resized area with black transparent pixels return [ fillSizeDifference(firstImageWidth, firstImageHeight)(resizedFirst), fillSizeDifference(secondImageWidth, secondImageHeight)(resizedSecond), ]; }; const isFailure = ({ pass, updateSnapshot }) => !pass && !updateSnapshot; const shouldUpdate = ({ pass, updateSnapshot, updatePassedSnapshot }) => ( (!pass && updateSnapshot) || (pass && updatePassedSnapshot) ); const shouldFail = ({ totalPixels, diffPixelCount, hasSizeMismatch, allowSizeMismatch, failureThresholdType, failureThreshold, }) => { let pass = false; let diffSize = false; const diffRatio = diffPixelCount / totalPixels; if (hasSizeMismatch) { // do not fail if allowSizeMismatch is set pass = allowSizeMismatch; diffSize = true; } if (!diffSize || pass === true) { if (failureThresholdType === 'pixel') { pass = diffPixelCount <= failureThreshold; } else if (failureThresholdType === 'percent') { pass = diffRatio <= failureThreshold; } else { throw new Error(`Unknown failureThresholdType: ${failureThresholdType}. Valid options are "pixel" or "percent".`); } } return { pass, diffSize, diffRatio, }; }; function diffImageToSnapshot(options) { const { receivedImageBuffer, snapshotIdentifier, snapshotsDir, storeReceivedOnFailure, receivedDir, diffDir, diffDirection, updateSnapshot = false, updatePassedSnapshot = false, customDiffConfig = {}, failureThreshold, failureThresholdType, blur, allowSizeMismatch = false, comparisonMethod = 'pixelmatch', } = options; const comparisonFn = comparisonMethod === 'ssim' ? ssimMatch : pixelmatch; let result = {}; const baselineSnapshotPath = path.join(snapshotsDir, `${snapshotIdentifier}-snap.png`); if (!fs.existsSync(baselineSnapshotPath)) { mkdirp.sync(path.dirname(baselineSnapshotPath)); fs.writeFileSync(baselineSnapshotPath, receivedImageBuffer); result = { added: true }; } else { const receivedSnapshotPath = path.join(receivedDir, `${snapshotIdentifier}-received.png`); rimraf.sync(receivedSnapshotPath); const diffOutputPath = path.join(diffDir, `${snapshotIdentifier}-diff.png`); rimraf.sync(diffOutputPath); const defaultDiffConfig = comparisonMethod !== 'ssim' ? defaultPixelmatchDiffConfig : defaultSSIMDiffConfig; const diffConfig = Object.assign({}, defaultDiffConfig, customDiffConfig); const rawReceivedImage = PNG.sync.read(receivedImageBuffer); const rawBaselineImage = PNG.sync.read(fs.readFileSync(baselineSnapshotPath)); const hasSizeMismatch = ( rawReceivedImage.height !== rawBaselineImage.height || rawReceivedImage.width !== rawBaselineImage.width ); const imageDimensions = { receivedHeight: rawReceivedImage.height, receivedWidth: rawReceivedImage.width, baselineHeight: rawBaselineImage.height, baselineWidth: rawBaselineImage.width, }; // Align images in size if different const [receivedImage, baselineImage] = hasSizeMismatch ? alignImagesToSameSize(rawReceivedImage, rawBaselineImage) : [rawReceivedImage, rawBaselineImage]; const imageWidth = receivedImage.width; const imageHeight = receivedImage.height; if (typeof blur === 'number' && blur > 0) { glur(receivedImage.data, imageWidth, imageHeight, blur); glur(baselineImage.data, imageWidth, imageHeight, blur); } const diffImage = new PNG({ width: imageWidth, height: imageHeight }); let diffPixelCount = 0; diffPixelCount = comparisonFn( receivedImage.data, baselineImage.data, diffImage.data, imageWidth, imageHeight, diffConfig ); const totalPixels = imageWidth * imageHeight; const { pass, diffSize, diffRatio, } = shouldFail({ totalPixels, diffPixelCount, hasSizeMismatch, allowSizeMismatch, failureThresholdType, failureThreshold, }); if (isFailure({ pass, updateSnapshot })) { if (storeReceivedOnFailure) { mkdirp.sync(path.dirname(receivedSnapshotPath)); fs.writeFileSync(receivedSnapshotPath, receivedImageBuffer); } mkdirp.sync(path.dirname(diffOutputPath)); const composer = new ImageComposer({ direction: diffDirection, }); composer.addImage(baselineImage, imageWidth, imageHeight); composer.addImage(diffImage, imageWidth, imageHeight); composer.addImage(receivedImage, imageWidth, imageHeight); const composerParams = composer.getParams(); const compositeResultImage = new PNG({ width: composerParams.compositeWidth, height: composerParams.compositeHeight, }); // copy baseline, diff, and received images into composite result image composerParams.images.forEach((image, index) => { PNG.bitblt( image.imageData, compositeResultImage, 0, 0, image.imageWidth, image.imageHeight, composerParams.offsetX * index, composerParams.offsetY * index ); }); // Set filter type to Paeth to avoid expensive auto scanline filter detection // For more information see https://www.w3.org/TR/PNG-Filters.html const pngBuffer = PNG.sync.write(compositeResultImage, { filterType: 4 }); fs.writeFileSync(diffOutputPath, pngBuffer); result = { pass: false, diffSize, imageDimensions, receivedSnapshotPath, diffOutputPath, diffRatio, diffPixelCount, imgSrcString: `data:image/png;base64,${pngBuffer.toString('base64')}`, }; } else if (shouldUpdate({ pass, updateSnapshot, updatePassedSnapshot })) { mkdirp.sync(path.dirname(baselineSnapshotPath)); fs.writeFileSync(baselineSnapshotPath, receivedImageBuffer); result = { updated: true }; } else { result = { pass, diffSize, diffRatio, diffPixelCount, diffOutputPath, }; } } return result; } function runDiffImageToSnapshot(options) { options.receivedImageBuffer = options.receivedImageBuffer.toString('base64'); const serializedInput = JSON.stringify(options); let result = {}; const writeDiffProcess = childProcess.spawnSync( process.execPath, [`${__dirname}/diff-process.js`], { input: Buffer.from(serializedInput), stdio: ['pipe', 'inherit', 'inherit', 'pipe'], maxBuffer: 10 * 1024 * 1024, // 10 MB } ); if (writeDiffProcess.status === 0) { const output = writeDiffProcess.output[3].toString(); result = JSON.parse(output); } else { throw new Error(`Error running image diff: ${(writeDiffProcess.error && writeDiffProcess.error.message) || 'Unknown Error'}`); } return result; } module.exports = { diffImageToSnapshot, runDiffImageToSnapshot, };