@pixsame/cypress-plugin-visual-regression-diff 0.0.0 → 1.0.0-canary-20261012-spwhnz4b
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +535 -1
- package/dist/commands.d.ts +60 -0
- package/dist/constants.d.ts +28 -0
- package/dist/constants.js +43 -0
- package/dist/constants.js.map +1 -0
- package/dist/constants.mjs +34 -0
- package/dist/constants.mjs.map +1 -0
- package/dist/plugins.d.ts +1 -0
- package/dist/plugins.js +575 -0
- package/dist/plugins.js.map +1 -0
- package/dist/plugins.mjs +567 -0
- package/dist/plugins.mjs.map +1 -0
- package/dist/support.d.ts +12 -0
- package/dist/support.js +647 -0
- package/dist/support.js.map +1 -0
- package/dist/support.mjs +633 -0
- package/dist/support.mjs.map +1 -0
- package/dist/types.d.ts +20 -0
- package/package.json +117 -4
package/dist/plugins.mjs
ADDED
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
import fs, { promises } from 'fs';
|
|
2
|
+
import { PNG } from 'pngjs';
|
|
3
|
+
import pixelmatch from 'pixelmatch';
|
|
4
|
+
import { moveFile } from 'move-file';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import sharp from 'sharp';
|
|
7
|
+
import metaPngPkg from 'meta-png';
|
|
8
|
+
import { globSync } from 'glob';
|
|
9
|
+
|
|
10
|
+
const PLUGIN_NAME = 'cp-visual-regression-diff';
|
|
11
|
+
const IMAGE_SNAPSHOT_PREFIX = `__${PLUGIN_NAME}_snapshots__`;
|
|
12
|
+
var FILE_SUFFIX;
|
|
13
|
+
(function (FILE_SUFFIX) {
|
|
14
|
+
FILE_SUFFIX["diff"] = ".diff";
|
|
15
|
+
FILE_SUFFIX["actual"] = ".actual";
|
|
16
|
+
})(FILE_SUFFIX || (FILE_SUFFIX = {}));
|
|
17
|
+
const TASK = {
|
|
18
|
+
getScreenshotPathInfo: `${PLUGIN_NAME}-getScreenshotPathInfo`,
|
|
19
|
+
compareImages: `${PLUGIN_NAME}-compareImages`,
|
|
20
|
+
approveImage: `${PLUGIN_NAME}-approveImage`,
|
|
21
|
+
cleanupImages: `${PLUGIN_NAME}-cleanupImages`,
|
|
22
|
+
doesFileExist: `${PLUGIN_NAME}-doesFileExist`,
|
|
23
|
+
processImgPath: `${PLUGIN_NAME}-processImgPath`,
|
|
24
|
+
recordPendingDiff: `${PLUGIN_NAME}-recordPendingDiff`,
|
|
25
|
+
getPendingDiffs: `${PLUGIN_NAME}-getPendingDiffs`,
|
|
26
|
+
clearPendingDiffs: `${PLUGIN_NAME}-clearPendingDiffs`
|
|
27
|
+
/* c8 ignore next */
|
|
28
|
+
};
|
|
29
|
+
const PATH_VARIABLES = {
|
|
30
|
+
specPath: '{spec_path}',
|
|
31
|
+
unixSystemRootPath: '{unix_system_root_path}',
|
|
32
|
+
winSystemRootPath: '{win_system_root_path}'
|
|
33
|
+
};
|
|
34
|
+
const WINDOWS_LIKE_DRIVE_REGEX = /^[A-Z]:$/;
|
|
35
|
+
const METADATA_KEY = 'FRSOURCE_CPVRD_V';
|
|
36
|
+
|
|
37
|
+
const supportsExpose = version => {
|
|
38
|
+
const [major, minor] = version.split('.').map(Number);
|
|
39
|
+
return major > 15 || major === 15 && minor >= 10;
|
|
40
|
+
};
|
|
41
|
+
const getPluginConfig = (config, key) => {
|
|
42
|
+
var _config$version, _config$env;
|
|
43
|
+
if (supportsExpose((_config$version = config.version) != null ? _config$version : '')) {
|
|
44
|
+
var _config$expose;
|
|
45
|
+
return (_config$expose = config.expose) == null ? void 0 : _config$expose[key];
|
|
46
|
+
}
|
|
47
|
+
return (_config$env = config.env) == null ? void 0 : _config$env[key];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function isHighSurrogate(codePoint) {
|
|
51
|
+
return codePoint >= 0xd800 && codePoint <= 0xdbff;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isLowSurrogate(codePoint) {
|
|
55
|
+
return codePoint >= 0xdc00 && codePoint <= 0xdfff;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Truncate string by size in bytes
|
|
59
|
+
var truncate = function truncate(getLength, string, byteLength) {
|
|
60
|
+
if (typeof string !== "string") {
|
|
61
|
+
throw new Error("Input must be string");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
var charLength = string.length;
|
|
65
|
+
var curByteLength = 0;
|
|
66
|
+
var codePoint;
|
|
67
|
+
var segment;
|
|
68
|
+
|
|
69
|
+
for (var i = 0; i < charLength; i += 1) {
|
|
70
|
+
codePoint = string.charCodeAt(i);
|
|
71
|
+
segment = string[i];
|
|
72
|
+
|
|
73
|
+
if (isHighSurrogate(codePoint) && isLowSurrogate(string.charCodeAt(i + 1))) {
|
|
74
|
+
i += 1;
|
|
75
|
+
segment += string[i];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
curByteLength += getLength(segment);
|
|
79
|
+
|
|
80
|
+
if (curByteLength === byteLength) {
|
|
81
|
+
return string.slice(0, i + 1);
|
|
82
|
+
}
|
|
83
|
+
else if (curByteLength > byteLength) {
|
|
84
|
+
return string.slice(0, i - segment.length + 1);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return string;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
var getLength = Buffer.byteLength.bind(Buffer);
|
|
92
|
+
var truncateUtf8Bytes = truncate.bind(null, getLength);
|
|
93
|
+
|
|
94
|
+
/*jshint node:true*/
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Replaces characters in strings that are illegal/unsafe for filenames.
|
|
98
|
+
* Unsafe characters are either removed or replaced by a substitute set
|
|
99
|
+
* in the optional `options` object.
|
|
100
|
+
*
|
|
101
|
+
* Illegal Characters on Various Operating Systems
|
|
102
|
+
* / ? < > \ : * | "
|
|
103
|
+
* https://kb.acronis.com/content/39790
|
|
104
|
+
*
|
|
105
|
+
* Unicode Control codes
|
|
106
|
+
* C0 0x00-0x1f & C1 (0x80-0x9f)
|
|
107
|
+
* http://en.wikipedia.org/wiki/C0_and_C1_control_codes
|
|
108
|
+
*
|
|
109
|
+
* Reserved filenames on Unix-based systems (".", "..")
|
|
110
|
+
* Reserved filenames in Windows ("CON", "PRN", "AUX", "NUL", "COM1",
|
|
111
|
+
* "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
|
112
|
+
* "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", and
|
|
113
|
+
* "LPT9") case-insesitively and with or without filename extensions.
|
|
114
|
+
*
|
|
115
|
+
* Capped at 255 characters in length.
|
|
116
|
+
* http://unix.stackexchange.com/questions/32795/what-is-the-maximum-allowed-filename-and-folder-size-with-ecryptfs
|
|
117
|
+
*
|
|
118
|
+
* @param {String} input Original filename
|
|
119
|
+
* @param {Object} options {replacement: String | Function }
|
|
120
|
+
* @return {String} Sanitized filename
|
|
121
|
+
*/
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
var illegalRe = /[\/\?<>\\:\*\|"]/g;
|
|
126
|
+
var controlRe = /[\x00-\x1f\x80-\x9f]/g;
|
|
127
|
+
var reservedRe = /^\.+$/;
|
|
128
|
+
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Strip trailing spaces and dots, which are not allowed on some Windows file
|
|
132
|
+
* systems. Does not use a regex to avoid a quadratic ReDoS vulnerability
|
|
133
|
+
* (CWE-1333).
|
|
134
|
+
*/
|
|
135
|
+
function replaceTrailingDotsAndSpaces(str, replacement) {
|
|
136
|
+
var end = str.length;
|
|
137
|
+
while (end > 0 && (str[end - 1] === '.' || str[end - 1] === ' ')) end--;
|
|
138
|
+
return end < str.length ? str.slice(0, end) + replacement : str;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function sanitize(input, replacement) {
|
|
142
|
+
if (typeof input !== 'string') {
|
|
143
|
+
throw new Error('Input must be string');
|
|
144
|
+
}
|
|
145
|
+
var sanitized = input
|
|
146
|
+
.replace(illegalRe, replacement)
|
|
147
|
+
.replace(controlRe, replacement)
|
|
148
|
+
.replace(reservedRe, replacement)
|
|
149
|
+
.replace(windowsReservedRe, replacement);
|
|
150
|
+
sanitized = replaceTrailingDotsAndSpaces(sanitized, replacement);
|
|
151
|
+
return truncateUtf8Bytes(sanitized, 255);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
var sanitizeFilename = function (input, options) {
|
|
155
|
+
var replacement = (options && options.replacement) || '';
|
|
156
|
+
var output = sanitize(input, replacement);
|
|
157
|
+
if (replacement === '') {
|
|
158
|
+
return output;
|
|
159
|
+
}
|
|
160
|
+
return sanitize(output, '');
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// highest `_#n` index handed out per screenshot path during the current spec
|
|
164
|
+
const nameCacheCounter = {};
|
|
165
|
+
// counters as they were before the current test attempt started, for every
|
|
166
|
+
// path the attempt touched - a retry restores them so the regenerated
|
|
167
|
+
// screenshots get the same names again
|
|
168
|
+
const countersBeforeAttempt = {};
|
|
169
|
+
let currentAttempt;
|
|
170
|
+
const resetMap = map => {
|
|
171
|
+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
|
172
|
+
for (const key in map) delete map[key];
|
|
173
|
+
};
|
|
174
|
+
const parsePathPartVariables = (specPath, pathPart, i) => {
|
|
175
|
+
if (pathPart === PATH_VARIABLES.specPath) {
|
|
176
|
+
return path.dirname(specPath);
|
|
177
|
+
} else if (i === 0 && !pathPart) {
|
|
178
|
+
// when unix-like absolute path
|
|
179
|
+
return PATH_VARIABLES.unixSystemRootPath;
|
|
180
|
+
} else if (i === 0 && WINDOWS_LIKE_DRIVE_REGEX.test(pathPart)) {
|
|
181
|
+
// when win-like absolute path
|
|
182
|
+
return path.join(PATH_VARIABLES.winSystemRootPath, pathPart[0]);
|
|
183
|
+
}
|
|
184
|
+
return pathPart;
|
|
185
|
+
};
|
|
186
|
+
const startAttempt = (testId, retry) => {
|
|
187
|
+
var _currentAttempt;
|
|
188
|
+
const isSameTest = ((_currentAttempt = currentAttempt) == null ? void 0 : _currentAttempt.testId) === testId;
|
|
189
|
+
if (isSameTest && currentAttempt && retry > currentAttempt.retry) {
|
|
190
|
+
for (const screenshotPath in countersBeforeAttempt) {
|
|
191
|
+
nameCacheCounter[screenshotPath] = countersBeforeAttempt[screenshotPath];
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (!isSameTest || currentAttempt && retry !== currentAttempt.retry) {
|
|
195
|
+
resetMap(countersBeforeAttempt);
|
|
196
|
+
}
|
|
197
|
+
currentAttempt = {
|
|
198
|
+
testId,
|
|
199
|
+
retry
|
|
200
|
+
};
|
|
201
|
+
};
|
|
202
|
+
const generateScreenshotPath = ({
|
|
203
|
+
titleFromOptions,
|
|
204
|
+
imagesPath,
|
|
205
|
+
specPath,
|
|
206
|
+
currentRetryNumber,
|
|
207
|
+
testId
|
|
208
|
+
}) => {
|
|
209
|
+
const screenshotPath = path.join(...imagesPath.split('/').map(parsePathPartVariables.bind(void 0, specPath)), sanitizeFilename(titleFromOptions));
|
|
210
|
+
startAttempt(testId, currentRetryNumber);
|
|
211
|
+
if (typeof nameCacheCounter[screenshotPath] === 'undefined') {
|
|
212
|
+
nameCacheCounter[screenshotPath] = -1;
|
|
213
|
+
}
|
|
214
|
+
if (!(screenshotPath in countersBeforeAttempt)) {
|
|
215
|
+
countersBeforeAttempt[screenshotPath] = nameCacheCounter[screenshotPath];
|
|
216
|
+
}
|
|
217
|
+
return path.join(IMAGE_SNAPSHOT_PREFIX, `${screenshotPath}_#${++nameCacheCounter[screenshotPath]}${FILE_SUFFIX.actual}.png`);
|
|
218
|
+
};
|
|
219
|
+
const screenshotPathRegex = new RegExp(`^([\\s\\S]+?)_#([0-9]+)(?:(?:\\${FILE_SUFFIX.diff})|(?:\\${FILE_SUFFIX.actual}))?\\.(?:png|PNG)$`);
|
|
220
|
+
const wasScreenshotUsed = imagePath => {
|
|
221
|
+
const matched = imagePath.match(screenshotPathRegex);
|
|
222
|
+
/* c8 ignore next */
|
|
223
|
+
if (!matched) return false;
|
|
224
|
+
const [, screenshotPath, numString] = matched;
|
|
225
|
+
const num = parseInt(numString);
|
|
226
|
+
/* c8 ignore next */
|
|
227
|
+
if (!screenshotPath || isNaN(num)) return false;
|
|
228
|
+
return screenshotPath in nameCacheCounter && num <= nameCacheCounter[screenshotPath];
|
|
229
|
+
};
|
|
230
|
+
const resetScreenshotNameCache = () => {
|
|
231
|
+
currentAttempt = undefined;
|
|
232
|
+
resetMap(nameCacheCounter);
|
|
233
|
+
resetMap(countersBeforeAttempt);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const {
|
|
237
|
+
addMetadata,
|
|
238
|
+
getMetadata
|
|
239
|
+
} = metaPngPkg;
|
|
240
|
+
// version of images generated by this plugin
|
|
241
|
+
// bump only when there is a breaking change to:
|
|
242
|
+
// image comparison or image generation algorithms
|
|
243
|
+
const DIFF_IMAGES_VERSION = '1';
|
|
244
|
+
const addPNGMetadata = (config, png) => addMetadata(new Uint8Array(png), METADATA_KEY, JSON.stringify({
|
|
245
|
+
version: DIFF_IMAGES_VERSION,
|
|
246
|
+
testingType: config.testingType || 'e2e'
|
|
247
|
+
}) /* c8 ignore next */);
|
|
248
|
+
const getPNGMetadata = png => {
|
|
249
|
+
const metadataString = getMetadata(new Uint8Array(png), METADATA_KEY /* c8 ignore next */);
|
|
250
|
+
if (metadataString === undefined) return;
|
|
251
|
+
try {
|
|
252
|
+
return JSON.parse(metadataString);
|
|
253
|
+
} catch (_unused) /* v8 ignore next 2 */{
|
|
254
|
+
return {
|
|
255
|
+
version: metadataString
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
const isImageCurrentVersion = png => {
|
|
260
|
+
var _getPNGMetadata;
|
|
261
|
+
return ((_getPNGMetadata = getPNGMetadata(png)) == null ? void 0 : _getPNGMetadata.version) === DIFF_IMAGES_VERSION;
|
|
262
|
+
};
|
|
263
|
+
const isImageGeneratedByPlugin = png => !!getPNGMetadata(png /* c8 ignore next */);
|
|
264
|
+
const isImageOfTestType = (png, testingType) => {
|
|
265
|
+
var _getPNGMetadata2;
|
|
266
|
+
if (!isImageGeneratedByPlugin(png)) return false;
|
|
267
|
+
const imageTestingType = (_getPNGMetadata2 = getPNGMetadata(png /* c8 ignore next */)) == null ? void 0 : _getPNGMetadata2.testingType;
|
|
268
|
+
return imageTestingType === testingType || testingType === undefined;
|
|
269
|
+
};
|
|
270
|
+
const writePNG = (config, name, png) => fs.writeFileSync(name, addPNGMetadata(config, png instanceof PNG ? PNG.sync.write(png) : png));
|
|
271
|
+
const inArea = (x, y, height, width) => y > height || x > width;
|
|
272
|
+
const fillSizeDifference = (image, width, height) => {
|
|
273
|
+
for (let y = 0; y < image.height; y++) {
|
|
274
|
+
for (let x = 0; x < image.width; x++) {
|
|
275
|
+
if (inArea(x, y, height, width)) {
|
|
276
|
+
const idx = image.width * y + x << 2;
|
|
277
|
+
image.data[idx] = 0;
|
|
278
|
+
image.data[idx + 1] = 0;
|
|
279
|
+
image.data[idx + 2] = 0;
|
|
280
|
+
image.data[idx + 3] = 64;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return image;
|
|
285
|
+
/* c8 ignore next */
|
|
286
|
+
};
|
|
287
|
+
const createImageResizer = (width, height) => source => {
|
|
288
|
+
const resized = new PNG({
|
|
289
|
+
width,
|
|
290
|
+
height,
|
|
291
|
+
fill: true
|
|
292
|
+
});
|
|
293
|
+
PNG.bitblt(source, resized, 0, 0, source.width, source.height, 0, 0);
|
|
294
|
+
return resized;
|
|
295
|
+
/* c8 ignore next */
|
|
296
|
+
};
|
|
297
|
+
const scaleImageAndWrite = async ({
|
|
298
|
+
scaleFactor,
|
|
299
|
+
path
|
|
300
|
+
}) => {
|
|
301
|
+
const imgBuffer = fs.readFileSync(path);
|
|
302
|
+
if (scaleFactor === 1) return imgBuffer;
|
|
303
|
+
const rawImgNew = PNG.sync.read(imgBuffer);
|
|
304
|
+
const newImageWidth = Math.ceil(rawImgNew.width * scaleFactor);
|
|
305
|
+
const newImageHeight = Math.ceil(rawImgNew.height * scaleFactor);
|
|
306
|
+
await sharp(imgBuffer).resize(newImageWidth, newImageHeight).toFile(path);
|
|
307
|
+
return fs.readFileSync(path);
|
|
308
|
+
};
|
|
309
|
+
const alignImagesToSameSize = (firstImage, secondImage) => {
|
|
310
|
+
const firstImageWidth = firstImage.width;
|
|
311
|
+
const firstImageHeight = firstImage.height;
|
|
312
|
+
const secondImageWidth = secondImage.width;
|
|
313
|
+
const secondImageHeight = secondImage.height;
|
|
314
|
+
const resizeToSameSize = createImageResizer(Math.max(firstImageWidth, secondImageWidth), Math.max(firstImageHeight, secondImageHeight));
|
|
315
|
+
const resizedFirst = resizeToSameSize(firstImage);
|
|
316
|
+
const resizedSecond = resizeToSameSize(secondImage);
|
|
317
|
+
return [fillSizeDifference(resizedFirst, firstImageWidth, firstImageHeight), fillSizeDifference(resizedSecond, secondImageWidth, secondImageHeight)];
|
|
318
|
+
};
|
|
319
|
+
const cleanupUnused = config => {
|
|
320
|
+
globSync('**/*.png', {
|
|
321
|
+
cwd: config.projectRoot,
|
|
322
|
+
ignore: 'node_modules/**/*'
|
|
323
|
+
}).forEach(pngPath => {
|
|
324
|
+
const absolutePath = path.join(config.projectRoot, pngPath);
|
|
325
|
+
if (!wasScreenshotUsed(pngPath) && isImageOfTestType(fs.readFileSync(absolutePath), config.testingType)) {
|
|
326
|
+
fs.unlinkSync(absolutePath);
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
let pendingDiffs = [];
|
|
332
|
+
const round = n => Math.ceil(n * 1000) / 1000;
|
|
333
|
+
const unlinkSyncSafe = path => fs.existsSync(path) && fs.unlinkSync(path);
|
|
334
|
+
// `.diff.png` sibling of an `.actual.png`; null when the name has no `.actual`
|
|
335
|
+
// suffix (only happens with hand-picked paths), so nothing else gets deleted
|
|
336
|
+
const diffPathFor = actualPath => {
|
|
337
|
+
const diffPath = actualPath.replace(FILE_SUFFIX.actual, FILE_SUFFIX.diff);
|
|
338
|
+
return diffPath === actualPath ? null : diffPath;
|
|
339
|
+
};
|
|
340
|
+
const removeStaleDiff = actualPath => {
|
|
341
|
+
const diffPath = diffPathFor(actualPath);
|
|
342
|
+
if (diffPath) unlinkSyncSafe(diffPath);
|
|
343
|
+
};
|
|
344
|
+
const getScreenshotPathInfoTask = cfg => {
|
|
345
|
+
const screenshotPath = generateScreenshotPath(cfg);
|
|
346
|
+
return {
|
|
347
|
+
screenshotPath,
|
|
348
|
+
title: path.basename(screenshotPath, '.png')
|
|
349
|
+
};
|
|
350
|
+
};
|
|
351
|
+
const cleanupImagesTask = config => {
|
|
352
|
+
if (getPluginConfig(config, 'pluginVisualRegressionCleanupUnusedImages')) {
|
|
353
|
+
cleanupUnused(config);
|
|
354
|
+
}
|
|
355
|
+
resetScreenshotNameCache();
|
|
356
|
+
return null;
|
|
357
|
+
};
|
|
358
|
+
const approveImageTask = async ({
|
|
359
|
+
img,
|
|
360
|
+
imgOld
|
|
361
|
+
}) => {
|
|
362
|
+
const oldImg = imgOld != null ? imgOld : img.replace(FILE_SUFFIX.actual, '');
|
|
363
|
+
unlinkSyncSafe(oldImg);
|
|
364
|
+
const diffImg = img.replace(FILE_SUFFIX.actual, FILE_SUFFIX.diff);
|
|
365
|
+
unlinkSyncSafe(diffImg);
|
|
366
|
+
await moveFile(img, oldImg);
|
|
367
|
+
return null;
|
|
368
|
+
};
|
|
369
|
+
const compareImagesTask = async (cypressConfig, cfg) => {
|
|
370
|
+
const messages = [];
|
|
371
|
+
// Stamp the screenshot with plugin metadata exactly once, so that every file
|
|
372
|
+
// derived from it (baseline via moveFile, kept .actual.png, manual rename)
|
|
373
|
+
// carries FRSOURCE_CPVRD_V and won't be silently rewritten on the next run.
|
|
374
|
+
const stampedImgNew = addPNGMetadata(cypressConfig, await scaleImageAndWrite({
|
|
375
|
+
scaleFactor: cfg.scaleFactor,
|
|
376
|
+
path: cfg.imgNew
|
|
377
|
+
}));
|
|
378
|
+
fs.writeFileSync(cfg.imgNew, stampedImgNew);
|
|
379
|
+
const rawImgNewBuffer = Buffer.from(stampedImgNew);
|
|
380
|
+
let imgDiff;
|
|
381
|
+
let imgNewBase64, imgOldBase64, imgDiffBase64;
|
|
382
|
+
let error = false;
|
|
383
|
+
if (fs.existsSync(cfg.imgOld) && cfg.updateImages !== true) {
|
|
384
|
+
const rawImgNew = PNG.sync.read(rawImgNewBuffer);
|
|
385
|
+
const rawImgOldBuffer = fs.readFileSync(cfg.imgOld);
|
|
386
|
+
const rawImgOld = PNG.sync.read(rawImgOldBuffer);
|
|
387
|
+
const isImgSizeDifferent = rawImgNew.height !== rawImgOld.height || rawImgNew.width !== rawImgOld.width;
|
|
388
|
+
const [imgNew, imgOld] = isImgSizeDifferent ? alignImagesToSameSize(rawImgNew, rawImgOld) : [rawImgNew, rawImgOld];
|
|
389
|
+
const {
|
|
390
|
+
width,
|
|
391
|
+
height
|
|
392
|
+
} = imgNew;
|
|
393
|
+
const diff = new PNG({
|
|
394
|
+
width,
|
|
395
|
+
height
|
|
396
|
+
});
|
|
397
|
+
const diffConfig = Object.assign({
|
|
398
|
+
includeAA: true
|
|
399
|
+
}, cfg.diffConfig);
|
|
400
|
+
const diffPixels = pixelmatch(new Uint8Array(imgNew.data), new Uint8Array(imgOld.data), diff.data, width, height, diffConfig);
|
|
401
|
+
imgDiff = diffPixels / (width * height);
|
|
402
|
+
if (isImgSizeDifferent) {
|
|
403
|
+
messages.push(`Warning: Images size mismatch - new screenshot is ${rawImgNew.width}px by ${rawImgNew.height}px while old one is ${rawImgOld.width}px by ${rawImgOld.height} (width x height).`);
|
|
404
|
+
}
|
|
405
|
+
if (imgDiff > cfg.maxDiffThreshold) {
|
|
406
|
+
messages.unshift(`Image diff factor (${round(imgDiff * 100)}%) is bigger than maximum threshold option ${round(cfg.maxDiffThreshold * 100)}%.`);
|
|
407
|
+
error = true;
|
|
408
|
+
}
|
|
409
|
+
const diffBuffer = PNG.sync.write(diff);
|
|
410
|
+
imgNewBase64 = PNG.sync.write(imgNew).toString('base64');
|
|
411
|
+
imgDiffBase64 = diffBuffer.toString('base64');
|
|
412
|
+
imgOldBase64 = PNG.sync.write(imgOld).toString('base64');
|
|
413
|
+
if (error && cfg.updateImages === 'failures-only') {
|
|
414
|
+
await moveFile(cfg.imgNew, cfg.imgOld);
|
|
415
|
+
// a diff image left by an earlier failed attempt is stale now
|
|
416
|
+
removeStaleDiff(cfg.imgNew);
|
|
417
|
+
error = false;
|
|
418
|
+
messages[0] = messages[0].replace('is bigger than maximum threshold option', 'was bigger than maximum threshold option (baseline updated):');
|
|
419
|
+
} else if (error) {
|
|
420
|
+
writePNG(cypressConfig, cfg.imgNew.replace(FILE_SUFFIX.actual, FILE_SUFFIX.diff), diffBuffer);
|
|
421
|
+
} else {
|
|
422
|
+
removeStaleDiff(cfg.imgNew);
|
|
423
|
+
if (rawImgOld && !isImageCurrentVersion(rawImgOldBuffer)) {
|
|
424
|
+
await moveFile(cfg.imgNew, cfg.imgOld);
|
|
425
|
+
} else {
|
|
426
|
+
// don't overwrite file if it's the same (imgDiff < cfg.maxDiffThreshold && !isImgSizeDifferent)
|
|
427
|
+
fs.unlinkSync(cfg.imgNew);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
} else {
|
|
431
|
+
// there is no "old screenshot" or screenshots should be immediately updated
|
|
432
|
+
imgDiff = 0;
|
|
433
|
+
imgNewBase64 = '';
|
|
434
|
+
imgDiffBase64 = '';
|
|
435
|
+
imgOldBase64 = '';
|
|
436
|
+
if (cfg.createMissingImages) {
|
|
437
|
+
await moveFile(cfg.imgNew, cfg.imgOld);
|
|
438
|
+
removeStaleDiff(cfg.imgNew);
|
|
439
|
+
} else {
|
|
440
|
+
error = true;
|
|
441
|
+
messages.unshift(`Baseline image is missing at path: "${cfg.imgOld}". Provide a baseline image or enable "createMissingImages" option in plugin configuration.`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (typeof imgDiff !== 'undefined') {
|
|
445
|
+
if (!error) {
|
|
446
|
+
messages.unshift(`Image diff factor (${round(imgDiff * 100)}%) is within boundaries of maximum threshold option ${round(cfg.maxDiffThreshold * 100)}%.`);
|
|
447
|
+
}
|
|
448
|
+
return {
|
|
449
|
+
error,
|
|
450
|
+
message: messages.join('\n'),
|
|
451
|
+
imgDiff,
|
|
452
|
+
imgNewBase64,
|
|
453
|
+
imgDiffBase64,
|
|
454
|
+
imgOldBase64,
|
|
455
|
+
maxDiffThreshold: cfg.maxDiffThreshold
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
/* c8 ignore next */
|
|
459
|
+
return null;
|
|
460
|
+
};
|
|
461
|
+
const doesFileExistTask = ({
|
|
462
|
+
path
|
|
463
|
+
}) => fs.existsSync(path);
|
|
464
|
+
const processImgPathTask = ({
|
|
465
|
+
path
|
|
466
|
+
}) => path;
|
|
467
|
+
const recordPendingDiffTask = record => {
|
|
468
|
+
pendingDiffs.push(record);
|
|
469
|
+
return pendingDiffs.filter(d => !d.passed).length;
|
|
470
|
+
};
|
|
471
|
+
const getPendingDiffsTask = () => [...pendingDiffs];
|
|
472
|
+
const clearPendingDiffsTask = () => {
|
|
473
|
+
pendingDiffs = [];
|
|
474
|
+
return null;
|
|
475
|
+
};
|
|
476
|
+
/* c8 ignore start */
|
|
477
|
+
const initTaskHook = config => ({
|
|
478
|
+
[TASK.getScreenshotPathInfo]: getScreenshotPathInfoTask,
|
|
479
|
+
[TASK.cleanupImages]: cleanupImagesTask.bind(undefined, config),
|
|
480
|
+
[TASK.doesFileExist]: doesFileExistTask,
|
|
481
|
+
[TASK.approveImage]: approveImageTask,
|
|
482
|
+
[TASK.compareImages]: compareImagesTask.bind(undefined, config),
|
|
483
|
+
[TASK.processImgPath]: processImgPathTask,
|
|
484
|
+
[TASK.recordPendingDiff]: recordPendingDiffTask,
|
|
485
|
+
[TASK.getPendingDiffs]: getPendingDiffsTask,
|
|
486
|
+
[TASK.clearPendingDiffs]: clearPendingDiffsTask
|
|
487
|
+
});
|
|
488
|
+
/* c8 ignore stop */
|
|
489
|
+
|
|
490
|
+
const MIMIC_ROOT_WIN_REGEX = new RegExp(`^${PATH_VARIABLES.winSystemRootPath}\\${path.sep}([A-Z])\\${path.sep}`);
|
|
491
|
+
const MIMIC_ROOT_UNIX_REGEX = new RegExp(`^${PATH_VARIABLES.unixSystemRootPath}\\${path.sep}`);
|
|
492
|
+
const getConfigVariableOrThrow = (config, name) => {
|
|
493
|
+
if (config[name]) {
|
|
494
|
+
return config[name];
|
|
495
|
+
}
|
|
496
|
+
/* c8 ignore start */
|
|
497
|
+
throw `[@pixsame/cypress-plugin-visual-regression-diff] CypressConfig.${name} cannot be missing or \`false\`!`;
|
|
498
|
+
};
|
|
499
|
+
/* c8 ignore stop */
|
|
500
|
+
const parseAbsolutePath = ({
|
|
501
|
+
screenshotPath,
|
|
502
|
+
projectRoot
|
|
503
|
+
}) => {
|
|
504
|
+
let newAbsolutePath;
|
|
505
|
+
const matchedMimicingWinRoot = screenshotPath.match(MIMIC_ROOT_WIN_REGEX);
|
|
506
|
+
const matchedMimicingUnixRoot = screenshotPath.match(MIMIC_ROOT_UNIX_REGEX);
|
|
507
|
+
if (matchedMimicingWinRoot && matchedMimicingWinRoot[1]) {
|
|
508
|
+
const driveLetter = matchedMimicingWinRoot[1];
|
|
509
|
+
newAbsolutePath = path.join(`${driveLetter}:\\`, screenshotPath.substring(matchedMimicingWinRoot[0].length));
|
|
510
|
+
} else if (matchedMimicingUnixRoot) {
|
|
511
|
+
newAbsolutePath = path.sep + screenshotPath.substring(matchedMimicingUnixRoot[0].length);
|
|
512
|
+
} else {
|
|
513
|
+
newAbsolutePath = path.join(projectRoot, screenshotPath);
|
|
514
|
+
}
|
|
515
|
+
return path.normalize(newAbsolutePath);
|
|
516
|
+
};
|
|
517
|
+
const initAfterScreenshotHook = config => details => {
|
|
518
|
+
var _details$name;
|
|
519
|
+
// it's not a screenshot generated by FRSOURCE Cypress Plugin Visual Regression Diff
|
|
520
|
+
/* c8 ignore start */
|
|
521
|
+
if (((_details$name = details.name) == null ? void 0 : _details$name.indexOf(IMAGE_SNAPSHOT_PREFIX)) !== 0) return;
|
|
522
|
+
/* c8 ignore stop */
|
|
523
|
+
const screenshotsFolder = getConfigVariableOrThrow(config, 'screenshotsFolder');
|
|
524
|
+
const screenshotPath = details.name.substring(IMAGE_SNAPSHOT_PREFIX.length + path.sep.length);
|
|
525
|
+
const newAbsolutePath = parseAbsolutePath({
|
|
526
|
+
screenshotPath,
|
|
527
|
+
projectRoot: config.projectRoot
|
|
528
|
+
});
|
|
529
|
+
return (async () => {
|
|
530
|
+
await moveFile(details.path, newAbsolutePath);
|
|
531
|
+
await promises.rm(path.join(screenshotsFolder, IMAGE_SNAPSHOT_PREFIX), {
|
|
532
|
+
recursive: true,
|
|
533
|
+
force: true
|
|
534
|
+
});
|
|
535
|
+
return {
|
|
536
|
+
path: newAbsolutePath
|
|
537
|
+
};
|
|
538
|
+
})();
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
/* c8 ignore start */
|
|
542
|
+
const initForceDeviceScaleFactor = on => {
|
|
543
|
+
// based on https://github.com/cypress-io/cypress/issues/2102#issuecomment-521299946
|
|
544
|
+
on('before:browser:launch', (browser, launchOptions) => {
|
|
545
|
+
if (browser.name === 'chrome' || browser.name === 'chromium') {
|
|
546
|
+
launchOptions.args.push('--force-device-scale-factor=1');
|
|
547
|
+
launchOptions.args.push('--high-dpi-support=1');
|
|
548
|
+
return launchOptions;
|
|
549
|
+
} else if (browser.name === 'electron' && browser.isHeaded) {
|
|
550
|
+
// eslint-disable-next-line no-console
|
|
551
|
+
console.log("There isn't currently a way of setting the device scale factor in Cypress when running headed electron so we disable the image regression commands.");
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
};
|
|
555
|
+
/* c8 ignore stop */
|
|
556
|
+
const initPlugin = (on, config) => {
|
|
557
|
+
/* c8 ignore start */
|
|
558
|
+
if (getPluginConfig(config, 'pluginVisualRegressionForceDeviceScaleFactor') !== false) {
|
|
559
|
+
initForceDeviceScaleFactor(on);
|
|
560
|
+
}
|
|
561
|
+
/* c8 ignore stop */
|
|
562
|
+
on('task', initTaskHook(config));
|
|
563
|
+
on('after:screenshot', initAfterScreenshotHook(config));
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
export { initPlugin };
|
|
567
|
+
//# sourceMappingURL=plugins.mjs.map
|