flexysnap 0.1.4-alpha.1 → 0.2.0-alpha.1

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
@@ -8,7 +8,9 @@ Traditional pixel-diffing tools flag every layout shift as a failure, even a 2px
8
8
 
9
9
  - **Structural diffing** — captures element bounding boxes, text nodes, and image histograms instead of raw pixels, so sub-pixel font rendering and anti-aliasing never fail your suite.
10
10
  - **Position tolerance** — element groups can be marked `strictPosition: false` to check size only, ignoring exact placement for content that legitimately moves.
11
+ - **Digit masking** — element groups can be marked `maskDigits: true` so counters, prices, and timers don't fail on numeric churn.
11
12
  - **Stability detection** — wireframes are re-captured until the layout settles, so lazy-loaded images, animations, and reflow don't produce flaky baselines.
13
+ - **Scroll-safe capture** — the scroll position at extraction time is recorded and restored, and stored alongside the screenshot.
12
14
  - **Built on Playwright** — works with your existing Playwright config, fixtures, and test runner. No new browser automation layer to learn.
13
15
  - **Annotated screenshots** — overlay the captured wireframe onto its screenshot to visualize what was checked and what differed.
14
16
 
@@ -36,12 +38,16 @@ const elementGroups = [
36
38
  { selector: '.hero-banner', type: 'image' },
37
39
  { selector: '.product-title', type: 'text' },
38
40
  { selector: '.cross-sell-carousel', type: 'box', strictPosition: false },
39
- { selector: '.price', type: 'text', textIgnoreClasses: ['screen-reader-text'] }
41
+ { selector: '.price', type: 'text', maskDigits: true },
42
+ { selector: '.stock-status', type: 'text', textIgnoreClasses: ['screen-reader-text'] }
40
43
  ];
41
44
  ```
42
45
 
43
46
  - `strictPosition: false` — compare element size only, not exact position.
44
- - `textIgnoreClasses` — skip text found inside elements carrying these class names.
47
+ - `maskDigits: true` — replace digit runs with a placeholder before comparing text.
48
+ - `textIgnoreClasses` — skip text found inside elements carrying these class names, at any ancestor level.
49
+
50
+ Only visible elements are captured: elements that are `display: none`, `visibility: hidden`, fully transparent, zero-sized, or entirely outside the viewport are skipped.
45
51
 
46
52
  ## Quick start
47
53
 
@@ -63,19 +69,57 @@ test('product page wireframe', async ({ page }) => {
63
69
  await expectWireframe(
64
70
  page,
65
71
  elementGroups,
66
- 'product-config', // config name
72
+ 'product-config', // output directory
67
73
  'product-page', // output file basename
68
- 'Product Page' // human-readable name
74
+ 'Product Page', // human-readable name
75
+ { retryDelay: 1000, maxRetryCount: 10, metadata: { url: page.url() } }
69
76
  );
70
77
  });
71
78
  ```
72
79
 
73
- `expectWireframe` captures a stable wireframe (re-sampling until the layout settles), writes a JSON wireframe and a PNG screenshot into:
80
+ `expectWireframe` captures a stable wireframe (re-sampling until the layout settles), then writes `<outputFile>.json` and `<outputFile>.png` into the resolved output directory. It returns:
74
81
 
82
+ ```js
83
+ {
84
+ wireframeOutput, // the JSON object that was written
85
+ screenshotPath, // absolute path of the PNG
86
+ outputDir // resolved output directory
87
+ }
75
88
  ```
76
- wireframes/test/<TEST_TYPE>/<config>/<DEVICE_TYPE>/<USER_TYPE>/
89
+
90
+ ### Wireframe JSON shape
91
+
92
+ ```json
93
+ {
94
+ "name": "Product Page",
95
+ "timestamp": "2024-01-01T00:00:00.000Z",
96
+ "scrollPosition": { "x": 0, "y": 0 },
97
+ "screenshotScrollPosition": { "x": 0, "y": 0 },
98
+ "elementGroups": [
99
+ {
100
+ "selector": ".product-title",
101
+ "type": "text",
102
+ "strictPosition": true,
103
+ "elements": [
104
+ {
105
+ "index": 0,
106
+ "type": "text",
107
+ "boundingRect": { "top": 120, "left": 32, "bottom": 148, "right": 420 },
108
+ "texts": [
109
+ {
110
+ "text": "Example Item",
111
+ "boundingRect": { "top": 120, "left": 32, "bottom": 148, "right": 260 }
112
+ }
113
+ ]
114
+ }
115
+ ]
116
+ }
117
+ ]
118
+ }
77
119
  ```
