UNPKG

8.8 kBJavaScriptView Raw
1/*
2 * Copyright (c) 2017 American Express Travel Related Services Company, Inc.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
5 * in compliance with the License. You may obtain a copy of the License at
6 *
7 * http://www.apache.org/licenses/LICENSE-2.0
8 *
9 * Unless required by applicable law or agreed to in writing, software distributed under the License
10 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
11 * or implied. See the License for the specific language governing permissions and limitations under
12 * the License.
13 */
14
15const childProcess = require('child_process');
16const fs = require('fs');
17const path = require('path');
18const mkdirp = require('mkdirp');
19const pixelmatch = require('pixelmatch');
20const { PNG } = require('pngjs');
21const rimraf = require('rimraf');
22const glur = require('glur');
23const ImageComposer = require('./image-composer');
24
25/**
26 * Helper function to create reusable image resizer
27 */
28const createImageResizer = (width, height) => (source) => {
29 const resized = new PNG({ width, height, fill: true });
30 PNG.bitblt(source, resized, 0, 0, source.width, source.height, 0, 0);
31 return resized;
32};
33
34/**
35 * Fills diff area with black transparent color for meaningful diff
36 */
37/* eslint-disable no-plusplus, no-param-reassign, no-bitwise */
38const fillSizeDifference = (width, height) => (image) => {
39 const inArea = (x, y) => y > height || x > width;
40 for (let y = 0; y < image.height; y++) {
41 for (let x = 0; x < image.width; x++) {
42 if (inArea(x, y)) {
43 const idx = ((image.width * y) + x) << 2;
44 image.data[idx] = 0;
45 image.data[idx + 1] = 0;
46 image.data[idx + 2] = 0;
47 image.data[idx + 3] = 64;
48 }
49 }
50 }
51 return image;
52};
53/* eslint-enabled */
54
55/**
56 * Aligns images sizes to biggest common value
57 * and fills new pixels with transparent pixels
58 */
59const alignImagesToSameSize = (firstImage, secondImage) => {
60 // Keep original sizes to fill extended area later
61 const firstImageWidth = firstImage.width;
62 const firstImageHeight = firstImage.height;
63 const secondImageWidth = secondImage.width;
64 const secondImageHeight = secondImage.height;
65 // Calculate biggest common values
66 const resizeToSameSize = createImageResizer(
67 Math.max(firstImageWidth, secondImageWidth),
68 Math.max(firstImageHeight, secondImageHeight)
69 );
70 // Resize both images
71 const resizedFirst = resizeToSameSize(firstImage);
72 const resizedSecond = resizeToSameSize(secondImage);
73 // Fill resized area with black transparent pixels
74 return [
75 fillSizeDifference(firstImageWidth, firstImageHeight)(resizedFirst),
76 fillSizeDifference(secondImageWidth, secondImageHeight)(resizedSecond),
77 ];
78};
79
80const isFailure = ({ pass, updateSnapshot }) => !pass && !updateSnapshot;
81
82const shouldUpdate = ({ pass, updateSnapshot, updatePassedSnapshot }) => (
83 (!pass && updateSnapshot) || (pass && updatePassedSnapshot)
84);
85
86const shouldFail = ({
87 totalPixels,
88 diffPixelCount,
89 hasSizeMismatch,
90 allowSizeMismatch,
91 failureThresholdType,
92 failureThreshold,
93}) => {
94 let pass = false;
95 let diffSize = false;
96 const diffRatio = diffPixelCount / totalPixels;
97 if (hasSizeMismatch) {
98 // do not fail if allowSizeMismatch is set
99 pass = allowSizeMismatch;
100 diffSize = true;
101 }
102 if (!diffSize || pass === true) {
103 if (failureThresholdType === 'pixel') {
104 pass = diffPixelCount <= failureThreshold;
105 } else if (failureThresholdType === 'percent') {
106 pass = diffRatio <= failureThreshold;
107 } else {
108 throw new Error(`Unknown failureThresholdType: ${failureThresholdType}. Valid options are "pixel" or "percent".`);
109 }
110 }
111 return {
112 pass,
113 diffSize,
114 diffRatio,
115 };
116};
117
118function diffImageToSnapshot(options) {
119 const {
120 receivedImageBuffer,
121 snapshotIdentifier,
122 snapshotsDir,
123 diffDir,
124 diffDirection,
125 updateSnapshot = false,
126 updatePassedSnapshot = false,
127 customDiffConfig = {},
128 failureThreshold,
129 failureThresholdType,
130 blur,
131 allowSizeMismatch = false,
132 } = options;
133
134 let result = {};
135 const baselineSnapshotPath = path.join(snapshotsDir, `${snapshotIdentifier}-snap.png`);
136 if (!fs.existsSync(baselineSnapshotPath)) {
137 mkdirp.sync(snapshotsDir);
138 fs.writeFileSync(baselineSnapshotPath, receivedImageBuffer);
139 result = { added: true };
140 } else {
141 const diffOutputPath = path.join(diffDir, `${snapshotIdentifier}-diff.png`);
142 rimraf.sync(diffOutputPath);
143
144 const defaultDiffConfig = {
145 threshold: 0.01,
146 };
147
148 const diffConfig = Object.assign({}, defaultDiffConfig, customDiffConfig);
149
150 const rawReceivedImage = PNG.sync.read(receivedImageBuffer);
151 const rawBaselineImage = PNG.sync.read(fs.readFileSync(baselineSnapshotPath));
152 const hasSizeMismatch = (
153 rawReceivedImage.height !== rawBaselineImage.height ||
154 rawReceivedImage.width !== rawBaselineImage.width
155 );
156 const imageDimensions = {
157 receivedHeight: rawReceivedImage.height,
158 receivedWidth: rawReceivedImage.width,
159 baselineHeight: rawBaselineImage.height,
160 baselineWidth: rawBaselineImage.width,
161 };
162 // Align images in size if different
163 const [receivedImage, baselineImage] = hasSizeMismatch
164 ? alignImagesToSameSize(rawReceivedImage, rawBaselineImage)
165 : [rawReceivedImage, rawBaselineImage];
166 const imageWidth = receivedImage.width;
167 const imageHeight = receivedImage.height;
168
169 if (typeof blur === 'number' && blur > 0) {
170 glur(receivedImage.data, imageWidth, imageHeight, blur);
171 glur(baselineImage.data, imageWidth, imageHeight, blur);
172 }
173
174 const diffImage = new PNG({ width: imageWidth, height: imageHeight });
175
176 let diffPixelCount = 0;
177
178 diffPixelCount = pixelmatch(
179 receivedImage.data,
180 baselineImage.data,
181 diffImage.data,
182 imageWidth,
183 imageHeight,
184 diffConfig
185 );
186
187 const totalPixels = imageWidth * imageHeight;
188
189 const {
190 pass,
191 diffSize,
192 diffRatio,
193 } = shouldFail({
194 totalPixels,
195 diffPixelCount,
196 hasSizeMismatch,
197 allowSizeMismatch,
198 failureThresholdType,
199 failureThreshold,
200 });
201
202 if (isFailure({ pass, updateSnapshot })) {
203 mkdirp.sync(diffDir);
204 const composer = new ImageComposer({
205 direction: diffDirection,
206 });
207
208 composer.addImage(baselineImage, imageWidth, imageHeight);
209 composer.addImage(diffImage, imageWidth, imageHeight);
210 composer.addImage(receivedImage, imageWidth, imageHeight);
211
212 const composerParams = composer.getParams();
213
214 const compositeResultImage = new PNG({
215 width: composerParams.compositeWidth,
216 height: composerParams.compositeHeight,
217 });
218
219 // copy baseline, diff, and received images into composite result image
220 composerParams.images.forEach((image, index) => {
221 PNG.bitblt(
222 image.imageData, compositeResultImage, 0, 0, image.imageWidth, image.imageHeight,
223 composerParams.offsetX * index, composerParams.offsetY * index
224 );
225 });
226 // Set filter type to Paeth to avoid expensive auto scanline filter detection
227 // For more information see https://www.w3.org/TR/PNG-Filters.html
228 const pngBuffer = PNG.sync.write(compositeResultImage, { filterType: 4 });
229 fs.writeFileSync(diffOutputPath, pngBuffer);
230
231 result = {
232 pass: false,
233 diffSize,
234 imageDimensions,
235 diffOutputPath,
236 diffRatio,
237 diffPixelCount,
238 imgSrcString: `data:image/png;base64,${pngBuffer.toString('base64')}`,
239 };
240 } else if (shouldUpdate({ pass, updateSnapshot, updatePassedSnapshot })) {
241 mkdirp.sync(snapshotsDir);
242 fs.writeFileSync(baselineSnapshotPath, receivedImageBuffer);
243 result = { updated: true };
244 } else {
245 result = {
246 pass,
247 diffSize,
248 diffRatio,
249 diffPixelCount,
250 diffOutputPath,
251 };
252 }
253 }
254 return result;
255}
256
257function runDiffImageToSnapshot(options) {
258 options.receivedImageBuffer = options.receivedImageBuffer.toString('base64');
259
260 const serializedInput = JSON.stringify(options);
261
262 let result = {};
263
264 const writeDiffProcess = childProcess.spawnSync(
265 process.execPath, [`${__dirname}/diff-process.js`],
266 {
267 input: Buffer.from(serializedInput),
268 stdio: ['pipe', 'inherit', 'inherit', 'pipe'],
269 maxBuffer: 10 * 1024 * 1024, // 10 MB
270 }
271 );
272
273 if (writeDiffProcess.status === 0) {
274 const output = writeDiffProcess.output[3].toString();
275 result = JSON.parse(output);
276 } else {
277 throw new Error('Error running image diff.');
278 }
279
280 return result;
281}
282
283module.exports = {
284 diffImageToSnapshot,
285 runDiffImageToSnapshot,
286};