flexysnap 0.3.0 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flexysnap",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Flexible visual snapshot testing for Playwright, built for e-commerce.",
5
5
  "keywords": [
6
6
  "playwright",
@@ -60,10 +60,7 @@ async function annotateWireframeFile(wireframeFile, screenshotFile) {
60
60
  box: { fill: 'rgba(220, 160, 40, 0.4)', stroke: '#E6A500' },
61
61
  image: { fill: 'rgba(70, 180, 120, 0.4)', stroke: '#2EBC6F' },
62
62
  text: { fill: 'rgba(50, 130, 190, 0.4)', stroke: '#0066CC' },
63
- error: { fill: 'rgba(211, 47, 47, 0.4)', stroke: '#D32F2F' },
64
- missing: { fill: 'rgba(233, 30, 140, 0.4)', stroke: '#E91E8C' },
65
- extra: { fill: 'rgba(156, 39, 176, 0.4)', stroke: '#9C27B0' },
66
- shift: { fill: 'rgba(121, 85, 72, 0.4)', stroke: '#795548' }
63
+ error: { fill: 'rgba(211, 47, 47, 0.4)', stroke: '#D32F2F' }
67
64
  };
68
65
 
69
66
  function determineColorKey(obj, defaultKey) {
@@ -71,24 +68,90 @@ async function annotateWireframeFile(wireframeFile, screenshotFile) {
71
68
  return defaultKey;
72
69
  }
73
70
 
74
- const differenceTypes = obj.differences.map(difference => difference.type);
71
+ const hasError = obj.differences.some(difference => difference.kind === 'error');
75
72
 
76
- if (differenceTypes.includes('missing_text') || differenceTypes.includes('missing_element')) {
77
- return 'missing';
73
+ return hasError ? 'error' : defaultKey;
74
+ }
75
+
76
+ function hasDifferenceType(differences, types) {
77
+ if (!differences || !Array.isArray(differences) || differences.length === 0) {
78
+ return false;
78
79
  }
79
80
 
80
- if (differenceTypes.includes('extra_text') || differenceTypes.includes('extra_element')) {
81
- return 'extra';
81
+ return differences.some(difference => types.includes(difference.type));
82
+ }
83
+
84
+ function getLineDash(differences) {
85
+ const hasLayoutShift = hasDifferenceType(differences, ['layout_shift']);
86
+ const hasSizeMismatch = hasDifferenceType(differences, ['size_mismatch']);
87
+
88
+ if (hasLayoutShift && hasSizeMismatch) {
89
+ return [6, 2, 1, 2];
82
90
  }
83
91
 
84
- if (differenceTypes.includes('layout_shift') || differenceTypes.includes('size_mismatch')) {
85
- return 'shift';
92
+ if (hasLayoutShift) {
93
+ return [6, 4];
86
94
  }
87
95
 
88
- return 'error';
96
+ if (hasSizeMismatch) {
97
+ return [2, 2];
98
+ }
99
+
100
+ return [];
89
101
  }
90
102
 
91
- function drawBoundingBox(rect, colorKey, dashed = false) {
103
+ function drawHatchedFill(rect, width, height, fillColor) {
104
+ ctx.save();
105
+ ctx.beginPath();
106
+ ctx.rect(rect.left, rect.top, width, height);
107
+ ctx.clip();
108
+
109
+ ctx.strokeStyle = fillColor;
110
+ ctx.lineWidth = 2;
111
+ ctx.setLineDash([]);
112
+
113
+ const spacing = 6;
114
+ const diagonalReach = width + height;
115
+
116
+ for (let offset = -height; offset < diagonalReach; offset += spacing) {
117
+ ctx.beginPath();
118
+ ctx.moveTo(rect.left + offset, rect.top);
119
+ ctx.lineTo(rect.left + offset + height, rect.top + height);
120
+ ctx.stroke();
121
+ }
122
+
123
+ ctx.restore();
124
+ }
125
+
126
+ function drawDiagonalCross(rect, strokeColor) {
127
+ ctx.strokeStyle = strokeColor;
128
+ ctx.lineWidth = 2;
129
+ ctx.setLineDash([]);
130
+ ctx.beginPath();
131
+ ctx.moveTo(rect.left, rect.top);
132
+ ctx.lineTo(rect.right, rect.bottom);
133
+ ctx.moveTo(rect.right, rect.top);
134
+ ctx.lineTo(rect.left, rect.bottom);
135
+ ctx.stroke();
136
+ }
137
+
138
+ function drawUprightCross(rect, strokeColor) {
139
+ ctx.strokeStyle = strokeColor;
140
+ ctx.lineWidth = 2;
141
+ ctx.setLineDash([]);
142
+
143
+ const midX = (rect.left + rect.right) / 2;
144
+ const midY = (rect.top + rect.bottom) / 2;
145
+
146
+ ctx.beginPath();
147
+ ctx.moveTo(midX, rect.top);
148
+ ctx.lineTo(midX, rect.bottom);
149
+ ctx.moveTo(rect.left, midY);
150
+ ctx.lineTo(rect.right, midY);
151
+ ctx.stroke();
152
+ }
153
+
154
+ function drawBoundingBox(rect, colorKey, differences) {
92
155
  const adjustedRect = {
93
156
  left: rect.left + offsetX,
94
157
  right: rect.right + offsetX,
@@ -100,31 +163,42 @@ async function annotateWireframeFile(wireframeFile, screenshotFile) {
100
163
  const boxHeight = adjustedRect.bottom - adjustedRect.top;
101
164
  const color = colors[colorKey];
102
165
 
103
- ctx.fillStyle = color.fill;
104
- ctx.fillRect(adjustedRect.left, adjustedRect.top, boxWidth, boxHeight);
166
+ if (hasDifferenceType(differences, ['text_mismatch', 'histogram_difference'])) {
167
+ drawHatchedFill(adjustedRect, boxWidth, boxHeight, color.fill);
168
+ } else {
169
+ ctx.fillStyle = color.fill;
170
+ ctx.fillRect(adjustedRect.left, adjustedRect.top, boxWidth, boxHeight);
171
+ }
172
+
105
173
  ctx.strokeStyle = color.stroke;
106
174
  ctx.lineWidth = 2;
107
- ctx.setLineDash(dashed ? [6, 4] : []);
175
+ ctx.setLineDash(getLineDash(differences));
108
176
  ctx.strokeRect(adjustedRect.left, adjustedRect.top, boxWidth, boxHeight);
109
177
  ctx.setLineDash([]);
178
+
179
+ if (hasDifferenceType(differences, ['missing_element', 'missing_text'])) {
180
+ drawDiagonalCross(adjustedRect, color.stroke);
181
+ }
182
+
183
+ if (hasDifferenceType(differences, ['extra_element', 'extra_text'])) {
184
+ drawUprightCross(adjustedRect, color.stroke);
185
+ }
110
186
  }
111
187
 
112
188
  for (const elementGroup of wireframe.elementGroups) {
113
- const dashed = elementGroup.options?.strictPosition === false;
114
-
115
189
  for (const element of elementGroup.elements) {
116
190
  const rect = element.boundingRect;
117
191
 
118
192
  if (element.type === 'box') {
119
193
  const colorKey = determineColorKey(element, 'box');
120
- drawBoundingBox(rect, colorKey, dashed);
194
+ drawBoundingBox(rect, colorKey, element.differences);
121
195
  } else if (element.type === 'image') {
122
196
  const colorKey = determineColorKey(element, 'image');
123
- drawBoundingBox(rect, colorKey, dashed);
197
+ drawBoundingBox(rect, colorKey, element.differences);
124
198
  } else if (element.type === 'text' && element.texts && element.texts.length > 0) {
125
199
  for (const text of element.texts) {
126
200
  const colorKey = determineColorKey(text, 'text');
127
- drawBoundingBox(text.boundingRect, colorKey, dashed);
201
+ drawBoundingBox(text.boundingRect, colorKey, text.differences);
128
202
  }
129
203
  }
130
204
  }
@@ -11,16 +11,16 @@ function calculateBoundingBoxDistance(rect1, rect2) {
11
11
  Math.abs(rect1.bottom - rect2.bottom));
12
12
  }
13
13
 
14
- function createPairings(currentElements, baselineElements) {
14
+ function pairByBoundingBox(currentIndices, baselineIndices, currentElements, baselineElements) {
15
15
  const pairings = [];
16
-
17
- for (let currentIndex = 0; currentIndex < currentElements.length; currentIndex++) {
18
- for (let baselineIndex = 0; baselineIndex < baselineElements.length; baselineIndex++) {
16
+
17
+ for (const currentIndex of currentIndices) {
18
+ for (const baselineIndex of baselineIndices) {
19
19
  const distance = calculateBoundingBoxDistance(
20
20
  currentElements[currentIndex].boundingRect,
21
21
  baselineElements[baselineIndex].boundingRect
22
22
  );
23
-
23
+
24
24
  pairings.push({
25
25
  currentIndex,
26
26
  baselineIndex,
@@ -34,7 +34,7 @@ function createPairings(currentElements, baselineElements) {
34
34
  const usedCurrentIndices = new Set();
35
35
  const usedBaselineIndices = new Set();
36
36
  const matchedPairs = [];
37
-
37
+
38
38
  for (const pairing of pairings) {
39
39
  if (!usedCurrentIndices.has(pairing.currentIndex) && !usedBaselineIndices.has(pairing.baselineIndex)) {
40
40
  usedCurrentIndices.add(pairing.currentIndex);
@@ -42,21 +42,134 @@ function createPairings(currentElements, baselineElements) {
42
42
  matchedPairs.push(pairing);
43
43
  }
44
44
  }
45
-
45
+
46
+ const unmatchedCurrent = [];
47
+ for (const currentIndex of currentIndices) {
48
+ if (!usedCurrentIndices.has(currentIndex)) {
49
+ unmatchedCurrent.push(currentIndex);
50
+ }
51
+ }
52
+
53
+ const unmatchedBaseline = [];
54
+ for (const baselineIndex of baselineIndices) {
55
+ if (!usedBaselineIndices.has(baselineIndex)) {
56
+ unmatchedBaseline.push(baselineIndex);
57
+ }
58
+ }
59
+
60
+ return {
61
+ matchedPairs,
62
+ unmatchedCurrent,
63
+ unmatchedBaseline
64
+ };
65
+ }
66
+
67
+ function createPairings(currentElements, baselineElements) {
68
+ const currentIndices = currentElements.map((_, index) => index);
69
+ const baselineIndices = baselineElements.map((_, index) => index);
70
+
71
+ return pairByBoundingBox(currentIndices, baselineIndices, currentElements, baselineElements);
72
+ }
73
+
74
+ function buildTextIndexMap(texts) {
75
+ const map = new Map();
76
+ texts.forEach((textElement, index) => {
77
+ const key = textElement.text;
78
+ if (!map.has(key)) {
79
+ map.set(key, []);
80
+ }
81
+ map.get(key).push(index);
82
+ });
83
+ return map;
84
+ }
85
+
86
+ function createTextPairings(currentTexts, baselineTexts) {
87
+ const currentTextMap = buildTextIndexMap(currentTexts);
88
+ const baselineTextMap = buildTextIndexMap(baselineTexts);
89
+
90
+ const usedCurrentIndices = new Set();
91
+ const usedBaselineIndices = new Set();
92
+ const matchedPairs = [];
93
+
94
+ for (const [text, currentIndices] of currentTextMap) {
95
+ const baselineIndices = baselineTextMap.get(text);
96
+ if (currentIndices.length === 1 && baselineIndices && baselineIndices.length === 1) {
97
+ const currentIndex = currentIndices[0];
98
+ const baselineIndex = baselineIndices[0];
99
+ matchedPairs.push({
100
+ currentIndex,
101
+ baselineIndex,
102
+ distance: 0
103
+ });
104
+ usedCurrentIndices.add(currentIndex);
105
+ usedBaselineIndices.add(baselineIndex);
106
+ }
107
+ }
108
+
109
+ for (const [text, currentIndices] of currentTextMap) {
110
+ const baselineIndices = baselineTextMap.get(text) || [];
111
+ const remainingCurrentIndices = currentIndices.filter(index => !usedCurrentIndices.has(index));
112
+ const remainingBaselineIndices = baselineIndices.filter(index => !usedBaselineIndices.has(index));
113
+
114
+ if (remainingCurrentIndices.length > 0 && remainingBaselineIndices.length > 0) {
115
+ const result = pairByBoundingBox(
116
+ remainingCurrentIndices,
117
+ remainingBaselineIndices,
118
+ currentTexts,
119
+ baselineTexts
120
+ );
121
+
122
+ for (const pairing of result.matchedPairs) {
123
+ matchedPairs.push(pairing);
124
+ usedCurrentIndices.add(pairing.currentIndex);
125
+ usedBaselineIndices.add(pairing.baselineIndex);
126
+ }
127
+ }
128
+ }
129
+
130
+ const remainingCurrentIndices = [];
131
+ for (let i = 0; i < currentTexts.length; i++) {
132
+ if (!usedCurrentIndices.has(i)) {
133
+ remainingCurrentIndices.push(i);
134
+ }
135
+ }
136
+
137
+ const remainingBaselineIndices = [];
138
+ for (let i = 0; i < baselineTexts.length; i++) {
139
+ if (!usedBaselineIndices.has(i)) {
140
+ remainingBaselineIndices.push(i);
141
+ }
142
+ }
143
+
144
+ if (remainingCurrentIndices.length > 0 && remainingBaselineIndices.length > 0) {
145
+ const result = pairByBoundingBox(
146
+ remainingCurrentIndices,
147
+ remainingBaselineIndices,
148
+ currentTexts,
149
+ baselineTexts
150
+ );
151
+
152
+ for (const pairing of result.matchedPairs) {
153
+ matchedPairs.push(pairing);
154
+ usedCurrentIndices.add(pairing.currentIndex);
155
+ usedBaselineIndices.add(pairing.baselineIndex);
156
+ }
157
+ }
158
+
46
159
  const unmatchedCurrent = [];
47
- for (let i = 0; i < currentElements.length; i++) {
160
+ for (let i = 0; i < currentTexts.length; i++) {
48
161
  if (!usedCurrentIndices.has(i)) {
49
162
  unmatchedCurrent.push(i);
50
163
  }
51
164
  }
52
-
165
+
53
166
  const unmatchedBaseline = [];
54
- for (let i = 0; i < baselineElements.length; i++) {
167
+ for (let i = 0; i < baselineTexts.length; i++) {
55
168
  if (!usedBaselineIndices.has(i)) {
56
169
  unmatchedBaseline.push(i);
57
170
  }
58
171
  }
59
-
172
+
60
173
  return {
61
174
  matchedPairs,
62
175
  unmatchedCurrent,
@@ -73,6 +186,7 @@ function compareBoundingBoxes(baselineRect, currentRect, options) {
73
186
  if (boundingBoxDifference > 10) {
74
187
  differences.push({
75
188
  type: "layout_shift",
189
+ kind: 'error',
76
190
  difference: boundingBoxDifference
77
191
  });
78
192
  }
@@ -89,6 +203,7 @@ function compareBoundingBoxes(baselineRect, currentRect, options) {
89
203
  if (sizeDiff > 10) {
90
204
  differences.push({
91
205
  type: "size_mismatch",
206
+ kind: 'error',
92
207
  difference: sizeDiff
93
208
  });
94
209
  }
@@ -97,6 +212,7 @@ function compareBoundingBoxes(baselineRect, currentRect, options) {
97
212
  if (diffRatio > 1.1 || diffRatio < 0.9) {
98
213
  differences.push({
99
214
  type: "size_mismatch",
215
+ kind: 'note',
100
216
  difference: diffRatio
101
217
  });
102
218
  }
@@ -112,7 +228,7 @@ function replaceDigitsWithPlaceholder(text) {
112
228
  }
113
229
 
114
230
  function compareTexts(baselineTexts, currentTexts, options) {
115
- const {matchedPairs, unmatchedCurrent, unmatchedBaseline} = createPairings(currentTexts, baselineTexts);
231
+ const {matchedPairs, unmatchedCurrent, unmatchedBaseline} = createTextPairings(currentTexts, baselineTexts);
116
232
 
117
233
  for (const pairing of matchedPairs) {
118
234
  const baselineText = baselineTexts[pairing.baselineIndex];
@@ -130,6 +246,7 @@ function compareTexts(baselineTexts, currentTexts, options) {
130
246
  if (textsEqual) {
131
247
  currentText.differences.push({
132
248
  type: 'text_mismatch',
249
+ kind: 'error',
133
250
  baseline: baselineText.text,
134
251
  current: currentText.text,
135
252
  });
@@ -143,22 +260,24 @@ function compareTexts(baselineTexts, currentTexts, options) {
143
260
  }
144
261
  }
145
262
 
146
- if (options?.extraAllowed !== true)
147
- for (const currentIndex of unmatchedCurrent) {
148
- const currentText = currentTexts[currentIndex];
149
- currentText.differences = [{
150
- type: 'extra_text',
151
- }];
152
- }
263
+ const allowExtraText = options?.allowExtra === true;
264
+ for (const currentIndex of unmatchedCurrent) {
265
+ const currentText = currentTexts[currentIndex];
266
+ currentText.differences = [{
267
+ type: 'extra_text',
268
+ kind: allowExtraText ? 'note' : 'error',
269
+ }];
270
+ }
153
271
 
154
- if (options.missingAllowed !== true)
155
- for (const baselineIndex of unmatchedBaseline) {
156
- const baselineText = baselineTexts[baselineIndex];
157
- currentTexts.push(baselineText);
158
- baselineText.differences = [{
159
- type: 'missing_text',
160
- }];
161
- }
272
+ const allowMissingText = options?.allowMissing === true;
273
+ for (const baselineIndex of unmatchedBaseline) {
274
+ const baselineText = baselineTexts[baselineIndex];
275
+ currentTexts.push(baselineText);
276
+ baselineText.differences = [{
277
+ type: 'missing_text',
278
+ kind: allowMissingText ? 'note' : 'error',
279
+ }];
280
+ }
162
281
 
163
282
  for (const currentText of currentTexts) {
164
283
  if (currentText.differences?.length === 0) {
@@ -173,6 +292,7 @@ function compareElements(baselineElement, currentElement, options) {
173
292
  if (dh > 15) {
174
293
  currentElement.differences.push({
175
294
  type: 'histogram_difference',
295
+ kind: 'error',
176
296
  });
177
297
  }
178
298
  }
@@ -215,22 +335,24 @@ function compareWireframes(baselineWireframe, currentWireframe) {
215
335
  }
216
336
  }
217
337
 
218
- if (currentElementGroup.options?.extraAllowed !== true)
219
- for (const currentIndex of unmatchedCurrent) {
220
- const currentElement = currentElementGroup.elements[currentIndex];
221
- currentElement.differences = [{
222
- type: 'extra_element',
223
- }];
224
- }
338
+ const allowExtraElements = currentElementGroup.options?.allowExtra === true;
339
+ for (const currentIndex of unmatchedCurrent) {
340
+ const currentElement = currentElementGroup.elements[currentIndex];
341
+ currentElement.differences = [{
342
+ type: 'extra_element',
343
+ kind: allowExtraElements ? 'note' : 'error',
344
+ }];
345
+ }
225
346
 
226
- if (currentElementGroup.options?.missingAllowed !== true)
227
- for (const baselineIndex of unmatchedBaseline) {
228
- const baselineElement = baselineElementGroup.elements[baselineIndex];
229
- currentElementGroup.elements.push(baselineElement);
230
- baselineElement.differences = [{
231
- type: 'missing_element',
232
- }];
233
- }
347
+ const allowMissingElements = currentElementGroup.options?.allowMissing === true;
348
+ for (const baselineIndex of unmatchedBaseline) {
349
+ const baselineElement = baselineElementGroup.elements[baselineIndex];
350
+ currentElementGroup.elements.push(baselineElement);
351
+ baselineElement.differences = [{
352
+ type: 'missing_element',
353
+ kind: allowMissingElements ? 'note' : 'error',
354
+ }];
355
+ }
234
356
  }
235
357
 
236
358
  return currentWireframe;
@@ -109,8 +109,8 @@ function createEmptyElementGroupResult(elementGroup) {
109
109
  strictPosition: elementGroup.options?.strictPosition !== false,
110
110
  strictSize: elementGroup.options?.strictSize !== false,
111
111
  maskDigits: elementGroup.options?.maskDigits !== false,
112
- extraAllowed: elementGroup.options?.extraAllowed === true,
113
- missingAllowed: elementGroup.options?.missingAllowed === true
112
+ allowExtra: elementGroup.options?.allowExtra === true,
113
+ allowMissing: elementGroup.options?.allowMissing === true
114
114
  },
115
115
  elements: []
116
116
  };
@@ -205,8 +205,8 @@ async function extractElementGroupData(page, elementGroup, groupIndex, elementId
205
205
  elementGroup.options.strictPosition = elementGroup.options.strictPosition !== false;
206
206
  elementGroup.options.strictSize = elementGroup.options.strictSize !== false;
207
207
  elementGroup.options.maskDigits = elementGroup.options.maskDigits !== false;
208
- elementGroup.options.extraAllowed = elementGroup.options.extraAllowed === true;
209
- elementGroup.options.missingAllowed = elementGroup.options.missingAllowed === true;
208
+ elementGroup.options.allowExtra = elementGroup.options.allowExtra === true;
209
+ elementGroup.options.allowMissing = elementGroup.options.allowMissing === true;
210
210
  elementGroup.elements = [];
211
211
 
212
212
  let index = 0;