@wdio/image-comparison-core 1.2.4 → 2.0.0
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/CHANGELOG.md +48 -0
- package/dist/helpers/options.d.ts +1 -4
- package/dist/helpers/options.d.ts.map +1 -1
- package/dist/helpers/options.js +2 -11
- package/dist/methods/compareReport.interfaces.d.ts +1 -1
- package/dist/methods/compareReport.interfaces.d.ts.map +1 -1
- package/dist/methods/createCompareReport.test.js +4 -2
- package/dist/methods/images.d.ts +2 -1
- package/dist/methods/images.d.ts.map +1 -1
- package/dist/methods/images.executeImageCompare.test.js +39 -44
- package/dist/methods/images.js +31 -31
- package/dist/methods/images.test.js +80 -220
- package/dist/methods/processDiffPixels.d.ts +1 -1
- package/dist/methods/processDiffPixels.d.ts.map +1 -1
- package/dist/methods/processDiffPixels.js +11 -2
- package/dist/methods/rectangles.d.ts.map +1 -1
- package/dist/methods/rectangles.interfaces.d.ts +1 -1
- package/dist/methods/rectangles.interfaces.d.ts.map +1 -1
- package/dist/methods/rectangles.js +12 -9
- package/dist/pixelmatch/compare.interfaces.d.ts +43 -0
- package/dist/pixelmatch/compare.interfaces.d.ts.map +1 -0
- package/dist/pixelmatch/compareImages.d.ts.map +1 -0
- package/dist/pixelmatch/compareImages.js +173 -0
- package/dist/pixelmatch/compareImages.test.d.ts +2 -0
- package/dist/pixelmatch/compareImages.test.d.ts.map +1 -0
- package/dist/pixelmatch/compareImages.test.js +262 -0
- package/dist/utils/imageUtils.d.ts +17 -0
- package/dist/utils/imageUtils.d.ts.map +1 -0
- package/dist/utils/imageUtils.js +189 -0
- package/dist/utils/imageUtils.test.d.ts +2 -0
- package/dist/utils/imageUtils.test.d.ts.map +1 -0
- package/dist/utils/imageUtils.test.js +277 -0
- package/package.json +6 -5
- package/dist/resemble/compare.interfaces.d.ts +0 -136
- package/dist/resemble/compare.interfaces.d.ts.map +0 -1
- package/dist/resemble/compareImages.d.ts.map +0 -1
- package/dist/resemble/compareImages.js +0 -21
- package/dist/resemble/resemble.jimp.cjs +0 -981
- package/dist/resemble/resemble.jimp.d.cts +0 -46
- package/dist/resemble/resemble.jimp.d.cts.map +0 -1
- /package/dist/{resemble → pixelmatch}/compare.interfaces.js +0 -0
- /package/dist/{resemble → pixelmatch}/compareImages.d.ts +0 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { decodeImage } from '../utils/imageUtils.js';
|
|
2
3
|
import { ANDROID_OFFSETS, IOS_OFFSETS } from '../helpers/constants.js';
|
|
3
4
|
import { calculateDprData, getBase64ScreenshotSize, isObject } from '../helpers/utils.js';
|
|
4
5
|
import { getElementPositionAndroid, getElementPositionDesktop, getElementWebviewPosition } from './elementPosition.js';
|
|
@@ -374,10 +375,14 @@ export async function determineWebElementIgnoreRegions(options, ignores) {
|
|
|
374
375
|
// to reduce 1px boundary differences on high-DPR / BiDi.
|
|
375
376
|
let result = [...regions, ...regionsFromElements]
|
|
376
377
|
.map((region) => {
|
|
378
|
+
// Floor position (x/y) to include the start pixel, ceil size (width/height)
|
|
379
|
+
// to include the end pixel. Flooring size can lose 1px when CSS * DPR has
|
|
380
|
+
// a fractional part, causing the last row or column of an element to fall
|
|
381
|
+
// outside the ignored region.
|
|
377
382
|
let x = Math.floor(region.x * devicePixelRatio);
|
|
378
383
|
let y = Math.floor(region.y * devicePixelRatio);
|
|
379
|
-
let width = Math.
|
|
380
|
-
let height = Math.
|
|
384
|
+
let width = Math.ceil(region.width * devicePixelRatio);
|
|
385
|
+
let height = Math.ceil(region.height * devicePixelRatio);
|
|
381
386
|
if (padding > 0) {
|
|
382
387
|
x = Math.max(0, x - padding);
|
|
383
388
|
y = Math.max(0, y - padding);
|
|
@@ -498,10 +503,8 @@ export async function prepareIgnoreRectangles(options) {
|
|
|
498
503
|
}
|
|
499
504
|
}
|
|
500
505
|
if (webStatusAddressToolBarOptions.length > 0) {
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
// Additionally, rectangles with either width or height equal to 0 will result in an entire axis being ignored
|
|
504
|
-
// due to how resemble handles falsy values. Filter those out up front.
|
|
506
|
+
// Filter out zero-dimension rectangles: a 0,0,0,0 rect would block out the entire image,
|
|
507
|
+
// and rects with width or height of 0 produce undefined axis behaviour. Remove them upfront.
|
|
505
508
|
webStatusAddressToolBarOptions = webStatusAddressToolBarOptions
|
|
506
509
|
.filter((rectangle) => !(rectangle.x === 0 && rectangle.y === 0 && rectangle.width === 0 && rectangle.height === 0))
|
|
507
510
|
.filter((rectangle) => rectangle.width > 0 && rectangle.height > 0);
|
|
@@ -512,8 +515,8 @@ export async function prepareIgnoreRectangles(options) {
|
|
|
512
515
|
try {
|
|
513
516
|
// For iOS: block out home bar
|
|
514
517
|
if (!isAndroid && deviceRectangles.homeBar.height > 0) {
|
|
515
|
-
const image =
|
|
516
|
-
const imageHeightDevicePixels = image.
|
|
518
|
+
const image = decodeImage(readFileSync(actualFilePath));
|
|
519
|
+
const imageHeightDevicePixels = image.height;
|
|
517
520
|
const imageHeightCssPixels = imageHeightDevicePixels / devicePixelRatio;
|
|
518
521
|
// Adjust home bar X position relative to the viewport (full page image only contains viewport)
|
|
519
522
|
const viewportXCssPixels = deviceRectangles.viewport.x;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { BaseBoundingBox, BaseCoordinates } from '../base.interfaces.js';
|
|
2
|
+
import type { RawImage } from '../utils/imageUtils.js';
|
|
3
|
+
export interface CompareData {
|
|
4
|
+
/** The mismatch percentage like 0.12345567 */
|
|
5
|
+
rawMisMatchPercentage: number;
|
|
6
|
+
/** The mismatch percentage like 0.12 */
|
|
7
|
+
misMatchPercentage: number;
|
|
8
|
+
/** Raw RGBA pixel data of the diff composited on the actual screenshot, with dimensions */
|
|
9
|
+
getRawPixels: () => RawImage;
|
|
10
|
+
/** The diff image encoded as a PNG buffer */
|
|
11
|
+
getBuffer: () => Promise<Buffer>;
|
|
12
|
+
/** The bounds of the diff area */
|
|
13
|
+
diffBounds: BaseBoundingBox;
|
|
14
|
+
/** The analysis time in milliseconds */
|
|
15
|
+
analysisTime: number;
|
|
16
|
+
/** The diff pixels location(s) and color(s) */
|
|
17
|
+
diffPixels: BaseCoordinates[];
|
|
18
|
+
}
|
|
19
|
+
type Box = {
|
|
20
|
+
/** Left boundary of the box */
|
|
21
|
+
left: number;
|
|
22
|
+
/** Top boundary of the box */
|
|
23
|
+
top: number;
|
|
24
|
+
/** Right boundary of the box */
|
|
25
|
+
right: number;
|
|
26
|
+
/** Bottom boundary of the box */
|
|
27
|
+
bottom: number;
|
|
28
|
+
};
|
|
29
|
+
type OutputSettings = {
|
|
30
|
+
/** Box area to ignore during comparison */
|
|
31
|
+
ignoredBoxes?: Box[] | undefined;
|
|
32
|
+
};
|
|
33
|
+
export type ComparisonIgnoreOption = 'nothing' | 'less' | 'antialiasing' | 'colors' | 'alpha';
|
|
34
|
+
export interface ComparisonOptions {
|
|
35
|
+
/** Output settings for the comparison */
|
|
36
|
+
output?: OutputSettings | undefined;
|
|
37
|
+
/** Whether to scale images to the same size before comparison */
|
|
38
|
+
scaleToSameSize?: boolean | undefined;
|
|
39
|
+
/** What aspects to ignore during comparison */
|
|
40
|
+
ignore?: ComparisonIgnoreOption | ComparisonIgnoreOption[] | undefined;
|
|
41
|
+
}
|
|
42
|
+
export {};
|
|
43
|
+
//# sourceMappingURL=compare.interfaces.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"compare.interfaces.d.ts","sourceRoot":"","sources":["../../src/pixelmatch/compare.interfaces.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAA;AAC7E,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAA;AAEtD,MAAM,WAAW,WAAW;IACxB,8CAA8C;IAC9C,qBAAqB,EAAE,MAAM,CAAC;IAC9B,wCAAwC;IACxC,kBAAkB,EAAE,MAAM,CAAC;IAC3B,2FAA2F;IAC3F,YAAY,EAAE,MAAM,QAAQ,CAAC;IAC7B,6CAA6C;IAC7C,SAAS,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IACjC,kCAAkC;IAClC,UAAU,EAAE,eAAe,CAAC;IAC5B,wCAAwC;IACxC,YAAY,EAAE,MAAM,CAAC;IACrB,+CAA+C;IAC/C,UAAU,EAAE,eAAe,EAAE,CAAC;CACjC;AAED,KAAK,GAAG,GAAG;IACP,+BAA+B;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,8BAA8B;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,gCAAgC;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,iCAAiC;IACjC,MAAM,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF,KAAK,cAAc,GAAG;IAClB,2CAA2C;IAC3C,YAAY,CAAC,EAAE,GAAG,EAAE,GAAG,SAAS,CAAC;CACpC,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG,SAAS,GAAG,MAAM,GAAG,cAAc,GAAG,QAAQ,GAAG,OAAO,CAAC;AAE9F,MAAM,WAAW,iBAAiB;IAC9B,yCAAyC;IACzC,MAAM,CAAC,EAAE,cAAc,GAAG,SAAS,CAAC;IACpC,iEAAiE;IACjE,eAAe,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACtC,+CAA+C;IAC/C,MAAM,CAAC,EAAE,sBAAsB,GAAG,sBAAsB,EAAE,GAAG,SAAS,CAAC;CAC1E"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"compareImages.d.ts","sourceRoot":"","sources":["../../src/pixelmatch/compareImages.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,iBAAiB,EAA0B,MAAM,yBAAyB,CAAA;AAqErG,wBAA8B,aAAa,CACvC,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,iBAAiB,GAC3B,OAAO,CAAC,WAAW,CAAC,CA0HtB"}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import pixelmatch from 'pixelmatch';
|
|
2
|
+
import { decodeImage, resizeBilinear, encodeImage } from '../utils/imageUtils.js';
|
|
3
|
+
function resolveIgnoreList(ignore) {
|
|
4
|
+
if (!ignore) {
|
|
5
|
+
return [];
|
|
6
|
+
}
|
|
7
|
+
return Array.isArray(ignore) ? ignore : [ignore];
|
|
8
|
+
}
|
|
9
|
+
function toPixelmatchOptions(ignoreList) {
|
|
10
|
+
if (ignoreList.includes('nothing')) {
|
|
11
|
+
return { threshold: 0, includeAA: true };
|
|
12
|
+
}
|
|
13
|
+
if (ignoreList.includes('less')) {
|
|
14
|
+
// 16/255 per channel in resemble maps roughly to ~6.3% of max YIQ distance
|
|
15
|
+
return { threshold: 0.063, includeAA: false };
|
|
16
|
+
}
|
|
17
|
+
// 'antialiasing', 'alpha', 'colors' and the default.
|
|
18
|
+
// Resemble's ignoreAntialiasing uses 32/255 per-channel tolerance which
|
|
19
|
+
// corresponds to ~0.13 in YIQ perceptual distance. Using 0.1 is stricter
|
|
20
|
+
// and causes invisible sub-pixel differences to register as failures.
|
|
21
|
+
return { threshold: 0.13, includeAA: false };
|
|
22
|
+
}
|
|
23
|
+
function grayscalePixels(pixels, totalPixels) {
|
|
24
|
+
for (let i = 0; i < totalPixels * 4; i += 4) {
|
|
25
|
+
const luma = Math.round(0.299 * pixels[i] + 0.587 * pixels[i + 1] + 0.114 * pixels[i + 2]);
|
|
26
|
+
pixels[i] = luma;
|
|
27
|
+
pixels[i + 1] = luma;
|
|
28
|
+
pixels[i + 2] = luma;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function opaqueAlphaChannel(pixels, totalPixels) {
|
|
32
|
+
for (let i = 3; i < totalPixels * 4; i += 4) {
|
|
33
|
+
pixels[i] = 255;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Pad a raw RGBA pixel buffer to a larger canvas size, placing the source at
|
|
37
|
+
// position (0, 0) and filling the remaining area with opaque white.
|
|
38
|
+
// Pad source at (0, 0) and fill the remaining area with opaque white.
|
|
39
|
+
function padToSize(src, srcW, srcH, dstW, dstH) {
|
|
40
|
+
const dst = Buffer.alloc(dstW * dstH * 4, 255); // opaque white
|
|
41
|
+
for (let y = 0; y < srcH; y++) {
|
|
42
|
+
src.copy(dst, y * dstW * 4, y * srcW * 4, (y + 1) * srcW * 4);
|
|
43
|
+
}
|
|
44
|
+
return dst;
|
|
45
|
+
}
|
|
46
|
+
function zeroIgnoredBoxes(pixels, width, boxes) {
|
|
47
|
+
for (const box of boxes) {
|
|
48
|
+
for (let y = box.top; y <= box.bottom; y++) {
|
|
49
|
+
for (let x = box.left; x <= box.right; x++) {
|
|
50
|
+
const offset = (y * width + x) * 4;
|
|
51
|
+
pixels[offset] = 0;
|
|
52
|
+
pixels[offset + 1] = 0;
|
|
53
|
+
pixels[offset + 2] = 0;
|
|
54
|
+
pixels[offset + 3] = 0;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export default async function compareImages(image1, image2, options) {
|
|
60
|
+
const start = Date.now();
|
|
61
|
+
let img1 = decodeImage(image1);
|
|
62
|
+
let img2 = decodeImage(image2);
|
|
63
|
+
if (options.scaleToSameSize) {
|
|
64
|
+
const size1 = img1.width * img1.height;
|
|
65
|
+
const size2 = img2.width * img2.height;
|
|
66
|
+
if (size1 > size2) {
|
|
67
|
+
img2 = resizeBilinear(img2, img1.width, img1.height);
|
|
68
|
+
}
|
|
69
|
+
else if (size2 > size1) {
|
|
70
|
+
img1 = resizeBilinear(img1, img2.width, img2.height);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Determine the target canvas size (max of both dimensions).
|
|
74
|
+
const width = Math.max(img1.width, img2.width);
|
|
75
|
+
const height = Math.max(img1.height, img2.height);
|
|
76
|
+
const totalPixels = width * height;
|
|
77
|
+
// Copy bitmap data into mutable buffers, padding smaller images at (0,0)
|
|
78
|
+
// with opaque white so content is not shifted by centering.
|
|
79
|
+
const pixels1 = img1.width === width && img1.height === height
|
|
80
|
+
? Buffer.from(img1.data)
|
|
81
|
+
: padToSize(Buffer.from(img1.data), img1.width, img1.height, width, height);
|
|
82
|
+
const pixels2 = img2.width === width && img2.height === height
|
|
83
|
+
? Buffer.from(img2.data)
|
|
84
|
+
: padToSize(Buffer.from(img2.data), img2.width, img2.height, width, height);
|
|
85
|
+
// Snapshot the original actual pixels before any comparison transforms (grayscale,
|
|
86
|
+
// alpha-opaque, zero-out). The diff image uses this as its background so the real
|
|
87
|
+
// screenshot content is always visible, including inside blockout regions.
|
|
88
|
+
const displayPixels2 = Buffer.from(pixels2);
|
|
89
|
+
const ignoreList = resolveIgnoreList(options.ignore);
|
|
90
|
+
if (ignoreList.includes('colors')) {
|
|
91
|
+
grayscalePixels(pixels1, totalPixels);
|
|
92
|
+
grayscalePixels(pixels2, totalPixels);
|
|
93
|
+
}
|
|
94
|
+
if (ignoreList.includes('alpha')) {
|
|
95
|
+
opaqueAlphaChannel(pixels1, totalPixels);
|
|
96
|
+
opaqueAlphaChannel(pixels2, totalPixels);
|
|
97
|
+
}
|
|
98
|
+
const ignoredBoxes = options.output?.ignoredBoxes ?? [];
|
|
99
|
+
if (ignoredBoxes.length > 0) {
|
|
100
|
+
zeroIgnoredBoxes(pixels1, width, ignoredBoxes);
|
|
101
|
+
zeroIgnoredBoxes(pixels2, width, ignoredBoxes);
|
|
102
|
+
}
|
|
103
|
+
const { threshold, includeAA } = toPixelmatchOptions(ignoreList);
|
|
104
|
+
const outputPixels = new Uint8Array(totalPixels * 4);
|
|
105
|
+
// Use magenta [255, 0, 255] for both diff and AA pixels.
|
|
106
|
+
const diffCount = pixelmatch(pixels1, pixels2, outputPixels, width, height, {
|
|
107
|
+
threshold,
|
|
108
|
+
includeAA,
|
|
109
|
+
diffColor: [255, 0, 255],
|
|
110
|
+
aaColor: [255, 0, 255],
|
|
111
|
+
});
|
|
112
|
+
// Collect diff pixel coordinates from the output buffer.
|
|
113
|
+
// Both diff and AA pixels are drawn in magenta [255, 0, 255]; grayscale pixels are matches.
|
|
114
|
+
const diffPixels = [];
|
|
115
|
+
let left = width;
|
|
116
|
+
let top = height;
|
|
117
|
+
let right = 0;
|
|
118
|
+
let bottom = 0;
|
|
119
|
+
for (let i = 0; i < outputPixels.length; i += 4) {
|
|
120
|
+
if (outputPixels[i] === 255 && outputPixels[i + 1] === 0 && outputPixels[i + 2] === 255) {
|
|
121
|
+
const pixelIndex = i / 4;
|
|
122
|
+
const x = pixelIndex % width;
|
|
123
|
+
const y = Math.floor(pixelIndex / width);
|
|
124
|
+
diffPixels.push({ x, y });
|
|
125
|
+
if (x < left) {
|
|
126
|
+
left = x;
|
|
127
|
+
}
|
|
128
|
+
if (x > right) {
|
|
129
|
+
right = x;
|
|
130
|
+
}
|
|
131
|
+
if (y < top) {
|
|
132
|
+
top = y;
|
|
133
|
+
}
|
|
134
|
+
if (y > bottom) {
|
|
135
|
+
bottom = y;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const diffBounds = diffPixels.length > 0
|
|
140
|
+
? { left, top, right, bottom }
|
|
141
|
+
: { left: width, top: height, right: 0, bottom: 0 };
|
|
142
|
+
// Single-pass blend: paint diff pixels (magenta) on top of the actual screenshot.
|
|
143
|
+
// pixels2 is already in memory and normalised to the canvas size, so no extra decode needed.
|
|
144
|
+
const getRawPixels = () => {
|
|
145
|
+
const data = new Uint8Array(totalPixels * 4);
|
|
146
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
147
|
+
if (outputPixels[i] === 255 && outputPixels[i + 1] === 0 && outputPixels[i + 2] === 255) {
|
|
148
|
+
data[i] = 255;
|
|
149
|
+
data[i + 1] = 0;
|
|
150
|
+
data[i + 2] = 255;
|
|
151
|
+
data[i + 3] = 255;
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
data[i] = displayPixels2[i];
|
|
155
|
+
data[i + 1] = displayPixels2[i + 1];
|
|
156
|
+
data[i + 2] = displayPixels2[i + 2];
|
|
157
|
+
data[i + 3] = displayPixels2[i + 3];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { data, width, height };
|
|
161
|
+
};
|
|
162
|
+
const getBuffer = async () => encodeImage(getRawPixels());
|
|
163
|
+
const rawMisMatchPercentage = (diffCount / totalPixels) * 100;
|
|
164
|
+
return {
|
|
165
|
+
rawMisMatchPercentage,
|
|
166
|
+
misMatchPercentage: Number(rawMisMatchPercentage.toFixed(2)),
|
|
167
|
+
getRawPixels,
|
|
168
|
+
getBuffer,
|
|
169
|
+
diffBounds,
|
|
170
|
+
analysisTime: Date.now() - start,
|
|
171
|
+
diffPixels,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"compareImages.test.d.ts","sourceRoot":"","sources":["../../src/pixelmatch/compareImages.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
vi.mock('pixelmatch', () => ({
|
|
3
|
+
default: vi.fn()
|
|
4
|
+
}));
|
|
5
|
+
vi.mock('../utils/imageUtils.js', () => {
|
|
6
|
+
const makeImage = (width = 100, height = 100) => ({
|
|
7
|
+
data: new Uint8Array(width * height * 4).fill(128),
|
|
8
|
+
width,
|
|
9
|
+
height,
|
|
10
|
+
});
|
|
11
|
+
return {
|
|
12
|
+
decodeImage: vi.fn().mockImplementation(() => makeImage()),
|
|
13
|
+
resizeBilinear: vi.fn().mockImplementation((_img, w, h) => makeImage(w, h)),
|
|
14
|
+
encodeImage: vi.fn().mockReturnValue(Buffer.from('png-data')),
|
|
15
|
+
};
|
|
16
|
+
});
|
|
17
|
+
import compareImages from './compareImages.js';
|
|
18
|
+
import pixelmatch from 'pixelmatch';
|
|
19
|
+
import * as imageUtils from '../utils/imageUtils.js';
|
|
20
|
+
const pixelmatchFn = vi.mocked(pixelmatch);
|
|
21
|
+
const decodeImageFn = vi.mocked(imageUtils.decodeImage);
|
|
22
|
+
const resizeBilinearFn = vi.mocked(imageUtils.resizeBilinear);
|
|
23
|
+
describe('pixelmatch adapter - compareImages', () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
vi.clearAllMocks();
|
|
26
|
+
decodeImageFn.mockReturnValue({ data: new Uint8Array(100 * 100 * 4).fill(128), width: 100, height: 100 });
|
|
27
|
+
});
|
|
28
|
+
describe('basic comparison', () => {
|
|
29
|
+
it('returns zero mismatch percentage when images are identical', async () => {
|
|
30
|
+
pixelmatchFn.mockImplementation(() => 0);
|
|
31
|
+
const result = await compareImages(Buffer.from('img1'), Buffer.from('img2'), {});
|
|
32
|
+
expect(result.rawMisMatchPercentage).toBe(0);
|
|
33
|
+
expect(result.misMatchPercentage).toBe(0);
|
|
34
|
+
expect(result.diffPixels).toHaveLength(0);
|
|
35
|
+
});
|
|
36
|
+
it('returns correct mismatch percentage for a known diff count', async () => {
|
|
37
|
+
// 100x100 = 10000 total pixels, 100 diff pixels = 1%
|
|
38
|
+
pixelmatchFn.mockImplementation(() => 100);
|
|
39
|
+
const result = await compareImages(Buffer.from('img1'), Buffer.from('img2'), {});
|
|
40
|
+
expect(result.rawMisMatchPercentage).toBeCloseTo(1, 5);
|
|
41
|
+
expect(result.misMatchPercentage).toBe(1);
|
|
42
|
+
});
|
|
43
|
+
it('returns analysisTime greater than or equal to 0', async () => {
|
|
44
|
+
pixelmatchFn.mockImplementation(() => 0);
|
|
45
|
+
const result = await compareImages(Buffer.from('img1'), Buffer.from('img2'), {});
|
|
46
|
+
expect(result.analysisTime).toBeGreaterThanOrEqual(0);
|
|
47
|
+
});
|
|
48
|
+
it('resolves getBuffer to a Buffer', async () => {
|
|
49
|
+
pixelmatchFn.mockImplementation(() => 0);
|
|
50
|
+
const result = await compareImages(Buffer.from('img1'), Buffer.from('img2'), {});
|
|
51
|
+
const buf = await result.getBuffer();
|
|
52
|
+
expect(Buffer.isBuffer(buf)).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
it('getRawPixels paints diff pixels magenta and keeps actual pixels for matches', async () => {
|
|
55
|
+
const actual = new Uint8Array(100 * 100 * 4).fill(200); // distinctive actual colour
|
|
56
|
+
decodeImageFn
|
|
57
|
+
.mockReturnValueOnce({ data: new Uint8Array(100 * 100 * 4).fill(128), width: 100, height: 100 }) // baseline
|
|
58
|
+
.mockReturnValueOnce({ data: actual, width: 100, height: 100 }); // actual
|
|
59
|
+
pixelmatchFn.mockImplementation((_img1, _img2, output) => {
|
|
60
|
+
// Mark pixel at (0,0) as a diff (magenta)
|
|
61
|
+
output[0] = 255;
|
|
62
|
+
output[1] = 0;
|
|
63
|
+
output[2] = 255;
|
|
64
|
+
output[3] = 255;
|
|
65
|
+
// Pixel at (1,0) remains 0 - match
|
|
66
|
+
return 1;
|
|
67
|
+
});
|
|
68
|
+
const result = await compareImages(Buffer.from('img1'), Buffer.from('img2'), {});
|
|
69
|
+
const raw = result.getRawPixels();
|
|
70
|
+
// Diff pixel → magenta
|
|
71
|
+
expect(raw.data[0]).toBe(255);
|
|
72
|
+
expect(raw.data[1]).toBe(0);
|
|
73
|
+
expect(raw.data[2]).toBe(255);
|
|
74
|
+
expect(raw.data[3]).toBe(255);
|
|
75
|
+
// Match pixel → actual screenshot value (200)
|
|
76
|
+
expect(raw.data[4]).toBe(200);
|
|
77
|
+
expect(raw.data[5]).toBe(200);
|
|
78
|
+
expect(raw.data[6]).toBe(200);
|
|
79
|
+
expect(raw.width).toBe(100);
|
|
80
|
+
expect(raw.height).toBe(100);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
describe('diffPixels and diffBounds', () => {
|
|
84
|
+
it('collects magenta pixels as diff pixel coordinates', async () => {
|
|
85
|
+
pixelmatchFn.mockImplementation((_img1, _img2, output, width) => {
|
|
86
|
+
// Place a magenta pixel at x=5, y=3
|
|
87
|
+
const pos = (3 * width + 5) * 4;
|
|
88
|
+
output[pos] = 255;
|
|
89
|
+
output[pos + 1] = 0;
|
|
90
|
+
output[pos + 2] = 255;
|
|
91
|
+
output[pos + 3] = 255;
|
|
92
|
+
return 1;
|
|
93
|
+
});
|
|
94
|
+
const result = await compareImages(Buffer.from('img1'), Buffer.from('img2'), {});
|
|
95
|
+
expect(result.diffPixels).toHaveLength(1);
|
|
96
|
+
expect(result.diffPixels[0]).toEqual({ x: 5, y: 3 });
|
|
97
|
+
});
|
|
98
|
+
it('does not count grayscale matching pixels as diff pixels', async () => {
|
|
99
|
+
pixelmatchFn.mockImplementation((_img1, _img2, output, width) => {
|
|
100
|
+
const pos = (2 * width + 10) * 4;
|
|
101
|
+
output[pos] = 200;
|
|
102
|
+
output[pos + 1] = 200;
|
|
103
|
+
output[pos + 2] = 200;
|
|
104
|
+
output[pos + 3] = 255;
|
|
105
|
+
return 0;
|
|
106
|
+
});
|
|
107
|
+
const result = await compareImages(Buffer.from('img1'), Buffer.from('img2'), {});
|
|
108
|
+
expect(result.diffPixels).toHaveLength(0);
|
|
109
|
+
});
|
|
110
|
+
it('computes correct diffBounds from multiple diff pixels', async () => {
|
|
111
|
+
pixelmatchFn.mockImplementation((_img1, _img2, output, width) => {
|
|
112
|
+
const mark = (x, y) => {
|
|
113
|
+
const pos = (y * width + x) * 4;
|
|
114
|
+
output[pos] = 255;
|
|
115
|
+
output[pos + 1] = 0;
|
|
116
|
+
output[pos + 2] = 255;
|
|
117
|
+
output[pos + 3] = 255;
|
|
118
|
+
};
|
|
119
|
+
mark(10, 5);
|
|
120
|
+
mark(30, 20);
|
|
121
|
+
mark(20, 10);
|
|
122
|
+
return 3;
|
|
123
|
+
});
|
|
124
|
+
const result = await compareImages(Buffer.from('img1'), Buffer.from('img2'), {});
|
|
125
|
+
expect(result.diffBounds).toEqual({ left: 10, top: 5, right: 30, bottom: 20 });
|
|
126
|
+
});
|
|
127
|
+
it('returns sentinel diffBounds when there are no diff pixels', async () => {
|
|
128
|
+
pixelmatchFn.mockImplementation(() => 0);
|
|
129
|
+
const result = await compareImages(Buffer.from('img1'), Buffer.from('img2'), {});
|
|
130
|
+
expect(result.diffBounds.left).toBeGreaterThan(result.diffBounds.right);
|
|
131
|
+
expect(result.diffBounds.top).toBeGreaterThan(result.diffBounds.bottom);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
describe('ignore option mapping', () => {
|
|
135
|
+
it('passes threshold=0 and includeAA=true for ignore: nothing', async () => {
|
|
136
|
+
pixelmatchFn.mockImplementation(() => 0);
|
|
137
|
+
await compareImages(Buffer.from('img1'), Buffer.from('img2'), { ignore: 'nothing' });
|
|
138
|
+
expect(pixelmatchFn).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), expect.any(Number), expect.any(Number), expect.objectContaining({ threshold: 0, includeAA: true }));
|
|
139
|
+
});
|
|
140
|
+
it('passes threshold=0.063 and includeAA=false for ignore: less', async () => {
|
|
141
|
+
pixelmatchFn.mockImplementation(() => 0);
|
|
142
|
+
await compareImages(Buffer.from('img1'), Buffer.from('img2'), { ignore: 'less' });
|
|
143
|
+
expect(pixelmatchFn).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), expect.any(Number), expect.any(Number), expect.objectContaining({ threshold: 0.063, includeAA: false }));
|
|
144
|
+
});
|
|
145
|
+
it('passes threshold=0.13 and includeAA=false for ignore: antialiasing', async () => {
|
|
146
|
+
pixelmatchFn.mockImplementation(() => 0);
|
|
147
|
+
await compareImages(Buffer.from('img1'), Buffer.from('img2'), { ignore: 'antialiasing' });
|
|
148
|
+
expect(pixelmatchFn).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), expect.any(Number), expect.any(Number), expect.objectContaining({ threshold: 0.13, includeAA: false }));
|
|
149
|
+
});
|
|
150
|
+
it('passes threshold=0.13 and includeAA=false when no ignore option is given', async () => {
|
|
151
|
+
pixelmatchFn.mockImplementation(() => 0);
|
|
152
|
+
await compareImages(Buffer.from('img1'), Buffer.from('img2'), {});
|
|
153
|
+
expect(pixelmatchFn).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), expect.any(Number), expect.any(Number), expect.objectContaining({ threshold: 0.13, includeAA: false }));
|
|
154
|
+
});
|
|
155
|
+
it('accepts ignore as an array and uses the highest-priority mode', async () => {
|
|
156
|
+
pixelmatchFn.mockImplementation(() => 0);
|
|
157
|
+
await compareImages(Buffer.from('img1'), Buffer.from('img2'), {
|
|
158
|
+
ignore: ['antialiasing', 'less']
|
|
159
|
+
});
|
|
160
|
+
expect(pixelmatchFn).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), expect.any(Number), expect.any(Number), expect.objectContaining({ threshold: 0.063 }));
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
describe('pixel transformations', () => {
|
|
164
|
+
it('grayscales both pixel arrays when ignore includes colors', async () => {
|
|
165
|
+
let capturedPixels1;
|
|
166
|
+
pixelmatchFn.mockImplementation((img1) => {
|
|
167
|
+
capturedPixels1 = new Uint8Array(img1);
|
|
168
|
+
return 0;
|
|
169
|
+
});
|
|
170
|
+
await compareImages(Buffer.from('img1'), Buffer.from('img2'), { ignore: 'colors' });
|
|
171
|
+
// After grayscale, R=G=B for every pixel (luma of 128,128,128 = 128)
|
|
172
|
+
expect(capturedPixels1[0]).toBe(capturedPixels1[1]);
|
|
173
|
+
expect(capturedPixels1[1]).toBe(capturedPixels1[2]);
|
|
174
|
+
});
|
|
175
|
+
it('sets all alpha channels to 255 when ignore includes alpha', async () => {
|
|
176
|
+
// Use image data with alpha < 255
|
|
177
|
+
decodeImageFn.mockReturnValue({
|
|
178
|
+
data: new Uint8Array(100 * 100 * 4).fill(0), // all zeros, including alpha
|
|
179
|
+
width: 100,
|
|
180
|
+
height: 100,
|
|
181
|
+
});
|
|
182
|
+
let capturedPixels1;
|
|
183
|
+
pixelmatchFn.mockImplementation((img1) => {
|
|
184
|
+
capturedPixels1 = new Uint8Array(img1);
|
|
185
|
+
return 0;
|
|
186
|
+
});
|
|
187
|
+
await compareImages(Buffer.from('img1'), Buffer.from('img2'), { ignore: 'alpha' });
|
|
188
|
+
// After opaqueAlphaChannel, every 4th byte should be 255
|
|
189
|
+
expect(capturedPixels1[3]).toBe(255);
|
|
190
|
+
expect(capturedPixels1[7]).toBe(255);
|
|
191
|
+
});
|
|
192
|
+
it('pads img2 to canvas size when img1 is larger', async () => {
|
|
193
|
+
decodeImageFn
|
|
194
|
+
.mockReturnValueOnce({ data: new Uint8Array(100 * 100 * 4).fill(128), width: 100, height: 100 })
|
|
195
|
+
.mockReturnValueOnce({ data: new Uint8Array(50 * 50 * 4).fill(64), width: 50, height: 50 });
|
|
196
|
+
pixelmatchFn.mockImplementation(() => 0);
|
|
197
|
+
await compareImages(Buffer.from('img1'), Buffer.from('img2'), {});
|
|
198
|
+
// Canvas is 100×100; img2 (50×50) is padded to fill it
|
|
199
|
+
expect(pixelmatchFn).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), expect.any(Uint8Array), 100, 100, expect.any(Object));
|
|
200
|
+
});
|
|
201
|
+
it('pads img1 to canvas size when img2 is larger', async () => {
|
|
202
|
+
decodeImageFn
|
|
203
|
+
.mockReturnValueOnce({ data: new Uint8Array(50 * 50 * 4).fill(64), width: 50, height: 50 })
|
|
204
|
+
.mockReturnValueOnce({ data: new Uint8Array(100 * 100 * 4).fill(128), width: 100, height: 100 });
|
|
205
|
+
pixelmatchFn.mockImplementation(() => 0);
|
|
206
|
+
await compareImages(Buffer.from('img1'), Buffer.from('img2'), {});
|
|
207
|
+
// Canvas is 100×100; img1 (50×50) is padded to fill it
|
|
208
|
+
expect(pixelmatchFn).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), expect.any(Uint8Array), 100, 100, expect.any(Object));
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
describe('ignoredBoxes', () => {
|
|
212
|
+
it('zeroes out the specified box regions in both pixel arrays before comparison', async () => {
|
|
213
|
+
let capturedImg1;
|
|
214
|
+
let capturedImg2;
|
|
215
|
+
pixelmatchFn.mockImplementation((img1, img2) => {
|
|
216
|
+
capturedImg1 = new Uint8Array(img1);
|
|
217
|
+
capturedImg2 = new Uint8Array(img2);
|
|
218
|
+
return 0;
|
|
219
|
+
});
|
|
220
|
+
await compareImages(Buffer.from('img1'), Buffer.from('img2'), {
|
|
221
|
+
output: {
|
|
222
|
+
ignoredBoxes: [{ left: 0, top: 0, right: 0, bottom: 0 }]
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
expect(capturedImg1[0]).toBe(0);
|
|
226
|
+
expect(capturedImg1[1]).toBe(0);
|
|
227
|
+
expect(capturedImg1[2]).toBe(0);
|
|
228
|
+
expect(capturedImg1[3]).toBe(0);
|
|
229
|
+
expect(capturedImg2[0]).toBe(0);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
describe('scaleToSameSize', () => {
|
|
233
|
+
it('calls resizeBilinear on the smaller image when images differ in size', async () => {
|
|
234
|
+
decodeImageFn
|
|
235
|
+
.mockReturnValueOnce({ data: new Uint8Array(100 * 100 * 4), width: 100, height: 100 })
|
|
236
|
+
.mockReturnValueOnce({ data: new Uint8Array(50 * 50 * 4), width: 50, height: 50 });
|
|
237
|
+
pixelmatchFn.mockImplementation(() => 0);
|
|
238
|
+
await compareImages(Buffer.from('img1'), Buffer.from('img2'), { scaleToSameSize: true });
|
|
239
|
+
expect(resizeBilinearFn).toHaveBeenCalledTimes(1);
|
|
240
|
+
expect(resizeBilinearFn).toHaveBeenCalledWith(expect.objectContaining({ width: 50, height: 50 }), 100, 100);
|
|
241
|
+
});
|
|
242
|
+
it('resizes img1 when img2 is larger', async () => {
|
|
243
|
+
decodeImageFn
|
|
244
|
+
.mockReturnValueOnce({ data: new Uint8Array(50 * 50 * 4), width: 50, height: 50 })
|
|
245
|
+
.mockReturnValueOnce({ data: new Uint8Array(100 * 100 * 4), width: 100, height: 100 });
|
|
246
|
+
pixelmatchFn.mockImplementation(() => 0);
|
|
247
|
+
await compareImages(Buffer.from('img1'), Buffer.from('img2'), { scaleToSameSize: true });
|
|
248
|
+
expect(resizeBilinearFn).toHaveBeenCalledTimes(1);
|
|
249
|
+
expect(resizeBilinearFn).toHaveBeenCalledWith(expect.objectContaining({ width: 50, height: 50 }), 100, 100);
|
|
250
|
+
});
|
|
251
|
+
it('does not call resizeBilinear when scaleToSameSize is false', async () => {
|
|
252
|
+
pixelmatchFn.mockImplementation(() => 0);
|
|
253
|
+
await compareImages(Buffer.from('img1'), Buffer.from('img2'), { scaleToSameSize: false });
|
|
254
|
+
expect(resizeBilinearFn).not.toHaveBeenCalled();
|
|
255
|
+
});
|
|
256
|
+
it('does not call resizeBilinear when images are the same size', async () => {
|
|
257
|
+
pixelmatchFn.mockImplementation(() => 0);
|
|
258
|
+
await compareImages(Buffer.from('img1'), Buffer.from('img2'), { scaleToSameSize: true });
|
|
259
|
+
expect(resizeBilinearFn).not.toHaveBeenCalled();
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface RawImage {
|
|
2
|
+
data: Uint8Array;
|
|
3
|
+
width: number;
|
|
4
|
+
height: number;
|
|
5
|
+
}
|
|
6
|
+
export declare function decodeImage(buffer: Buffer): RawImage;
|
|
7
|
+
export declare function encodeImage(img: RawImage): Buffer;
|
|
8
|
+
export declare function toBase64Png(img: RawImage): string;
|
|
9
|
+
export declare function createCanvas(width: number, height: number, r?: number, g?: number, b?: number, a?: number): RawImage;
|
|
10
|
+
export declare function cropImage(img: RawImage, x: number, y: number, w: number, h: number): RawImage;
|
|
11
|
+
export declare function compositeImage(base: RawImage, overlay: RawImage, offsetX: number, offsetY: number, opacity?: number): void;
|
|
12
|
+
export declare function resizeBilinear(img: RawImage, newW: number, newH: number): RawImage;
|
|
13
|
+
export declare function rotate90CW(img: RawImage): RawImage;
|
|
14
|
+
export declare function rotate90CCW(img: RawImage): RawImage;
|
|
15
|
+
export declare function rotate180(img: RawImage): RawImage;
|
|
16
|
+
export declare function setOpacity(img: RawImage, opacity: number): void;
|
|
17
|
+
//# sourceMappingURL=imageUtils.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"imageUtils.d.ts","sourceRoot":"","sources":["../../src/utils/imageUtils.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,QAAQ;IACrB,IAAI,EAAE,UAAU,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;CACjB;AAsCD,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ,CAOpD;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,QAAQ,GAAG,MAAM,CAEjD;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,QAAQ,GAAG,MAAM,CAEjD;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,SAAI,EAAE,CAAC,SAAI,EAAE,CAAC,SAAI,EAAE,CAAC,SAAI,GAAG,QAAQ,CAWhG;AAED,wBAAgB,SAAS,CAAC,GAAG,EAAE,QAAQ,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,QAAQ,CAQ7F;AAGD,wBAAgB,cAAc,CAAC,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,SAAI,GAAG,IAAI,CAuBrH;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,QAAQ,CAmClF;AAID,wBAAgB,UAAU,CAAC,GAAG,EAAE,QAAQ,GAAG,QAAQ,CAiBlD;AAID,wBAAgB,WAAW,CAAC,GAAG,EAAE,QAAQ,GAAG,QAAQ,CAiBnD;AAED,wBAAgB,SAAS,CAAC,GAAG,EAAE,QAAQ,GAAG,QAAQ,CAYjD;AAGD,wBAAgB,UAAU,CAAC,GAAG,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAI/D"}
|