diff --git a/package.json b/package.json index 1fd7e53d62c9029b74bcd0860faad92bf2142bd7..21431b210cdcdb9c16a4b6b357f9d227ab74f175 100644 --- a/package.json +++ b/package.json @@ -161,6 +161,7 @@ "karma-sourcemap-loader": "^0.3.7", "karma-webpack": "^4.0.0-beta.0", "nodemon": "^1.18.4", + "pixelmatch": "^4.0.2", "prettier": "1.15.3", "vue-jest": "^3.0.2", "webpack-dev-server": "^3.1.14", diff --git a/spec/javascripts/lib/utils/common_utils_spec.js b/spec/javascripts/lib/utils/common_utils_spec.js index 0dc7e93539aaf1dd24391319e495d16882c87ba3..121c4040212a533f08496962dd8533bf1917b5a4 100644 --- a/spec/javascripts/lib/utils/common_utils_spec.js +++ b/spec/javascripts/lib/utils/common_utils_spec.js @@ -3,6 +3,25 @@ import * as commonUtils from '~/lib/utils/common_utils'; import MockAdapter from 'axios-mock-adapter'; import { faviconDataUrl, overlayDataUrl, faviconWithOverlayDataUrl } from './mock_data'; +const PIXEL_TOLERANCE = 0.2; + +/** + * Loads a data URL as the src of an + * {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/Image|Image} + * and resolves to that Image once loaded. + * + * @param url + * @returns {Promise} + */ +const urlToImage = url => + new Promise(resolve => { + const img = new Image(); + img.onload = function() { + resolve(img); + }; + img.src = url; + }); + describe('common_utils', () => { describe('parseUrl', () => { it('returns an anchor tag with url', () => { @@ -513,8 +532,9 @@ describe('common_utils', () => { it('should return the favicon with the overlay', done => { commonUtils .createOverlayIcon(faviconDataUrl, overlayDataUrl) - .then(url => { - expect(url).toEqual(faviconWithOverlayDataUrl); + .then(url => Promise.all([urlToImage(url), urlToImage(faviconWithOverlayDataUrl)])) + .then(([actual, expected]) => { + expect(actual).toImageDiffEqual(expected, PIXEL_TOLERANCE); done(); }) .catch(done.fail); @@ -536,10 +556,10 @@ describe('common_utils', () => { it('should set page favicon to provided favicon overlay', done => { commonUtils .setFaviconOverlay(overlayDataUrl) - .then(() => { - expect(document.getElementById('favicon').getAttribute('href')).toEqual( - faviconWithOverlayDataUrl, - ); + .then(() => document.getElementById('favicon').getAttribute('href')) + .then(url => Promise.all([urlToImage(url), urlToImage(faviconWithOverlayDataUrl)])) + .then(([actual, expected]) => { + expect(actual).toImageDiffEqual(expected, PIXEL_TOLERANCE); done(); }) .catch(done.fail); @@ -582,10 +602,10 @@ describe('common_utils', () => { commonUtils .setCiStatusFavicon(BUILD_URL) - .then(() => { - const favicon = document.getElementById('favicon'); - - expect(favicon.getAttribute('href')).toEqual(faviconWithOverlayDataUrl); + .then(() => document.getElementById('favicon').getAttribute('href')) + .then(url => Promise.all([urlToImage(url), urlToImage(faviconWithOverlayDataUrl)])) + .then(([actual, expected]) => { + expect(actual).toImageDiffEqual(expected, PIXEL_TOLERANCE); done(); }) .catch(done.fail); diff --git a/spec/javascripts/matchers.js b/spec/javascripts/matchers.js index 0d465510fd3611f02d0d6f67d484e4610a367efc..406527b08a35346095c0260f28954fd88cbf8b72 100644 --- a/spec/javascripts/matchers.js +++ b/spec/javascripts/matchers.js @@ -1,3 +1,5 @@ +import pixelmatch from 'pixelmatch'; + export default { toContainText: () => ({ compare(vm, text) { @@ -54,4 +56,41 @@ export default { return result; }, }), + toImageDiffEqual: () => { + const getImageData = img => { + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + canvas.getContext('2d').drawImage(img, 0, 0); + return canvas.getContext('2d').getImageData(0, 0, img.width, img.height).data; + }; + + return { + compare(actual, expected, threshold = 0.1) { + if (actual.height !== expected.height || actual.width !== expected.width) { + return { + pass: false, + message: `Expected image dimensions (h x w) of ${expected.height}x${expected.width}. + Received an image with ${actual.height}x${actual.width}`, + }; + } + + const { width, height } = actual; + const differentPixels = pixelmatch( + getImageData(actual), + getImageData(expected), + null, + width, + height, + { threshold }, + ); + + return { + pass: differentPixels < 20, + message: `${differentPixels} pixels differ more than ${threshold * + 100} percent between input and output.`, + }; + }, + }; + }, }; diff --git a/yarn.lock b/yarn.lock index 7b3144fca16f6cc52f26abe3787d28e3269d89c5..1486f6f864273420852629f32333f114e34c42d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7735,6 +7735,13 @@ pinkie@^2.0.0: resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" integrity sha1-clVrgM+g1IqXToDnckjoDtT3+HA= +pixelmatch@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/pixelmatch/-/pixelmatch-4.0.2.tgz#8f47dcec5011b477b67db03c243bc1f3085e8854" + integrity sha1-j0fc7FARtHe2fbA8JDvB8wheiFQ= + dependencies: + pngjs "^3.0.0" + pkg-dir@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-1.0.0.tgz#7a4b508a8d5bb2d629d447056ff4e9c9314cf3d4" @@ -7766,6 +7773,11 @@ pn@^1.1.0: resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb" integrity sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA== +pngjs@^3.0.0: + version "3.3.3" + resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.3.3.tgz#85173703bde3edac8998757b96e5821d0966a21b" + integrity sha512-1n3Z4p3IOxArEs1VRXnZ/RXdfEniAUS9jb68g58FIXMNkPJeZd+Qh4Uq7/e0LVxAQGos1eIUrqrt4FpjdnEd+Q== + pofile@^1: version "1.0.11" resolved "https://registry.yarnpkg.com/pofile/-/pofile-1.0.11.tgz#35aff58c17491d127a07336d5522ebc9df57c954"