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 +72 -21
- package/package.json +1 -1
- package/src/index.js +1 -0
- package/src/testUtils.js +13 -0
- package/src/wireframeComparison.js +17 -6
- package/src/wireframeUtils.js +1 -0
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',
|
|
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
|
-
- `
|
|
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', //
|
|
72
|
+
'product-config', // output directory
|
|
67
73
|
'product-page', // output file basename
|
|
68
|
-
'Product Page'
|
|
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
|
|
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
|
-
|
|
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,
|
|
98
|
-
- `
|
|
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
|
|
116
|
-
2. **Stability loop** — the wireframe is captured repeatedly until consecutive captures are stable
|
|
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
|
-
|
|
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
|
-
|
|
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>/<
|
|
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
package/src/index.js
CHANGED
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
|
|
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
|
-
|
|
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
|
}
|
package/src/wireframeUtils.js
CHANGED
|
@@ -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;
|