flexysnap 0.2.4-alpha.1 → 0.2.6

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/README.md CHANGED
@@ -44,6 +44,7 @@ const elementGroups = [
44
44
  ```
45
45
 
46
46
  - `strictPosition: false` — compare element size only, not exact position.
47
+ - `strictSize: false` — for position-tolerant groups, compare size by ratio rather than absolute pixel difference.
47
48
  - `maskDigits: true` — replace digit runs with a placeholder before comparing text.
48
49
  - `textIgnoreClasses` — skip text found inside elements carrying these class names, at any ancestor level.
49
50
 
@@ -95,6 +96,7 @@ test('product page wireframe', async ({ page }) => {
95
96
  "timestamp": "2024-01-01T00:00:00.000Z",
96
97
  "scrollPosition": { "x": 0, "y": 0 },
97
98
  "screenshotScrollPosition": { "x": 0, "y": 0 },
99
+ "deviceScaleFactor": 1,
98
100
  "elementGroups": [
99
101
  {
100
102
  "selector": ".product-title",
@@ -127,11 +129,14 @@ Anything passed as `options.metadata` is merged into the top level of this objec
127
129
  ### Test utilities
128
130
 
129
131
  - `waitForCompleteLoad(page)` — wait for `domcontentloaded`, `load`, and a settle delay.
132
+ - `gotoWithRetry(page, url)` — navigate to a URL, retrying once on failure, then wait for the page to finish loading.
130
133
  - `click(page, locator)` — resilient click that closes popups, re-hovers, scrolls into view, and retries.
131
134
  - `highlightedClick(page, locator)` — scroll a locator into the viewport and force-click it.
132
135
  - `hover(page, locator)` — hover a locator and remember it for re-hovering.
133
136
  - `rehover(page)` — re-hover the last hovered locator.
134
137
  - `fill(page, field, value)` — click and fill an input by name or locator.
138
+ - `selectOption(page, field, value)` — click and select an option by name or locator.
139
+ - `scrollToTopOfElement(page, target, offset?)` — scroll so a locator (or selector string) is positioned near the top of the viewport, minus an optional offset in pixels.
135
140
  - `setClosePopups(fn)` — register a function used to dismiss popups before interactions.
136
141
  - `setBaseUrl(url)` — set the base URL used for request cache-busting.
137
142
  - `logTimestamp(eventName)` — log an event with elapsed time since test start.
@@ -142,20 +147,22 @@ Anything passed as `options.metadata` is merged into the top level of this objec
142
147
  - `options.retryDelay` (default `1000`) — minimum milliseconds between stability samples.
143
148
  - `options.maxRetryCount` (default `10`) — maximum number of extraction attempts.
144
149
  - `options.metadata` (default `{}`) — extra fields merged into the written JSON.
150
+ - `options.closePopups` (default no-op) — function called before each element group extraction to dismiss popups.
145
151
  - `getRGBHistogramFromBuffer(buffer)` — compute a 48-bin RGB histogram (16 bins per channel, values normalized to percentages) from an image buffer.
146
152
 
147
153
  ### Wireframe comparison
148
154
 
149
- - `compareWireframes(baselineWireframe, currentWireframe)` — diff two wireframes and annotate the current one with `differences`.
150
- - `compareElements(baselineElement, currentElement, strictPosition?, maskDigits?)`
151
- - `compareTexts(baselineTexts, currentTexts, strictPosition?, maskDigits?)`
152
- - `compareBoundingBoxes(baselineRect, currentRect, tolerance?, strictPosition?)`
153
- - `createPairings(currentElements, baselineElements)` — nearest bounding-box matching between two element sets, returning `{ matchedPairs, unmatchedCurrent, unmatchedBaseline }`.
154
- - `histogramDiff(a, b)` — sum of absolute differences between two histograms.
155
+ - `compareWireframes(baselineWireframe, currentWireframe)` — diff two wireframes and return the current wireframe annotated with `differences`. Element groups are matched by index and must appear in the same order and count in both wireframes. Within each group, elements are paired with the baseline using nearest bounding-box matching, then diffed for layout shifts, size mismatches, text mismatches, and image histogram differences. Elements missing from the current capture are appended to the current wireframe's element groups so they still appear in the output, marked as `missing_element` (or `missing_text`).
155
156
 
156
157
  ### Stability
157
158
 
158
- - `areWireframesStable(previousWireframeData, currentWireframeData)` — determine whether two consecutive captures are stable enough to trust.
159
+ - `areWireframesStable(previousWireframeData, currentWireframeData)` — determine whether two consecutive captures are stable enough to trust, by checking for empty element groups, mismatched element counts, text content differences, and overall bounding-box size drift.
160
+
161
+ ### Output paths
162
+
163
+ - `setWireframeOutputRoot(rootPath)` — set the root directory used to resolve relative output directories (defaults to `process.cwd()`).
164
+ - `getWireframeOutputRoot()` — get the currently configured output root.
165
+ - `resolveWireframeOutputDir(outputDir)` — resolve an output directory against the configured root; absolute paths are returned unchanged.
159
166
 
160
167
  ## How it works
161
168
 
@@ -243,4 +250,4 @@ Issues and pull requests are welcome. Please open an issue before submitting lar
243
250
 
244
251
  ## License
245
252
 
246
- MIT
253
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flexysnap",
3
- "version": "0.2.4-alpha.1",
3
+ "version": "0.2.6",
4
4
  "description": "Flexible visual snapshot testing for Playwright, built for e-commerce.",
5
5
  "keywords": [
6
6
  "playwright",
@@ -49,4 +49,4 @@
49
49
  "devDependencies": {
50
50
  "eslint": "^9.9.0"
51
51
  }
52
- }
52
+ }
@@ -166,16 +166,8 @@ function compareTexts(baselineTexts, currentTexts, strictPosition = true, strict
166
166
  }
167
167
 
168
168
  function compareElements(baselineElement, currentElement, strictPosition = true, strictSize = true, maskDigits = false) {
169
- currentElement.differences = compareBoundingBoxes(
170
- baselineElement.boundingRect,
171
- currentElement.boundingRect,
172
- strictPosition,
173
- strictSize
174
- );
175
-
176
169
  if (baselineElement.histogram && currentElement.histogram) {
177
170
  const dh = histogramDiff(baselineElement.histogram, currentElement.histogram);
178
- //expect.soft(dh, 'Histogram difference').toBeLessThanOrEqual(10);
179
171
  if (dh > 15) {
180
172
  currentElement.differences.push({
181
173
  type: 'histogram_difference',
@@ -185,6 +177,13 @@ function compareElements(baselineElement, currentElement, strictPosition = true,
185
177
 
186
178
  if (baselineElement.texts.length > 0 || currentElement.texts.length > 0) {
187
179
  compareTexts(baselineElement.texts, currentElement.texts, strictPosition, strictSize, maskDigits);
180
+ } else {
181
+ currentElement.differences = compareBoundingBoxes(
182
+ baselineElement.boundingRect,
183
+ currentElement.boundingRect,
184
+ strictPosition,
185
+ strictSize
186
+ );
188
187
  }
189
188
  }
190
189
 
@@ -204,11 +203,6 @@ function compareWireframes(baselineWireframe, currentWireframe) {
204
203
  const strictPosition = currentElementGroup.strictPosition !== false;
205
204
  const strictSize = currentElementGroup.strictSize !== false;
206
205
  const maskDigits = currentElementGroup.maskDigits === true;
207
-
208
- //TODO: REMOVE later
209
- if (currentElementGroup.differences?.length === 0) {
210
- currentElementGroup.differences = undefined
211
- }
212
206
 
213
207
  const { matchedPairs, unmatchedCurrent, unmatchedBaseline } = createPairings(
214
208
  currentElementGroup.elements,
@@ -6,6 +6,7 @@ import { areWireframesStable } from './wireframeStability.js';
6
6
  import { resolveWireframeOutputDir } from './wireframeOutput.js';
7
7
 
8
8
  const FLEXYSNAP_ELEMENT_ID_ATTRIBUTE = 'data-flexysnap-id';
9
+ const ELEMENT_GROUP_EXTRACTION_TIMEOUT_MS = 5000;
9
10
 
10
11
  async function getRGBHistogramFromBuffer(buffer) {
11
12
  const image = await loadImage(buffer);
@@ -39,6 +40,15 @@ function delay(milliseconds) {
39
40
  return new Promise(resolve => setTimeout(resolve, milliseconds));
40
41
  }
41
42
 
43
+ function withTimeout(promise, milliseconds, timeoutMessage) {
44
+ let timeoutHandle;
45
+ const timeoutPromise = new Promise((_, reject) => {
46
+ timeoutHandle = setTimeout(() => reject(new Error(timeoutMessage)), milliseconds);
47
+ });
48
+
49
+ return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timeoutHandle));
50
+ }
51
+
42
52
  async function getDeviceScaleFactor(page) {
43
53
  try {
44
54
  const scaleFactor = await page.evaluate(() => window.devicePixelRatio);
@@ -92,66 +102,62 @@ function scaleWireframeData(wireframeData, scaleFactor) {
92
102
  return wireframeData;
93
103
  }
94
104
 
95
- async function extractWireframe(page, elementGroups) {
96
- const wireframeData = await page.evaluate(async ({elementGroups, elementIdAttribute}) => {
97
-
98
- function createBoundingRect(element) {
99
- const rect = element.getBoundingClientRect();
100
- return {
101
- top: Math.round(rect.top),
102
- left: Math.round(rect.left),
103
- bottom: Math.round(rect.bottom),
104
- right: Math.round(rect.right)
105
- };
106
- }
107
-
108
- function getTextNodeBoundingBox(textNode) {
109
- const range = document.createRange();
110
- range.selectNodeContents(textNode);
111
- const rect = range.getBoundingClientRect();
112
- return {
113
- top: Math.round(rect.top),
114
- left: Math.round(rect.left),
115
- bottom: Math.round(rect.bottom),
116
- right: Math.round(rect.right)
117
- };
118
- }
119
-
120
- function isElementVisible(element) {
121
- const style = window.getComputedStyle(element);
122
- if (style.display === 'none' ||
123
- style.visibility === 'hidden' ||
124
- style.opacity === '0' ||
125
- element.offsetWidth <= 0 ||
126
- element.offsetHeight <= 0) {
127
- return false;
128
- }
129
-
130
- const rect = element.getBoundingClientRect()
131
-
132
- return rect.right >= 0 &&
133
- rect.left < window.innerWidth &&
134
- rect.bottom >= 0 &&
135
- rect.top < window.innerHeight;
136
- }
105
+ function createEmptyElementGroupResult(elementGroup) {
106
+ return {
107
+ ...elementGroup,
108
+ strictPosition: elementGroup.strictPosition !== false,
109
+ strictSize: elementGroup.strictSize !== false,
110
+ maskDigits: elementGroup.maskDigits !== false,
111
+ elements: []
112
+ };
113
+ }
137
114
 
115
+ async function extractElementGroupData(page, elementGroup, groupIndex, elementIdAttribute) {
116
+ const extractionStartTime = Date.now();
138
117
 
139
- for (let groupIndex = 0; groupIndex < elementGroups.length; groupIndex++) {
140
- const elementGroup = elementGroups[groupIndex];
141
- elementGroup.strictPosition = elementGroup.strictPosition !== false;
142
- elementGroup.strictSize = elementGroup.strictSize !== false;
143
- elementGroup.maskDigits = elementGroup.maskDigits !== false;
144
- elementGroup.elements = [];
118
+ try {
119
+ const result = await withTimeout(
120
+ page.evaluate(({elementGroup, groupIndex, elementIdAttribute}) => {
121
+
122
+ function createBoundingRect(element) {
123
+ const rect = element.getBoundingClientRect();
124
+ return {
125
+ top: Math.round(rect.top),
126
+ left: Math.round(rect.left),
127
+ bottom: Math.round(rect.bottom),
128
+ right: Math.round(rect.right)
129
+ };
130
+ }
145
131
 
146
- let index = 0;
147
- const matchedElements = document.querySelectorAll(elementGroup.selector);
132
+ function getTextNodeBoundingBox(textNode) {
133
+ const range = document.createRange();
134
+ range.selectNodeContents(textNode);
135
+ const rect = range.getBoundingClientRect();
136
+ return {
137
+ top: Math.round(rect.top),
138
+ left: Math.round(rect.left),
139
+ bottom: Math.round(rect.bottom),
140
+ right: Math.round(rect.right)
141
+ };
142
+ }
148
143
 
149
- for (const element of matchedElements) {
150
- if (!isElementVisible(element))
151
- continue;
144
+ function isElementVisible(element) {
145
+ const style = window.getComputedStyle(element);
146
+ if (style.display === 'none' ||
147
+ style.visibility === 'hidden' ||
148
+ style.opacity === '0' ||
149
+ element.offsetWidth <= 0 ||
150
+ element.offsetHeight <= 0) {
151
+ return false;
152
+ }
152
153
 
154
+ const rect = element.getBoundingClientRect()
153
155
 
154
- const texts = [];
156
+ return rect.right >= 0 &&
157
+ rect.left < window.innerWidth &&
158
+ rect.bottom >= 0 &&
159
+ rect.top < window.innerHeight;
160
+ }
155
161
 
156
162
  function getElementsWithDirectText(root, textIgnoreClasses) {
157
163
  const results = [];
@@ -168,7 +174,6 @@ async function extractWireframe(page, elementGroups) {
168
174
 
169
175
  if (textIgnoreClasses) {
170
176
  let ignored = false;
171
- console.log(textIgnoreClasses);
172
177
  let parent = walker.currentNode.parentElement;
173
178
  while (parent != null && !ignored) {
174
179
  for (const className of parent.classList) {
@@ -192,52 +197,88 @@ async function extractWireframe(page, elementGroups) {
192
197
  return results;
193
198
  }
194
199
 
195
- if (elementGroup.type === 'text') {
200
+ elementGroup.strictPosition = elementGroup.strictPosition !== false;
201
+ elementGroup.strictSize = elementGroup.strictSize !== false;
202
+ elementGroup.maskDigits = elementGroup.maskDigits !== false;
203
+ elementGroup.elements = [];
196
204
 
197
- const descendantArray = getElementsWithDirectText(element, elementGroup.textIgnoreClasses)
205
+ let index = 0;
206
+ const matchedElements = document.querySelectorAll(elementGroup.selector);
198
207
 
199
- for (const descendant of descendantArray) {
200
- if (!isElementVisible(descendant.parentElement))
201
- continue;
208
+ for (const element of matchedElements) {
209
+ if (!isElementVisible(element))
210
+ continue;
202
211
 
203
- const text = descendant.textContent.trim().replaceAll(/\s+/g, ' ')
204
- if (text.length === 0)
205
- continue;
212
+ const texts = [];
206
213
 
207
- const isNested = descendantArray.some(other =>
208
- other !== descendant && other.contains(descendant)
209
- );
214
+ if (elementGroup.type === 'text') {
210
215
 
211
- if (isNested)
212
- continue;
216
+ const descendantArray = getElementsWithDirectText(element, elementGroup.textIgnoreClasses)
213
217
 
214
- texts.push({
215
- text: text,
216
- boundingRect: getTextNodeBoundingBox(descendant)
217
- });
218
+ for (const descendant of descendantArray) {
219
+ if (!isElementVisible(descendant.parentElement))
220
+ continue;
221
+
222
+ const text = descendant.textContent.trim().replaceAll(/\s+/g, ' ')
223
+ if (text.length === 0)
224
+ continue;
225
+
226
+ const isNested = descendantArray.some(other =>
227
+ other !== descendant && other.contains(descendant)
228
+ );
229
+
230
+ if (isNested)
231
+ continue;
232
+
233
+ texts.push({
234
+ text: text,
235
+ boundingRect: getTextNodeBoundingBox(descendant)
236
+ });
237
+ }
238
+ }
239
+
240
+ const elementData = {
241
+ index: index,
242
+ boundingRect: createBoundingRect(element),
243
+ texts: texts,
244
+ type: elementGroup.type
245
+ };
246
+
247
+ if (elementGroup.type === 'image') {
248
+ const elementId = `fsnap-g${groupIndex}-e${index}`;
249
+ element.setAttribute(elementIdAttribute, elementId);
250
+ elementData.elementId = elementId;
218
251
  }
219
- }
220
252
 
221
- const elementData = {
222
- index: index,
223
- boundingRect: createBoundingRect(element),
224
- texts: texts,
225
- type: elementGroup.type
226
- };
227
-
228
- if (elementGroup.type === 'image') {
229
- const elementId = `fsnap-g${groupIndex}-e${index}`;
230
- element.setAttribute(elementIdAttribute, elementId);
231
- elementData.elementId = elementId;
253
+ elementGroup.elements.push(elementData);
254
+ index += 1;
232
255
  }
233
256
 
234
- elementGroup.elements.push(elementData);
235
- index += 1;
236
- }
237
- }
257
+ return elementGroup;
258
+ }, {elementGroup, groupIndex, elementIdAttribute}),
259
+ ELEMENT_GROUP_EXTRACTION_TIMEOUT_MS,
260
+ `Timed out extracting element group with selector "${elementGroup.selector}"`
261
+ );
262
+
263
+ const extractionDuration = Date.now() - extractionStartTime;
264
+ logTimestamp(`Extracted element group "${elementGroup.selector}" in ${extractionDuration/1000} seconds.`);
265
+
266
+ return result;
267
+ } catch (error) {
268
+ const extractionDuration = Date.now() - extractionStartTime;
269
+ logTimestamp(`Failed to extract element group "${elementGroup.selector}" after ${extractionDuration/1000} seconds: ${error.message}`);
270
+ return createEmptyElementGroupResult(elementGroup);
271
+ }
272
+ }
238
273
 
239
- return elementGroups;
240
- }, {elementGroups, elementIdAttribute: FLEXYSNAP_ELEMENT_ID_ATTRIBUTE});
274
+ async function extractWireframe(page, elementGroups, closePopups) {
275
+ const wireframeData = [];
276
+
277
+ for (let groupIndex = 0; groupIndex < elementGroups.length; groupIndex++) {
278
+ closePopups();
279
+ const elementGroupData = await extractElementGroupData(page, elementGroups[groupIndex], groupIndex, FLEXYSNAP_ELEMENT_ID_ATTRIBUTE);
280
+ wireframeData.push(elementGroupData);
281
+ }
241
282
 
242
283
  const scrollPosition = await page.evaluate(() => ({
243
284
  x: window.scrollX,
@@ -294,14 +335,14 @@ function cloneElementGroups(elementGroups) {
294
335
  return elementGroups.map(elementGroup => ({...elementGroup, elements: undefined}));
295
336
  }
296
337
 
297
- async function extractStableWireframe(page, elementGroups, retryDelay, maxRetryCount) {
338
+ async function extractStableWireframe(page, elementGroups, retryDelay, maxRetryCount, closePopups) {
298
339
  let previousWireframeData = null;
299
340
  let currentWireframeData = null;
300
341
  let currentScrollPosition = null;
301
342
 
302
343
  for (let attempt = 0; attempt < maxRetryCount; attempt++) {
303
344
  const extractionStartTime = Date.now();
304
- const extractionResult = await extractWireframe(page, cloneElementGroups(elementGroups));
345
+ const extractionResult = await extractWireframe(page, cloneElementGroups(elementGroups), closePopups);
305
346
  currentWireframeData = extractionResult.wireframeData;
306
347
  currentScrollPosition = extractionResult.scrollPosition;
307
348
  const extractionDuration = Date.now() - extractionStartTime;
@@ -327,10 +368,10 @@ async function extractStableWireframe(page, elementGroups, retryDelay, maxRetryC
327
368
  }
328
369
 
329
370
  async function expectWireframe(page, elementGroups, outputDir, outputFile, outputName, options = {}) {
330
- const { retryDelay = 1000, maxRetryCount = 10, metadata = {} } = options;
371
+ const { retryDelay = 1000, maxRetryCount = 10, metadata = {}, closePopups = function() {} } = options;
331
372
 
332
373
  logTimestamp(`Starting wireframe capture for: ${outputFile}`);
333
- const { wireframeData, scrollPosition } = await extractStableWireframe(page, elementGroups, retryDelay, maxRetryCount);
374
+ const { wireframeData, scrollPosition } = await extractStableWireframe(page, elementGroups, retryDelay, maxRetryCount, closePopups);
334
375
 
335
376
  const screenshotScrollPosition = await page.evaluate(() => ({
336
377
  x: window.scrollX,