78
120
 
121
+ Anything passed as `options.metadata` is merged into the top level of this object.
122
+
79
123
  ## API
80
124
 
81
125
  `flexysnap` exports the following from its main entry point:
@@ -94,16 +138,19 @@ wireframes/test/<TEST_TYPE>/<config>/<DEVICE_TYPE>/<USER_TYPE>/
94
138
 
95
139
  ### Wireframe capture
96
140
 
97
- - `expectWireframe(page, elementGroups, configName, outputFile, outputName, retryDelay?, maxRetryCount?)` — capture a stable wireframe and write JSON + screenshot.
98
- - `getRGBHistogramFromBuffer(buffer)`compute a 48-bin RGB histogram from an image buffer.
141
+ - `expectWireframe(page, elementGroups, outputDir, outputFile, outputName, options?)` — capture a stable wireframe and write JSON + screenshot.
142
+ - `options.retryDelay` (default `1000`) — minimum milliseconds between stability samples.
143
+ - `options.maxRetryCount` (default `10`) — maximum number of extraction attempts.
144
+ - `options.metadata` (default `{}`) — extra fields merged into the written JSON.
145
+ - `getRGBHistogramFromBuffer(buffer)` — compute a 48-bin RGB histogram (16 bins per channel, values normalized to percentages) from an image buffer.
99
146
 
100
147
  ### Wireframe comparison
101
148
 
102
149
  - `compareWireframes(baselineWireframe, currentWireframe)` — diff two wireframes and annotate the current one with `differences`.
103
- - `compareElements(baselineElement, currentElement, strictPosition?)`
104
- - `compareTexts(baselineTexts, currentTexts, strictPosition?)`
150
+ - `compareElements(baselineElement, currentElement, strictPosition?, maskDigits?)`
151
+ - `compareTexts(baselineTexts, currentTexts, strictPosition?, maskDigits?)`
105
152
  - `compareBoundingBoxes(baselineRect, currentRect, tolerance?, strictPosition?)`
106
- - `createPairings(currentElements, baselineElements)` — nearest bounding-box matching between two element sets.
153
+ - `createPairings(currentElements, baselineElements)` — nearest bounding-box matching between two element sets, returning `{ matchedPairs, unmatchedCurrent, unmatchedBaseline }`.
107
154
  - `histogramDiff(a, b)` — sum of absolute differences between two histograms.
108
155
 
109
156
  ### Stability
@@ -112,13 +159,15 @@ wireframes/test/<TEST_TYPE>/<config>/<DEVICE_TYPE>/<USER_TYPE>/
112
159
 
113
160
  ## How it works
114
161
 
115
- 1. **Wireframe extraction** — `flexysnap` walks the DOM in the browser, collecting bounding boxes for each matched element, text nodes for `text` groups, and RGB histograms (via `sharp`) for `image` groups.
116
- 2. **Stability loop** — the wireframe is captured repeatedly until consecutive captures are stable (element counts match, text is unchanged, and total bounding-box area drift stays under 3%). This defeats lazy loading and animation flakiness.
162
+ 1. **Wireframe extraction** — `flexysnap` walks the DOM in the browser, collecting bounding boxes for each matched visible element and text nodes for `text` groups. For `image` groups it tags the element, waits for its images to finish loading, screenshots it, and computes an RGB histogram with `canvas`. Tagging attributes are removed and the original scroll position is restored afterwards.
163
+ 2. **Stability loop** — the wireframe is captured repeatedly until consecutive captures are stable. Each attempt waits out the remainder of `retryDelay`, so slow pages aren't penalized twice. This defeats lazy loading and animation flakiness.
117
164
  3. **Baseline comparison** — element groups from the current run are paired with the baseline using nearest bounding-box matching. Each pair is diffed for layout shifts, text mismatches, and histogram (image) differences. Unmatched elements are reported as extra or missing.
