Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow to generate diff for images with different sizes #42

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions __tests__/__snapshots__/integration.spec.js.snap

This file was deleted.

41 changes: 26 additions & 15 deletions __tests__/integration.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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\)\./;
Expand All @@ -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
Expand All @@ -118,7 +114,23 @@ describe('toMatchImageSnapshot', () => {
// Test against an image much larger than the snapshot.
expect(
() => expect(oversizeImageData).toMatchImageSnapshot({ customSnapshotIdentifier })
).toThrowErrorMatchingSnapshot();
).toThrowError(/Expected image to match or be a close match to snapshot but was 83\.85395537525355% different from snapshot/);

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 })
).toThrowError(/Expected image to match or be a close match to snapshot but was 54\.662222222222226% different from snapshot/);

expect(diffExists(customSnapshotIdentifier)).toBe(true);
});

it('writes a result image for failing tests', () => {
Expand All @@ -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 })
Expand All @@ -159,7 +170,7 @@ describe('toMatchImageSnapshot', () => {
() => expect(imageData).toMatchImageSnapshot({ customSnapshotIdentifier })
).not.toThrowError();

expect(fs.existsSync(pathToResultImage)).toBe(false);
expect(diffExists(customSnapshotIdentifier)).toBe(false);
});
});
});
Binary file added __tests__/stubs/TestImage150x150.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
78 changes: 69 additions & 9 deletions src/diff-snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am okay with disabling no-plusplus since in a loop that is a pretty standard way to iterate that has been around since the beginning of programming. And no-bitwise since you do need it.

However you should definitely not disable no-param-reassign.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no-param-reassign required to mute errors for reassigning image.data value

const fillSizeDifference = (width, height) => (image) => {
Copy link
Contributor Author

@sbekrin sbekrin Mar 9, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function just fills new area added after resize with transparent black color. I like idea of checker board pattern, but it seems to be too complicated to implement considering how low-level pngjs API is.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah the worry we had was that just filling it with color would confuse the user as they would think that the color was part of their passed in image.

Since you made it transparent though when I was running it locally it seemed clear enough that the black color was not a part of the image so this should be fine from my POV at least.

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,
Expand All @@ -45,14 +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.');
}
const rawReceivedImage = PNG.sync.read(receivedImageBuffer);
const rawBaselineImage = PNG.sync.read(fs.readFileSync(baselineSnapshotPath));
const hasSizeMismatch = (
rawReceivedImage.height !== rawBaselineImage.height ||
rawReceivedImage.width !== 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;
const diffImage = new PNG({ width: imageWidth, height: imageHeight });
Expand All @@ -70,7 +127,10 @@ function diffImageToSnapshot(options) {
const diffRatio = diffPixelCount / totalPixels;

let pass = false;
if (failureThresholdType === 'pixel') {
if (hasSizeMismatch) {
// Always fail test on image size mismatch
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we explicitly set pass to false for clarity of what the intent is?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, done

pass = false;
} else if (failureThresholdType === 'pixel') {
pass = diffPixelCount <= failureThreshold;
} else if (failureThresholdType === 'percent') {
pass = diffRatio <= failureThreshold;
Expand Down
1 change: 0 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ function configureToMatchImageSnapshot({

if (!pass) {
const differencePercentage = result.diffRatio * 100;

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)}`;
}
Expand Down