UNPKG

8.29 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 { createHash } = require('crypto');
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
86function diffImageToSnapshot(options) {
87 const {
88 receivedImageBuffer,
89 snapshotIdentifier,
90 snapshotsDir,
91 diffDir,
92 diffDirection,
93 updateSnapshot = false,
94 updatePassedSnapshot = false,
95 customDiffConfig = {},
96 failureThreshold,
97 failureThresholdType,
98 } = options;
99
100 let result = {};
101 const baselineSnapshotPath = path.join(snapshotsDir, `${snapshotIdentifier}-snap.png`);
102 if (!fs.existsSync(baselineSnapshotPath)) {
103 mkdirp.sync(snapshotsDir);
104 fs.writeFileSync(baselineSnapshotPath, receivedImageBuffer);
105 result = { added: true };
106 } else {
107 const diffOutputPath = path.join(diffDir, `${snapshotIdentifier}-diff.png`);
108 rimraf.sync(diffOutputPath);
109
110 const defaultDiffConfig = {
111 threshold: 0.01,
112 };
113
114 const diffConfig = Object.assign({}, defaultDiffConfig, customDiffConfig);
115
116 const rawReceivedImage = PNG.sync.read(receivedImageBuffer);
117 const rawBaselineImage = PNG.sync.read(fs.readFileSync(baselineSnapshotPath));
118 const hasSizeMismatch = (
119 rawReceivedImage.height !== rawBaselineImage.height ||
120 rawReceivedImage.width !== rawBaselineImage.width
121 );
122 const imageDimensions = {
123 receivedHeight: rawReceivedImage.height,
124 receivedWidth: rawReceivedImage.width,
125 baselineHeight: rawBaselineImage.height,
126 baselineWidth: rawBaselineImage.width,
127 };
128 // Align images in size if different
129 const [receivedImage, baselineImage] = hasSizeMismatch
130 ? alignImagesToSameSize(rawReceivedImage, rawBaselineImage)
131 : [rawReceivedImage, rawBaselineImage];
132 const imageWidth = receivedImage.width;
133 const imageHeight = receivedImage.height;
134 const diffImage = new PNG({ width: imageWidth, height: imageHeight });
135
136 let pass = false;
137 let diffSize = false;
138 let diffRatio = 0;
139 let diffPixelCount = 0;
140
141 const receivedImageDigest = createHash('sha1').update(receivedImage.data).digest('base64');
142 const baselineImageDigest = createHash('sha1').update(baselineImage.data).digest('base64');
143
144 pass = receivedImageDigest === baselineImageDigest;
145
146 if (!pass) {
147 diffPixelCount = pixelmatch(
148 receivedImage.data,
149 baselineImage.data,
150 diffImage.data,
151 imageWidth,
152 imageHeight,
153 diffConfig
154 );
155
156 const totalPixels = imageWidth * imageHeight;
157 diffRatio = diffPixelCount / totalPixels;
158 // Always fail test on image size mismatch
159 if (hasSizeMismatch) {
160 pass = false;
161 diffSize = true;
162 } else if (failureThresholdType === 'pixel') {
163 pass = diffPixelCount <= failureThreshold;
164 } else if (failureThresholdType === 'percent') {
165 pass = diffRatio <= failureThreshold;
166 } else {
167 throw new Error(`Unknown failureThresholdType: ${failureThresholdType}. Valid options are "pixel" or "percent".`);
168 }
169 }
170
171 if (isFailure({ pass, updateSnapshot })) {
172 mkdirp.sync(diffDir);
173 const composer = new ImageComposer({
174 direction: diffDirection,
175 });
176
177 composer.addImage(baselineImage, imageWidth, imageHeight);
178 composer.addImage(diffImage, imageWidth, imageHeight);
179 composer.addImage(receivedImage, imageWidth, imageHeight);
180
181 const composerParams = composer.getParams();
182
183 const compositeResultImage = new PNG({
184 width: composerParams.compositeWidth,
185 height: composerParams.compositeHeight,
186 });
187
188 // copy baseline, diff, and received images into composite result image
189 composerParams.images.forEach((image, index) => {
190 PNG.bitblt(
191 image.imageData, compositeResultImage, 0, 0, image.imageWidth, image.imageHeight,
192 composerParams.offsetX * index, composerParams.offsetY * index
193 );
194 });
195 // Set filter type to Paeth to avoid expensive auto scanline filter detection
196 // For more information see https://www.w3.org/TR/PNG-Filters.html
197 const pngBuffer = PNG.sync.write(compositeResultImage, { filterType: 4 });
198 fs.writeFileSync(diffOutputPath, pngBuffer);
199
200 result = {
201 pass: false,
202 diffSize,
203 imageDimensions,
204 diffOutputPath,
205 diffRatio,
206 diffPixelCount,
207 };
208 } else if (shouldUpdate({ pass, updateSnapshot, updatePassedSnapshot })) {
209 mkdirp.sync(snapshotsDir);
210 fs.writeFileSync(baselineSnapshotPath, receivedImageBuffer);
211 result = { updated: true };
212 } else {
213 result = {
214 pass,
215 diffRatio,
216 diffPixelCount,
217 diffOutputPath,
218 };
219 }
220 }
221 return result;
222}
223
224
225function runDiffImageToSnapshot(options) {
226 options.receivedImageBuffer = options.receivedImageBuffer.toString('base64');
227
228 const serializedInput = JSON.stringify(options);
229
230 let result = {};
231
232 const writeDiffProcess = childProcess.spawnSync(
233 process.execPath, [`${__dirname}/diff-process.js`],
234 { input: Buffer.from(serializedInput), stdio: ['pipe', 'inherit', 'inherit', 'pipe'] }
235 );
236
237 if (writeDiffProcess.status === 0) {
238 const output = writeDiffProcess.output[3].toString();
239 result = JSON.parse(output);
240 } else {
241 throw new Error('Error running image diff.');
242 }
243
244 return result;
245}
246
247module.exports = {
248 diffImageToSnapshot,
249 runDiffImageToSnapshot,
250};