118
165
 
119
- ## Environment variables
166
+ Comparison tolerances: bounding boxes allow up to 10px of drift on any edge, and image histograms allow a total absolute difference of 15.
167
+
168
+ ## Output paths
120
169
 
121
- Wireframe paths are namespaced by environment variables so the same tests can run across devices and user roles:
170
+ The `outputDir` argument is resolved by `resolveWireframeOutputDir`, which namespaces wireframes by environment variables so the same tests can run across devices and user roles:
122
171
 
123
172
  | Variable | Example | Purpose |
124
173
  |---------------|--------------|----------------------------------|
@@ -126,24 +175,26 @@ Wireframe paths are namespaced by environment variables so the same tests can ru
126
175
  | `DEVICE_TYPE` | `mobile` | Device / viewport identifier |
127
176
  | `USER_TYPE` | `guest` | User role identifier |
128
177
 
129
- The output path for a captured wireframe is:
130
-
131
178
  ```
132
- wireframes/test/<TEST_TYPE>/<configName>/<DEVICE_TYPE>/<USER_TYPE>/
179
+ wireframes/test/<TEST_TYPE>/<outputDir>/<DEVICE_TYPE>/<USER_TYPE>/
133
180
  ```
134
181
 
135
- where `configName` is passed directly to `expectWireframe`.
136
-
137
182
  ## Comparing against a baseline
138
183
 
139
- Use `compareWireframes` in your own Playwright spec to diff a captured wireframe against a stored baseline. It pairs each element group's elements with the baseline using nearest bounding-box matching, then annotates the current wireframe's elements and texts with a `differences` array.
184
+ Use `compareWireframes` in your own Playwright spec to diff a captured wireframe against a stored baseline. It pairs each element group's elements with the baseline using nearest bounding-box matching, then annotates the current wireframe's elements and texts with a `differences` array. Elements and texts without differences are left with `differences` undefined, so an unchanged page produces a clean wireframe.
140
185
 
141
186
  ```js
187
+ import fs from 'fs';
142
188
  import { compareWireframes } from 'flexysnap';
143
189
 
190
+ const baselineWireframe = JSON.parse(fs.readFileSync('baseline/product-page.json', 'utf8'));
191
+ const currentWireframe = JSON.parse(fs.readFileSync('current/product-page.json', 'utf8'));
192
+
144
193
  const annotated = compareWireframes(baselineWireframe, currentWireframe);
145
194
  ```
146
195
 
196
+ Elements missing from the current capture are appended to the current element group so they still appear in annotated output, marked as `missing_element` (or `missing_text`).
197
+
147
198
  ## Difference types
148
199
 
149
200
  | Type | Meaning |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flexysnap",
3
- "version": "0.1.4-alpha.1",
3
+ "version": "0.2.0-alpha.1",
4
4
  "description": "Flexible visual snapshot testing for Playwright, built for e-commerce.",
