diff --git a/__tests__/__snapshots__/integration.spec.js.snap b/__tests__/__snapshots__/integration.spec.js.snap index 4e86c88..c5136b6 100644 --- a/__tests__/__snapshots__/integration.spec.js.snap +++ b/__tests__/__snapshots__/integration.spec.js.snap @@ -1,3 +1,11 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`toMatchImageSnapshot failures fails gracefully with a differently sized image 3`] = `"toMatchImageSnapshot(): Received image size must match baseline snapshot size in order to make comparison."`; +exports[`toMatchImageSnapshot failures fails with a differently sized images and outputs diff 3`] = ` +"Expected image to match or be a close match to snapshot but was 83.85395537525355% different from snapshot (18603 differing pixels). +See diff for details: ./__tests__/__image_snapshots__/__diff_output__/cleanup-required-4-diff.png" +`; + +exports[`toMatchImageSnapshot failures fails with images without diff pixels after being resized 3`] = ` +"Expected image to match or be a close match to snapshot but was 54.662222222222226% different from snapshot (12299 differing pixels). +See diff for details: ./__tests__/__image_snapshots__/__diff_output__/cleanup-required-5-diff.png" +`; diff --git a/__tests__/integration.spec.js b/__tests__/integration.spec.js index 179750d..d95a963 100644 --- a/__tests__/integration.spec.js +++ b/__tests__/integration.spec.js @@ -19,14 +19,14 @@ const uniqueId = require('lodash/uniqueId'); const isPng = require('is-png'); describe('toMatchImageSnapshot', () => { - const imagePath = path.resolve(__dirname, './stubs', 'TestImage.png'); - const imageData = fs.readFileSync(imagePath); + const fromStubs = file => path.resolve(__dirname, './stubs', file); + const imageData = fs.readFileSync(fromStubs('TestImage.png')); const diffOutputDir = (snapshotsDir = '__image_snapshots__') => path.join(snapshotsDir, '/__diff_output__/'); const customSnapshotsDir = path.resolve(__dirname, '__custom_snapshots_dir__'); const cleanupRequiredIndicator = 'cleanup-required-'; - const getIdentifierIndicatingCleanupIsRequired = () => uniqueId(cleanupRequiredIndicator); const getSnapshotFilename = identifier => `${identifier}-snap.png`; + const diffExists = identifier => fs.existsSync(path.join(__dirname, diffOutputDir(), `${identifier}-diff.png`)); beforeAll(() => { const { toMatchImageSnapshot } = require('../src'); // eslint-disable-line global-require @@ -79,18 +79,14 @@ describe('toMatchImageSnapshot', () => { () => expect(imageData).toMatchImageSnapshot({ customSnapshotIdentifier }) ).not.toThrowError(); - expect( - fs.existsSync(path.join(__dirname, diffOutputDir(), `${customSnapshotIdentifier}-diff.png`)) - ).toBe(false); + expect(diffExists(customSnapshotIdentifier)).toBe(false); }); }); describe('failures', () => { - const failImagePath = path.resolve(__dirname, './stubs', 'TestImageFailure.png'); - const failImageData = fs.readFileSync(failImagePath); - - const oversizeImagePath = path.resolve(__dirname, './stubs', 'TestImageFailureOversize.png'); - const oversizeImageData = fs.readFileSync(oversizeImagePath); + const failImageData = fs.readFileSync(fromStubs('TestImageFailure.png')); + const oversizeImageData = fs.readFileSync(fromStubs('TestImageFailureOversize.png')); + const biggerImageData = fs.readFileSync(fromStubs('TestImage150x150.png')); it('fails for a different snapshot', () => { const expectedError = /^Expected image to match or be a close match to snapshot but was 86\.55000000000001% different from snapshot \(8655 differing pixels\)\./; @@ -107,7 +103,7 @@ describe('toMatchImageSnapshot', () => { ).toThrowError(expectedError); }); - it('fails gracefully with a differently sized image', () => { + it('fails with a differently sized images and outputs diff', () => { const customSnapshotIdentifier = getIdentifierIndicatingCleanupIsRequired(); // First we need to write a new snapshot image @@ -119,6 +115,22 @@ describe('toMatchImageSnapshot', () => { expect( () => expect(oversizeImageData).toMatchImageSnapshot({ customSnapshotIdentifier }) ).toThrowErrorMatchingSnapshot(); + + expect(diffExists(customSnapshotIdentifier)).toBe(true); + }); + + it('fails with images without diff pixels after being resized', () => { + const customSnapshotIdentifier = getIdentifierIndicatingCleanupIsRequired(); + + expect( + () => expect(imageData).toMatchImageSnapshot({ customSnapshotIdentifier }) + ).not.toThrowError(); + + expect( + () => expect(biggerImageData).toMatchImageSnapshot({ customSnapshotIdentifier }) + ).toThrowErrorMatchingSnapshot(); + + expect(diffExists(customSnapshotIdentifier)).toBe(true); }); it('writes a result image for failing tests', () => { @@ -143,7 +155,6 @@ describe('toMatchImageSnapshot', () => { it('removes result image from previous test runs for the same snapshot', () => { const customSnapshotIdentifier = getIdentifierIndicatingCleanupIsRequired(); - const pathToResultImage = path.join(__dirname, diffOutputDir(), `${customSnapshotIdentifier}-diff.png`); // First we need to write a new snapshot image expect( () => expect(imageData).toMatchImageSnapshot({ customSnapshotIdentifier }) @@ -159,7 +170,7 @@ describe('toMatchImageSnapshot', () => { () => expect(imageData).toMatchImageSnapshot({ customSnapshotIdentifier }) ).not.toThrowError(); - expect(fs.existsSync(pathToResultImage)).toBe(false); + expect(diffExists(customSnapshotIdentifier)).toBe(false); }); }); }); diff --git a/__tests__/stubs/TestImage150x150.png b/__tests__/stubs/TestImage150x150.png new file mode 100644 index 0000000..744958b Binary files /dev/null and b/__tests__/stubs/TestImage150x150.png differ diff --git a/src/diff-snapshot.js b/src/diff-snapshot.js index f23b50c..ca067a5 100644 --- a/src/diff-snapshot.js +++ b/src/diff-snapshot.js @@ -20,6 +20,61 @@ const pixelmatch = require('pixelmatch'); const mkdirp = require('mkdirp'); const { PNG } = require('pngjs'); +/** + * 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 */ + +/** + * 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), + ]; +}; + function diffImageToSnapshot(options) { const { receivedImageBuffer, @@ -45,13 +100,16 @@ function diffImageToSnapshot(options) { const diffConfig = Object.assign({}, defaultDiffConfig, customDiffConfig); - const receivedImage = PNG.sync.read(receivedImageBuffer); - const baselineImage = PNG.sync.read(fs.readFileSync(baselineSnapshotPath)); - - if ( - receivedImage.height !== baselineImage.height || receivedImage.width !== baselineImage.width - ) { - throw new Error('toMatchImageSnapshot(): Received image size must match baseline snapshot size in order to make comparison.'); + let receivedImage = PNG.sync.read(receivedImageBuffer); + let baselineImage = PNG.sync.read(fs.readFileSync(baselineSnapshotPath)); + const hasSizeMismatch = ( + receivedImage.height !== baselineImage.height || + receivedImage.width !== baselineImage.width + ); + // Align images in size if different + if (hasSizeMismatch) { + const resizedImages = alignImagesToSameSize(receivedImage, baselineImage); + [receivedImage, baselineImage] = resizedImages; } const imageWidth = receivedImage.width; const imageHeight = receivedImage.height; @@ -69,7 +127,7 @@ function diffImageToSnapshot(options) { const totalPixels = imageWidth * imageHeight; const diffRatio = diffPixelCount / totalPixels; - let pass = false; + let pass = !hasSizeMismatch; if (failureThresholdType === 'pixel') { pass = diffPixelCount <= failureThreshold; } else if (failureThresholdType === 'percent') { diff --git a/src/index.js b/src/index.js index 90304bc..cc97f13 100644 --- a/src/index.js +++ b/src/index.js @@ -75,9 +75,9 @@ function configureToMatchImageSnapshot({ if (!pass) { const differencePercentage = result.diffRatio * 100; - + const cwd = process.cwd(); message = () => `Expected image to match or be a close match to snapshot but was ${differencePercentage}% different from snapshot (${result.diffPixelCount} differing pixels).\n` - + `${chalk.bold.red('See diff for details:')} ${chalk.red(result.diffOutputPath)}`; + + `${chalk.bold.red('See diff for details:')} ${chalk.red(result.diffOutputPath.replace(cwd, '.'))}`; } }