5
5
  "keywords": [
6
6
  "playwright",
package/src/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export {
2
2
  waitForCompleteLoad,
3
+ gotoWithRetry,
3
4
  highlightedClick,
4
5
  click,
5
6
  hover,
package/src/testUtils.js CHANGED
@@ -2,6 +2,18 @@ import { test } from '@playwright/test';
2
2
 
3
3
  let closePopups = async function(page) {}
4
4
 
5
+ async function gotoWithRetry(page, url) {
6
+ try {
7
+ logTimestamp('Loading ' + url);
8
+ await page.goto(url, { timeout: 30_000, waitUntil: "domcontentloaded" });
9
+ } catch (firstError) {
10
+ logTimestamp('Retry loading ' + url);
11
+ await page.goto(url, { timeout: 30_000, waitUntil: "domcontentloaded" });
12
+ }
13
+ await waitForCompleteLoad(page)
14
+ }
15
+
16
+
5
17
  function setClosePopups(f) {
6
18
  closePopups = f;
7
19
  }
@@ -188,6 +200,7 @@ async function selectOption(page, field, value) {
188
200
 
189
201
  export {
190
202
  waitForCompleteLoad,
203
+ gotoWithRetry,
191
204
  highlightedClick,
192
205
  click,
193
206
  hover,
@@ -96,7 +96,11 @@ function compareBoundingBoxes(baselineRect, currentRect, tolerance = 10, strictP
96
96
  return differences;
97
97
  }
98
98
 
99
- function compareTexts(baselineTexts, currentTexts, strictPosition = true) {
99
+ function replaceDigitsWithPlaceholder(text) {
100
+ return text.replace(/\d+/g, '[digits]');
101
+ }
102
+
103
+ function compareTexts(baselineTexts, currentTexts, strictPosition = true, maskDigits = false) {
100
104
  const { matchedPairs, unmatchedCurrent, unmatchedBaseline } = createPairings(currentTexts, baselineTexts);
101
105
 
102
106
  for (const pairing of matchedPairs) {
@@ -104,6 +108,14 @@ function compareTexts(baselineTexts, currentTexts, strictPosition = true) {
104
108
  const currentText = currentTexts[pairing.currentIndex];
105
109
  currentText.differences = [];
106
110
 
111
+
112
+ let textsEqual = false
113
+ if (maskDigits) {
114
+ textsEqual = replaceDigitsWithPlaceholder(baselineText.text) !== replaceDigitsWithPlaceholder(currentText.text);
115
+ } else {
116
+ textsEqual = baselineText.text !== currentText.text;
117
+ }
118
+
107
119
  if (baselineText.text !== currentText.text) {
108
120
  //expect.soft(currentText.text).toEqual(baselineText.text);
109
121
  currentText.differences.push({
@@ -144,7 +156,7 @@ function compareTexts(baselineTexts, currentTexts, strictPosition = true) {
144
156
  }
145
157
  }
146
158
 
147
- function compareElements(baselineElement, currentElement, strictPosition = true) {
159
+ function compareElements(baselineElement, currentElement, strictPosition = true, maskDigits = false) {
148
160
  currentElement.differences = compareBoundingBoxes(
149
161
  baselineElement.boundingRect,
150
162
  currentElement.boundingRect,
@@ -163,9 +175,7 @@ function compareElements(baselineElement, currentElement, strictPosition = true)
163
175
  }
164
176
 
165
177
  if (baselineElement.texts.length > 0 || currentElement.texts.length > 0) {
166
- //expect.soft(baselineElement.texts.length, "Number of texts in element should be equal").toEqual(currentElement.texts.length);
167
-
168
- compareTexts(baselineElement.texts, currentElement.texts, strictPosition);
178
+ compareTexts(baselineElement.texts, currentElement.texts, strictPosition, maskDigits);
169
179
  }
170
180
  }
171
181
 
@@ -183,6 +193,7 @@ function compareWireframes(baselineWireframe, currentWireframe) {
183
193
  expect(baselineElementGroup.selector).toEqual(currentElementGroup.selector);
184
194
 
185
195
  const strictPosition = currentElementGroup.strictPosition !== false;
196
+ const maskDigits = currentElementGroup.maskDigits === true;
186
197
 
187
198
  //TODO: REMOVE later
188
199
  if (currentElementGroup.differences?.length === 0) {
@@ -197,7 +208,7 @@ function compareWireframes(baselineWireframe, currentWireframe) {
197
208
  for (const pairing of matchedPairs) {
198
209
  const baselineElement = baselineElementGroup.elements[pairing.baselineIndex];
199
210
  const currentElement = currentElementGroup.elements[pairing.currentIndex];
200
- compareElements(baselineElement, currentElement, strictPosition);
211
+ compareElements(baselineElement, currentElement, strictPosition, maskDigits);
201
212
  if (currentElement.differences?.length === 0) {
202
213
  currentElement.differences = undefined
203
214
  }
@@ -86,6 +86,7 @@ async function extractWireframe(page, elementGroups) {
86
86
  for (let groupIndex = 0; groupIndex < elementGroups.length; groupIndex++) {
87
87
  const elementGroup = elementGroups[groupIndex];
88
88
  elementGroup.strictPosition = elementGroup.strictPosition !== false;
89
+ elementGroup.maskDigits = elementGroup.maskDigits === false;
89
90
  elementGroup.elements = [];
90
91
 
91
92
  let index = 0;