flexysnap 0.1.0 → 0.1.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 +62 -42
- package/package.json +3 -3
- package/src/annotateWireframe.js +137 -0
- package/src/cli.js +29 -18
- package/src/index.js +21 -23
- package/src/testUtils.js +166 -0
- package/src/wireframeComparison.js +225 -0
- package/src/wireframeStability.js +157 -0
- package/src/wireframeUtils.js +268 -0
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Traditional pixel-diffing tools flag every layout shift as a failure, even a 2px
|
|
|
10
10
|
- **Position tolerance** — element groups can be marked `strictPosition: false` to check size only, ignoring exact placement for content that legitimately moves.
|
|
11
11
|
- **Stability detection** — wireframes are re-captured until the layout settles, so lazy-loaded images, animations, and reflow don't produce flaky baselines.
|
|
12
12
|
- **Built on Playwright** — works with your existing Playwright config, fixtures, and test runner. No new browser automation layer to learn.
|
|
13
|
-
- **
|
|
13
|
+
- **Annotated screenshots** — overlay the captured wireframe onto its screenshot to visualize what was checked and what differed.
|
|
14
14
|
|
|
15
15
|
## Installation
|
|
16
16
|
|
|
@@ -49,7 +49,7 @@ const elementGroups = [
|
|
|
49
49
|
|
|
50
50
|
```js
|
|
51
51
|
import { test } from '@playwright/test';
|
|
52
|
-
import { expectWireframe } from 'flexysnap
|
|
52
|
+
import { expectWireframe } from 'flexysnap';
|
|
53
53
|
|
|
54
54
|
const elementGroups = [
|
|
55
55
|
{ selector: '.hero-banner', type: 'image' },
|
|
@@ -76,6 +76,40 @@ test('product page wireframe', async ({ page }) => {
|
|
|
76
76
|
wireframes/test/<TEST_TYPE>/<config>/<DEVICE_TYPE>/<USER_TYPE>/
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
+
## API
|
|
80
|
+
|
|
81
|
+
`flexysnap` exports the following from its main entry point:
|
|
82
|
+
|
|
83
|
+
### Test utilities
|
|
84
|
+
|
|
85
|
+
- `waitForCompleteLoad(page)` — wait for `domcontentloaded`, `load`, and a settle delay.
|
|
86
|
+
- `click(page, locator)` — resilient click that closes popups, re-hovers, scrolls into view, and retries.
|
|
87
|
+
- `highlightedClick(page, locator)` — scroll a locator into the viewport and force-click it.
|
|
88
|
+
- `hover(page, locator)` — hover a locator and remember it for re-hovering.
|
|
89
|
+
- `rehover(page)` — re-hover the last hovered locator.
|
|
90
|
+
- `fill(page, field, value)` — click and fill an input by name or locator.
|
|
91
|
+
- `setClosePopups(fn)` — register a function used to dismiss popups before interactions.
|
|
92
|
+
- `setBaseUrl(url)` — set the base URL used for request cache-busting.
|
|
93
|
+
- `logTimestamp(eventName)` — log an event with elapsed time since test start.
|
|
94
|
+
|
|
95
|
+
### Wireframe capture
|
|
96
|
+
|
|
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.
|
|
99
|
+
|
|
100
|
+
### Wireframe comparison
|
|
101
|
+
|
|
102
|
+
- `compareWireframes(baselineWireframe, currentWireframe)` — diff two wireframes and annotate the current one with `differences`.
|
|
103
|
+
- `compareElements(baselineElement, currentElement, strictPosition?)`
|
|
104
|
+
- `compareTexts(baselineTexts, currentTexts, strictPosition?)`
|
|
105
|
+
- `compareBoundingBoxes(baselineRect, currentRect, tolerance?, strictPosition?)`
|
|
106
|
+
- `createPairings(currentElements, baselineElements)` — nearest bounding-box matching between two element sets.
|
|
107
|
+
- `histogramDiff(a, b)` — sum of absolute differences between two histograms.
|
|
108
|
+
|
|
109
|
+
### Stability
|
|
110
|
+
|
|
111
|
+
- `areWireframesStable(previousWireframeData, currentWireframeData)` — determine whether two consecutive captures are stable enough to trust.
|
|
112
|
+
|
|
79
113
|
## How it works
|
|
80
114
|
|
|
81
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.
|
|
@@ -89,27 +123,26 @@ Wireframe paths are namespaced by environment variables so the same tests can ru
|
|
|
89
123
|
| Variable | Example | Purpose |
|
|
90
124
|
|---------------|--------------|----------------------------------|
|
|
91
125
|
| `TEST_TYPE` | `smoke` | Top-level test grouping |
|
|
92
|
-
| `TEST_CONFIG` | `production` | Configuration name |
|
|
93
126
|
| `DEVICE_TYPE` | `mobile` | Device / viewport identifier |
|
|
94
127
|
| `USER_TYPE` | `guest` | User role identifier |
|
|
95
128
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
The comparison suite reads baseline and current wireframes and asserts they match:
|
|
129
|
+
The output path for a captured wireframe is:
|
|
99
130
|
|
|
100
|
-
```
|
|
101
|
-
TEST_TYPE
|
|
102
|
-
npx playwright test compare-wireframes.spec.js
|
|
131
|
+
```
|
|
132
|
+
wireframes/test/<TEST_TYPE>/<configName>/<DEVICE_TYPE>/<USER_TYPE>/
|
|
103
133
|
```
|
|
104
134
|
|
|
105
|
-
|
|
135
|
+
where `configName` is passed directly to `expectWireframe`.
|
|
106
136
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
137
|
+
## Comparing against a baseline
|
|
138
|
+
|
|
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.
|
|
140
|
+
|
|
141
|
+
```js
|
|
142
|
+
import { compareWireframes } from 'flexysnap';
|
|
111
143
|
|
|
112
|
-
|
|
144
|
+
const annotated = compareWireframes(baselineWireframe, currentWireframe);
|
|
145
|
+
```
|
|
113
146
|
|
|
114
147
|
## Difference types
|
|
115
148
|
|
|
@@ -124,47 +157,34 @@ Each matched wireframe is diffed and re-written with annotated `differences`, th
|
|
|
124
157
|
| `extra_element` | Element present now but not in baseline |
|
|
125
158
|
| `missing_element` | Element present in baseline but not now |
|
|
126
159
|
|
|
127
|
-
##
|
|
160
|
+
## CLI
|
|
128
161
|
|
|
129
|
-
|
|
162
|
+
`flexysnap` ships a small CLI:
|
|
130
163
|
|
|
131
164
|
```bash
|
|
132
|
-
#
|
|
133
|
-
|
|
165
|
+
# print usage
|
|
166
|
+
flexysnap
|
|
134
167
|
|
|
135
|
-
#
|
|
136
|
-
|
|
137
|
-
```
|
|
168
|
+
# regenerate baseline snapshots (not implemented yet)
|
|
169
|
+
flexysnap update
|
|
138
170
|
|
|
139
|
-
|
|
171
|
+
# annotate a single wireframe/screenshot pair
|
|
172
|
+
flexysnap annotate wireframe.json screenshot.png
|
|
140
173
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
```bash
|
|
144
|
-
node generate-report.js <baseline-folder> <current-folder> [output-file]
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
Example:
|
|
148
|
-
|
|
149
|
-
```bash
|
|
150
|
-
node generate-report.js \
|
|
151
|
-
wireframes/baseline/smoke/production/mobile/guest \
|
|
152
|
-
wireframes/test/smoke/production/mobile/guest \
|
|
153
|
-
report.html
|
|
174
|
+
# annotate every matching .json/.png pair in a folder
|
|
175
|
+
flexysnap annotate ./wireframes/test/smoke/product-config/mobile/guest
|
|
154
176
|
```
|
|
155
177
|
|
|
156
|
-
|
|
178
|
+
### Annotating screenshots
|
|
157
179
|
|
|
158
|
-
|
|
159
|
-
- A baseline↔current comparison slider (via cocoen) when both images are available.
|
|
160
|
-
- A global toggle between **original** and **annotated** screenshots.
|
|
161
|
-
- A per-wireframe list of detected differences, with empty sections auto-collapsed.
|
|
180
|
+
Overlay the captured wireframe onto its screenshot to visualize what was checked and what differed. Boxes are color-coded: text (blue), image (green), box (amber), and error (red) for any element carrying differences. Dashed borders indicate position-tolerant groups. Output is written next to the screenshot as `<name>_wireframe.png`.
|
|
162
181
|
|
|
163
182
|
## Roadmap
|
|
164
183
|
|
|
184
|
+
- [ ] Baseline update command (`flexysnap update`)
|
|
165
185
|
- [ ] Region auto-detection for common e-commerce patterns (cart, checkout, PDP)
|
|
166
186
|
- [ ] GitHub Actions annotation integration
|
|
167
|
-
- [ ]
|
|
187
|
+
- [ ] HTML report generation with comparison sliders
|
|
168
188
|
|
|
169
189
|
## Contributing
|
|
170
190
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flexysnap",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Flexible visual snapshot testing for Playwright, built for e-commerce.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"playwright",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
".": "./src/index.js"
|
|
29
29
|
},
|
|
30
30
|
"bin": {
|
|
31
|
-
"flexysnap": "
|
|
31
|
+
"flexysnap": "src/cli.js"
|
|
32
32
|
},
|
|
33
33
|
"files": [
|
|
34
34
|
"src",
|
|
@@ -52,4 +52,4 @@
|
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"eslint": "^9.9.0"
|
|
54
54
|
}
|
|
55
|
-
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { createCanvas } from 'canvas';
|
|
5
|
+
|
|
6
|
+
async function annotateWireframeFile(wireframeFile, screenshotFile) {
|
|
7
|
+
if (!fs.existsSync(wireframeFile)) {
|
|
8
|
+
console.error(`Wireframe file not found: ${wireframeFile}`);
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (!fs.existsSync(screenshotFile)) {
|
|
13
|
+
console.error(`Screenshot file not found: ${screenshotFile}`);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const wireframeContent = fs.readFileSync(wireframeFile, 'utf-8');
|
|
18
|
+
const wireframe = JSON.parse(wireframeContent);
|
|
19
|
+
|
|
20
|
+
const metadata = await sharp(screenshotFile).metadata();
|
|
21
|
+
const width = metadata.width;
|
|
22
|
+
const height = metadata.height;
|
|
23
|
+
|
|
24
|
+
const canvas = createCanvas(width, height);
|
|
25
|
+
const ctx = canvas.getContext('2d');
|
|
26
|
+
|
|
27
|
+
const colors = {
|
|
28
|
+
box: { fill: 'rgba(220, 160, 40, 0.4)', stroke: '#E6A500' },
|
|
29
|
+
image: { fill: 'rgba(70, 180, 120, 0.4)', stroke: '#2EBC6F' },
|
|
30
|
+
text: { fill: 'rgba(50, 130, 190, 0.4)', stroke: '#0066CC' },
|
|
31
|
+
error: { fill: 'rgba(211, 47, 47, 0.4)', stroke: '#D32F2F' }
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function hasErrors(obj) {
|
|
35
|
+
return obj.differences && Array.isArray(obj.differences) && obj.differences.length > 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function drawBoundingBox(rect, colorKey, dashed = false) {
|
|
39
|
+
const boxWidth = rect.right - rect.left;
|
|
40
|
+
const boxHeight = rect.bottom - rect.top;
|
|
41
|
+
const color = colors[colorKey];
|
|
42
|
+
|
|
43
|
+
ctx.fillStyle = color.fill;
|
|
44
|
+
ctx.fillRect(rect.left, rect.top, boxWidth, boxHeight);
|
|
45
|
+
ctx.strokeStyle = color.stroke;
|
|
46
|
+
ctx.lineWidth = 2;
|
|
47
|
+
ctx.setLineDash(dashed ? [6, 4] : []);
|
|
48
|
+
ctx.strokeRect(rect.left, rect.top, boxWidth, boxHeight);
|
|
49
|
+
ctx.setLineDash([]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const elementGroup of wireframe.elementGroups) {
|
|
53
|
+
const dashed = elementGroup.strictPosition === false;
|
|
54
|
+
|
|
55
|
+
for (const element of elementGroup.elements) {
|
|
56
|
+
const rect = element.boundingRect;
|
|
57
|
+
|
|
58
|
+
if (element.type === 'box') {
|
|
59
|
+
const colorKey = hasErrors(element) ? 'error' : 'box';
|
|
60
|
+
drawBoundingBox(rect, colorKey, dashed);
|
|
61
|
+
} else if (element.type === 'image') {
|
|
62
|
+
const colorKey = hasErrors(element) ? 'error' : 'image';
|
|
63
|
+
drawBoundingBox(rect, colorKey, dashed);
|
|
64
|
+
} else if (element.type === 'text' && element.texts && element.texts.length > 0) {
|
|
65
|
+
for (const text of element.texts) {
|
|
66
|
+
const colorKey = hasErrors(text) ? 'error' : 'text';
|
|
67
|
+
drawBoundingBox(text.boundingRect, colorKey, dashed);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const overlayBuffer = canvas.toBuffer('image/png');
|
|
74
|
+
|
|
75
|
+
const outputDir = path.dirname(screenshotFile);
|
|
76
|
+
const basename = path.basename(screenshotFile, '.png');
|
|
77
|
+
const outputPath = path.join(outputDir, `${basename}_wireframe.png`);
|
|
78
|
+
|
|
79
|
+
await sharp(screenshotFile)
|
|
80
|
+
.grayscale()
|
|
81
|
+
.composite([{
|
|
82
|
+
input: overlayBuffer,
|
|
83
|
+
blend: 'over'
|
|
84
|
+
}])
|
|
85
|
+
.toFile(outputPath);
|
|
86
|
+
|
|
87
|
+
console.log(`Annotated wireframe saved to: ${outputPath}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function processFolder(folderPath) {
|
|
91
|
+
if (!fs.existsSync(folderPath)) {
|
|
92
|
+
console.error(`Folder not found: ${folderPath}`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const stats = fs.statSync(folderPath);
|
|
97
|
+
if (!stats.isDirectory()) {
|
|
98
|
+
console.error(`Path is not a directory: ${folderPath}`);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const files = fs.readdirSync(folderPath);
|
|
103
|
+
const basenames = new Set();
|
|
104
|
+
|
|
105
|
+
for (const file of files) {
|
|
106
|
+
const ext = path.extname(file).toLowerCase();
|
|
107
|
+
if (ext === '.json' || ext === '.png') {
|
|
108
|
+
const basename = path.basename(file, ext);
|
|
109
|
+
basenames.add(basename);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const basename of basenames) {
|
|
114
|
+
const jsonFile = path.join(folderPath, `${basename}.json`);
|
|
115
|
+
const pngFile = path.join(folderPath, `${basename}.png`);
|
|
116
|
+
|
|
117
|
+
if (fs.existsSync(jsonFile) && fs.existsSync(pngFile)) {
|
|
118
|
+
await annotateWireframeFile(jsonFile, pngFile);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function runAnnotate(args) {
|
|
124
|
+
if (args.length === 0) {
|
|
125
|
+
console.error('Usage: flexysnap annotate <wireframe.json> <screenshot.png>');
|
|
126
|
+
console.error(' or: flexysnap annotate <folder>');
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (args.length === 1) {
|
|
131
|
+
await processFolder(args[0]);
|
|
132
|
+
} else if (args.length >= 2) {
|
|
133
|
+
const wireframeFile = args[0];
|
|
134
|
+
const screenshotFile = args[1];
|
|
135
|
+
await annotateWireframeFile(wireframeFile, screenshotFile);
|
|
136
|
+
}
|
|
137
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -1,22 +1,33 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
* flexysnap CLI — placeholder implementation.
|
|
5
|
-
*
|
|
6
|
-
* Usage:
|
|
7
|
-
* npx flexysnap update Regenerate baseline snapshots
|
|
8
|
-
*/
|
|
3
|
+
import { runAnnotate } from './annotateWireframe.js';
|
|
9
4
|
|
|
10
|
-
const [, , command] = process.argv;
|
|
5
|
+
const [, , command, ...args] = process.argv;
|
|
11
6
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
7
|
+
function printUsage() {
|
|
8
|
+
console.log('flexysnap');
|
|
9
|
+
console.log('');
|
|
10
|
+
console.log('Usage:');
|
|
11
|
+
console.log(' flexysnap update Regenerate baseline snapshots');
|
|
12
|
+
console.log(' flexysnap annotate <wireframe.json> <screenshot.png> Annotate a screenshot with wireframe data');
|
|
13
|
+
console.log(' flexysnap annotate <folder> Annotate all matching files in a folder');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function main() {
|
|
17
|
+
switch (command) {
|
|
18
|
+
case 'update':
|
|
19
|
+
console.log('flexysnap: baseline update not implemented yet.');
|
|
20
|
+
break;
|
|
21
|
+
case 'annotate':
|
|
22
|
+
await runAnnotate(args);
|
|
23
|
+
break;
|
|
24
|
+
default:
|
|
25
|
+
printUsage();
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
main().catch(err => {
|
|
31
|
+
console.error('Error:', err.message);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
});
|
package/src/index.js
CHANGED
|
@@ -1,26 +1,24 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
export {
|
|
2
|
+
waitForCompleteLoad,
|
|
3
|
+
highlightedClick,
|
|
4
|
+
click,
|
|
5
|
+
hover,
|
|
6
|
+
fill,
|
|
7
|
+
rehover,
|
|
8
|
+
setClosePopups,
|
|
9
|
+
logTimestamp,
|
|
10
|
+
setBaseUrl
|
|
11
|
+
} from './testUtils.js';
|
|
7
12
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
*/
|
|
17
|
-
export async function expectSnapshot(page, name, options = {}) {
|
|
18
|
-
const { tolerance = 0.02, regions = {} } = options;
|
|
13
|
+
export {
|
|
14
|
+
compareWireframes,
|
|
15
|
+
compareElements,
|
|
16
|
+
compareTexts,
|
|
17
|
+
compareBoundingBoxes,
|
|
18
|
+
createPairings,
|
|
19
|
+
histogramDiff
|
|
20
|
+
} from './wireframeComparison.js';
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
throw new Error(
|
|
22
|
-
`expectSnapshot("${name}") is not implemented yet. tolerance=${tolerance}, regions=${JSON.stringify(regions)}`
|
|
23
|
-
);
|
|
24
|
-
}
|
|
22
|
+
export { areWireframesStable } from './wireframeStability.js';
|
|
25
23
|
|
|
26
|
-
export
|
|
24
|
+
export { expectWireframe, getRGBHistogramFromBuffer } from './wireframeUtils.js';
|
package/src/testUtils.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { test } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
let closePopups = async function(page) {}
|
|
4
|
+
|
|
5
|
+
function setClosePopups(f) {
|
|
6
|
+
closePopups = f;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
let baseUrl = '';
|
|
10
|
+
function setBaseUrl(url) {
|
|
11
|
+
baseUrl = url;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test.beforeEach(async ({ context }) => {
|
|
15
|
+
startTimer();
|
|
16
|
+
logTimestamp('Test execution started');
|
|
17
|
+
await context.route('**/*', route => {
|
|
18
|
+
const req = route.request();
|
|
19
|
+
const url = new URL(req.url());
|
|
20
|
+
const path = url.pathname.toLowerCase();
|
|
21
|
+
if (req.method() !== 'GET' || path.includes('/wp-content/') || !path.includes(baseUrl)) {
|
|
22
|
+
return route.continue();
|
|
23
|
+
}
|
|
24
|
+
url.searchParams.set('_cb', Date.now().toString());
|
|
25
|
+
const urlString = url.toString();
|
|
26
|
+
logTimestamp('Requesting ' + urlString);
|
|
27
|
+
route.continue({ url: urlString });
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
let testStartTime = null;
|
|
32
|
+
function startTimer() {
|
|
33
|
+
testStartTime = new Date();
|
|
34
|
+
|
|
35
|
+
logTimestamp(`Test started at: ${testStartTime.toISOString()}`);
|
|
36
|
+
|
|
37
|
+
return testStartTime;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function logTimestamp(eventName) {
|
|
41
|
+
if (!testStartTime) {
|
|
42
|
+
startTimer();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const currentTime = new Date();
|
|
46
|
+
const elapsedMs = currentTime - testStartTime;
|
|
47
|
+
const elapsedSec = (elapsedMs / 1000).toFixed(3);
|
|
48
|
+
|
|
49
|
+
const logMessage = `[${currentTime.toISOString()}] ${eventName} - ${elapsedSec}s elapsed\n`;
|
|
50
|
+
console.log(logMessage.trim());
|
|
51
|
+
|
|
52
|
+
return { currentTime, elapsedMs, elapsedSec };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function waitForCompleteLoad(page) {
|
|
56
|
+
await page.waitForLoadState('domcontentloaded');
|
|
57
|
+
await page.waitForLoadState('load');
|
|
58
|
+
await page.waitForTimeout(500);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async function click(page, locator) {
|
|
63
|
+
await waitForCompleteLoad(page);
|
|
64
|
+
await closePopups(page);
|
|
65
|
+
await rehover(page);
|
|
66
|
+
const success = await highlightedClick(page, locator);
|
|
67
|
+
await waitForCompleteLoad(page);
|
|
68
|
+
await page.waitForTimeout(500);
|
|
69
|
+
await closePopups(page);
|
|
70
|
+
if (!success) {
|
|
71
|
+
await highlightedClick(page, locator);
|
|
72
|
+
await page.waitForTimeout(500);
|
|
73
|
+
await closePopups(page);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function highlightedClick(page, locator) {
|
|
78
|
+
const isInViewport = await locator.evaluate((element) => {
|
|
79
|
+
const rect = element.getBoundingClientRect();
|
|
80
|
+
const windowHeight = window.innerHeight;
|
|
81
|
+
const windowWidth = window.innerWidth;
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
rect.top >= 0 &&
|
|
85
|
+
rect.left >= 0 &&
|
|
86
|
+
rect.bottom <= windowHeight &&
|
|
87
|
+
rect.right <= windowWidth
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if (!isInViewport) {
|
|
92
|
+
const elementPosition = await locator.evaluate((element) => {
|
|
93
|
+
const rect = element.getBoundingClientRect();
|
|
94
|
+
return {
|
|
95
|
+
top: rect.top + window.scrollY,
|
|
96
|
+
height: rect.height
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const viewportHeight = await page.evaluate(() => window.innerHeight);
|
|
101
|
+
const targetScrollY = elementPosition.top - (viewportHeight / 2) + (elementPosition.height / 2);
|
|
102
|
+
|
|
103
|
+
const maxScrollY = await page.evaluate(() => document.body.scrollHeight - window.innerHeight);
|
|
104
|
+
const finalScrollY = Math.max(0, Math.min(targetScrollY, maxScrollY));
|
|
105
|
+
|
|
106
|
+
logTimestamp(`Element not in viewport, scrolling to position: ${finalScrollY}`);
|
|
107
|
+
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), finalScrollY);
|
|
108
|
+
await page.waitForTimeout(500);
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
await closePopups(page);
|
|
112
|
+
await locator.click({ force: true });
|
|
113
|
+
logTimestamp('clicking ' + locator);
|
|
114
|
+
return true;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
logTimestamp(`Error clicking on locator: ${locator} ${error.message}`);
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let hoveredLocator = null;
|
|
122
|
+
|
|
123
|
+
async function hover(page, locator) {
|
|
124
|
+
hoveredLocator = locator;
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
await locator.hover();
|
|
128
|
+
logTimestamp('hovering ' + locator);
|
|
129
|
+
return true;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
logTimestamp(`Error hovering on locator: ${locator} ${error.message}`);
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function rehover(page) {
|
|
137
|
+
if (hoveredLocator) {
|
|
138
|
+
await hover(page, hoveredLocator);
|
|
139
|
+
hoveredLocator = null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function fill(page, field, value) {
|
|
144
|
+
let locator;
|
|
145
|
+
if (typeof field === 'string') {
|
|
146
|
+
const selector = `input[name="${field}"]`;
|
|
147
|
+
locator = page.locator(selector);
|
|
148
|
+
} else {
|
|
149
|
+
locator = field;
|
|
150
|
+
}
|
|
151
|
+
await click(page, locator);
|
|
152
|
+
await locator.fill(value);
|
|
153
|
+
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = {
|
|
157
|
+
waitForCompleteLoad,
|
|
158
|
+
highlightedClick,
|
|
159
|
+
click,
|
|
160
|
+
hover,
|
|
161
|
+
fill,
|
|
162
|
+
rehover,
|
|
163
|
+
setClosePopups,
|
|
164
|
+
logTimestamp,
|
|
165
|
+
setBaseUrl
|
|
166
|
+
};
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { expect } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
function histogramDiff(a, b) {
|
|
4
|
+
return a.reduce((sum, val, i) => sum + Math.abs(val - b[i]), 0);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function calculateBoundingBoxDistance(rect1, rect2) {
|
|
8
|
+
return Math.max(Math.abs(rect1.left - rect2.left),
|
|
9
|
+
Math.abs(rect1.right - rect2.right),
|
|
10
|
+
Math.abs(rect1.top - rect2.top),
|
|
11
|
+
Math.abs(rect1.bottom - rect2.bottom));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function createPairings(currentElements, baselineElements) {
|
|
15
|
+
const pairings = [];
|
|
16
|
+
|
|
17
|
+
for (let currentIndex = 0; currentIndex < currentElements.length; currentIndex++) {
|
|
18
|
+
for (let baselineIndex = 0; baselineIndex < baselineElements.length; baselineIndex++) {
|
|
19
|
+
const distance = calculateBoundingBoxDistance(
|
|
20
|
+
currentElements[currentIndex].boundingRect,
|
|
21
|
+
baselineElements[baselineIndex].boundingRect
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
pairings.push({
|
|
25
|
+
currentIndex,
|
|
26
|
+
baselineIndex,
|
|
27
|
+
distance
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
pairings.sort((a, b) => a.distance - b.distance);
|
|
33
|
+
|
|
34
|
+
const usedCurrentIndices = new Set();
|
|
35
|
+
const usedBaselineIndices = new Set();
|
|
36
|
+
const matchedPairs = [];
|
|
37
|
+
|
|
38
|
+
for (const pairing of pairings) {
|
|
39
|
+
if (!usedCurrentIndices.has(pairing.currentIndex) && !usedBaselineIndices.has(pairing.baselineIndex)) {
|
|
40
|
+
usedCurrentIndices.add(pairing.currentIndex);
|
|
41
|
+
usedBaselineIndices.add(pairing.baselineIndex);
|
|
42
|
+
matchedPairs.push(pairing);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const unmatchedCurrent = [];
|
|
47
|
+
for (let i = 0; i < currentElements.length; i++) {
|
|
48
|
+
if (!usedCurrentIndices.has(i)) {
|
|
49
|
+
unmatchedCurrent.push(i);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const unmatchedBaseline = [];
|
|
54
|
+
for (let i = 0; i < baselineElements.length; i++) {
|
|
55
|
+
if (!usedBaselineIndices.has(i)) {
|
|
56
|
+
unmatchedBaseline.push(i);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
matchedPairs,
|
|
62
|
+
unmatchedCurrent,
|
|
63
|
+
unmatchedBaseline
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function compareBoundingBoxes(baselineRect, currentRect, tolerance = 10, strictPosition = true) {
|
|
68
|
+
const differences = [];
|
|
69
|
+
|
|
70
|
+
if (strictPosition) {
|
|
71
|
+
const boundingBoxDifference = calculateBoundingBoxDistance(baselineRect, currentRect)
|
|
72
|
+
//expect.soft(boundingBoxDifference).toBeLessThanOrEqual(tolerance)
|
|
73
|
+
if (boundingBoxDifference > tolerance) {
|
|
74
|
+
differences.push({
|
|
75
|
+
type: "layout_shift",
|
|
76
|
+
difference: boundingBoxDifference
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
const baselineWidth = baselineRect.right - baselineRect.left;
|
|
81
|
+
const baselineHeight = baselineRect.bottom - baselineRect.top;
|
|
82
|
+
const currentWidth = currentRect.right - currentRect.left;
|
|
83
|
+
const currentHeight = currentRect.bottom - currentRect.top;
|
|
84
|
+
const sizeDiff = Math.max(
|
|
85
|
+
Math.abs(baselineWidth - currentWidth),
|
|
86
|
+
Math.abs(baselineHeight - currentHeight)
|
|
87
|
+
);
|
|
88
|
+
if (sizeDiff > tolerance) {
|
|
89
|
+
differences.push({
|
|
90
|
+
type: "size_mismatch",
|
|
91
|
+
difference: sizeDiff
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return differences;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function compareTexts(baselineTexts, currentTexts, strictPosition = true) {
|
|
100
|
+
const { matchedPairs, unmatchedCurrent, unmatchedBaseline } = createPairings(currentTexts, baselineTexts);
|
|
101
|
+
|
|
102
|
+
for (const pairing of matchedPairs) {
|
|
103
|
+
const baselineText = baselineTexts[pairing.baselineIndex];
|
|
104
|
+
const currentText = currentTexts[pairing.currentIndex];
|
|
105
|
+
currentText.differences = [];
|
|
106
|
+
|
|
107
|
+
if (baselineText.text !== currentText.text) {
|
|
108
|
+
//expect.soft(currentText.text).toEqual(baselineText.text);
|
|
109
|
+
currentText.differences.push({
|
|
110
|
+
type: 'text_mismatch',
|
|
111
|
+
baseline: baselineText.text,
|
|
112
|
+
current: currentText.text,
|
|
113
|
+
});
|
|
114
|
+
} else {
|
|
115
|
+
const boundingBoxDifferences = compareBoundingBoxes(
|
|
116
|
+
baselineText.boundingRect,
|
|
117
|
+
currentText.boundingRect,
|
|
118
|
+
10,
|
|
119
|
+
strictPosition
|
|
120
|
+
);
|
|
121
|
+
currentText.differences = currentText.differences.concat(boundingBoxDifferences);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const currentIndex of unmatchedCurrent) {
|
|
126
|
+
const currentText = currentTexts[currentIndex];
|
|
127
|
+
currentText.differences = [{
|
|
128
|
+
type: 'extra_text',
|
|
129
|
+
}];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const baselineIndex of unmatchedBaseline) {
|
|
133
|
+
const baselineText = baselineTexts[baselineIndex];
|
|
134
|
+
currentTexts.push(baselineText);
|
|
135
|
+
baselineText.differences = [{
|
|
136
|
+
type: 'missing_text',
|
|
137
|
+
}];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const currentText of currentTexts) {
|
|
141
|
+
if (currentText.differences?.length === 0) {
|
|
142
|
+
currentText.differences = undefined
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function compareElements(baselineElement, currentElement, strictPosition = true) {
|
|
148
|
+
currentElement.differences = compareBoundingBoxes(
|
|
149
|
+
baselineElement.boundingRect,
|
|
150
|
+
currentElement.boundingRect,
|
|
151
|
+
10,
|
|
152
|
+
strictPosition
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
if (baselineElement.histogram && currentElement.histogram) {
|
|
156
|
+
const dh = histogramDiff(baselineElement.histogram, currentElement.histogram);
|
|
157
|
+
//expect.soft(dh, 'Histogram difference').toBeLessThanOrEqual(10);
|
|
158
|
+
if (dh > 15) {
|
|
159
|
+
currentElement.differences.push({
|
|
160
|
+
type: 'histogram_difference',
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
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);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function compareWireframes(baselineWireframe, currentWireframe) {
|
|
173
|
+
const maxSelectorCount = Math.max(
|
|
174
|
+
baselineWireframe.elementGroups.length,
|
|
175
|
+
currentWireframe.elementGroups.length
|
|
176
|
+
);
|
|
177
|
+
expect(currentWireframe.elementGroups.length, 'Number of element groups in the layout should be the same.').toEqual(baselineWireframe.elementGroups.length);
|
|
178
|
+
|
|
179
|
+
for (let i = 0; i < maxSelectorCount; i++) {
|
|
180
|
+
const baselineElementGroup = baselineWireframe.elementGroups[i];
|
|
181
|
+
const currentElementGroup = currentWireframe.elementGroups[i];
|
|
182
|
+
|
|
183
|
+
expect(baselineElementGroup.selector).toEqual(currentElementGroup.selector);
|
|
184
|
+
|
|
185
|
+
const strictPosition = currentElementGroup.strictPosition !== false;
|
|
186
|
+
|
|
187
|
+
//TODO: REMOVE later
|
|
188
|
+
if (currentElementGroup.differences?.length === 0) {
|
|
189
|
+
currentElementGroup.differences = undefined
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const { matchedPairs, unmatchedCurrent, unmatchedBaseline } = createPairings(
|
|
193
|
+
currentElementGroup.elements,
|
|
194
|
+
baselineElementGroup.elements
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
for (const pairing of matchedPairs) {
|
|
198
|
+
const baselineElement = baselineElementGroup.elements[pairing.baselineIndex];
|
|
199
|
+
const currentElement = currentElementGroup.elements[pairing.currentIndex];
|
|
200
|
+
compareElements(baselineElement, currentElement, strictPosition);
|
|
201
|
+
if (currentElement.differences?.length === 0) {
|
|
202
|
+
currentElement.differences = undefined
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
for (const currentIndex of unmatchedCurrent) {
|
|
207
|
+
const currentElement = currentElementGroup.elements[currentIndex];
|
|
208
|
+
currentElement.differences = [{
|
|
209
|
+
type: 'extra_element',
|
|
210
|
+
}];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
for (const baselineIndex of unmatchedBaseline) {
|
|
214
|
+
const baselineElement = baselineElementGroup.elements[baselineIndex];
|
|
215
|
+
currentElementGroup.elements.push(baselineElement);
|
|
216
|
+
baselineElement.differences = [{
|
|
217
|
+
type: 'missing_element',
|
|
218
|
+
}];
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return currentWireframe;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export { compareWireframes, compareElements, compareTexts, compareBoundingBoxes, createPairings, histogramDiff };
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
|
|
2
|
+
import { logTimestamp } from './testUtils.js';
|
|
3
|
+
|
|
4
|
+
function collectTexts(wireframeData) {
|
|
5
|
+
const texts = [];
|
|
6
|
+
for (const elementGroup of wireframeData) {
|
|
7
|
+
for (const element of elementGroup.elements || []) {
|
|
8
|
+
for (const textEntry of element.texts || []) {
|
|
9
|
+
texts.push(textEntry.text);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return texts;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function countTextDifferences(previousWireframeData, currentWireframeData) {
|
|
17
|
+
const previousTexts = collectTexts(previousWireframeData);
|
|
18
|
+
const currentTexts = collectTexts(currentWireframeData);
|
|
19
|
+
|
|
20
|
+
const previousCounts = new Map();
|
|
21
|
+
for (const text of previousTexts) {
|
|
22
|
+
previousCounts.set(text, (previousCounts.get(text) || 0) + 1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const currentCounts = new Map();
|
|
26
|
+
for (const text of currentTexts) {
|
|
27
|
+
currentCounts.set(text, (currentCounts.get(text) || 0) + 1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const allTexts = new Set([...previousCounts.keys(), ...currentCounts.keys()]);
|
|
31
|
+
|
|
32
|
+
let differenceCount = 0;
|
|
33
|
+
for (const text of allTexts) {
|
|
34
|
+
const previousCount = previousCounts.get(text) || 0;
|
|
35
|
+
const currentCount = currentCounts.get(text) || 0;
|
|
36
|
+
differenceCount += Math.abs(previousCount - currentCount);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return differenceCount;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function findGroupsWithDifferentElementCount(previousWireframeData, currentWireframeData) {
|
|
43
|
+
const groupsWithDifference = [];
|
|
44
|
+
|
|
45
|
+
const groupCount = Math.max(previousWireframeData.length, currentWireframeData.length);
|
|
46
|
+
for (let groupIndex = 0; groupIndex < groupCount; groupIndex++) {
|
|
47
|
+
const previousGroup = previousWireframeData[groupIndex];
|
|
48
|
+
const currentGroup = currentWireframeData[groupIndex];
|
|
49
|
+
|
|
50
|
+
const previousElementCount = (previousGroup && previousGroup.elements) ? previousGroup.elements.length : 0;
|
|
51
|
+
const currentElementCount = (currentGroup && currentGroup.elements) ? currentGroup.elements.length : 0;
|
|
52
|
+
|
|
53
|
+
if (previousElementCount !== currentElementCount) {
|
|
54
|
+
const groupSelector = (currentGroup && currentGroup.selector) ||
|
|
55
|
+
(previousGroup && previousGroup.selector) ||
|
|
56
|
+
`index ${groupIndex}`;
|
|
57
|
+
groupsWithDifference.push({
|
|
58
|
+
groupIndex: groupIndex,
|
|
59
|
+
selector: groupSelector,
|
|
60
|
+
previousElementCount: previousElementCount,
|
|
61
|
+
currentElementCount: currentElementCount
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return groupsWithDifference;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function findEmptyGroups(wireframeData) {
|
|
70
|
+
const emptyGroups = [];
|
|
71
|
+
|
|
72
|
+
for (let groupIndex = 0; groupIndex < wireframeData.length; groupIndex++) {
|
|
73
|
+
const group = wireframeData[groupIndex];
|
|
74
|
+
const elementCount = (group && group.elements) ? group.elements.length : 0;
|
|
75
|
+
|
|
76
|
+
if (elementCount < 1) {
|
|
77
|
+
const groupSelector = (group && group.selector) || `index ${groupIndex}`;
|
|
78
|
+
emptyGroups.push({
|
|
79
|
+
groupIndex: groupIndex,
|
|
80
|
+
selector: groupSelector
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return emptyGroups;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getBoundingRectSize(boundingRect) {
|
|
89
|
+
const width = boundingRect.right - boundingRect.left;
|
|
90
|
+
const height = boundingRect.bottom - boundingRect.top;
|
|
91
|
+
return width * height;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function sumBoundingBoxSizeDifference(previousWireframeData, currentWireframeData) {
|
|
95
|
+
let totalSizeDifference = 0;
|
|
96
|
+
|
|
97
|
+
for (let groupIndex = 0; groupIndex < previousWireframeData.length; groupIndex++) {
|
|
98
|
+
const previousElements = previousWireframeData[groupIndex].elements || [];
|
|
99
|
+
const currentElements = currentWireframeData[groupIndex].elements || [];
|
|
100
|
+
|
|
101
|
+
for (let elementIndex = 0; elementIndex < previousElements.length; elementIndex++) {
|
|
102
|
+
const previousSize = getBoundingRectSize(previousElements[elementIndex].boundingRect);
|
|
103
|
+
const currentSize = getBoundingRectSize(currentElements[elementIndex].boundingRect);
|
|
104
|
+
totalSizeDifference += Math.abs(previousSize - currentSize);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return totalSizeDifference;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function sumBoundingBoxSize(wireframeData) {
|
|
112
|
+
let totalSize = 0;
|
|
113
|
+
|
|
114
|
+
for (const elementGroup of wireframeData) {
|
|
115
|
+
for (const element of elementGroup.elements || []) {
|
|
116
|
+
totalSize += getBoundingRectSize(element.boundingRect);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return totalSize;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function areWireframesStable(previousWireframeData, currentWireframeData) {
|
|
124
|
+
const emptyGroups = findEmptyGroups(currentWireframeData);
|
|
125
|
+
|
|
126
|
+
if (emptyGroups.length > 0) {
|
|
127
|
+
for (const group of emptyGroups) {
|
|
128
|
+
logTimestamp(`Wireframe group '${group.selector}' has no elements.`);
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const groupsWithDifferentElementCount = findGroupsWithDifferentElementCount(previousWireframeData, currentWireframeData);
|
|
134
|
+
|
|
135
|
+
if (groupsWithDifferentElementCount.length > 0) {
|
|
136
|
+
for (const group of groupsWithDifferentElementCount) {
|
|
137
|
+
logTimestamp(`Wireframe element count mismatch in group '${group.selector}' - previous: ${group.previousElementCount} current: ${group.currentElementCount}.`);
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const textDifferences = countTextDifferences(previousWireframeData, currentWireframeData);
|
|
143
|
+
|
|
144
|
+
if (textDifferences !== 0) {
|
|
145
|
+
logTimestamp(`Wireframe text differences: ${textDifferences}`)
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const sizeDifference = sumBoundingBoxSizeDifference(previousWireframeData, currentWireframeData);
|
|
150
|
+
const previousTotalSize = sumBoundingBoxSize(previousWireframeData);
|
|
151
|
+
const allowedSizeDifference = previousTotalSize * 0.03;
|
|
152
|
+
|
|
153
|
+
logTimestamp(`Wireframe bounding box size difference: ${sizeDifference} (allowed: ${allowedSizeDifference}).`)
|
|
154
|
+
return sizeDifference <= allowedSizeDifference;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export { areWireframesStable };
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { logTimestamp } from './testUtils.js';
|
|
4
|
+
import sharp from 'sharp';
|
|
5
|
+
import { areWireframesStable } from "./wireframeStability";
|
|
6
|
+
|
|
7
|
+
async function getRGBHistogramFromBuffer(buffer) {
|
|
8
|
+
const {data, info} = await sharp(buffer)
|
|
9
|
+
.raw()
|
|
10
|
+
.toBuffer({resolveWithObject: true});
|
|
11
|
+
|
|
12
|
+
const histogram = new Array(48).fill(0);
|
|
13
|
+
const pixelCount = info.width * info.height;
|
|
14
|
+
|
|
15
|
+
for (let i = 0; i < data.length; i += info.channels) {
|
|
16
|
+
histogram[Math.floor(data[i] / 16)]++;
|
|
17
|
+
histogram[Math.floor(data[i + 1] / 16) + 16]++;
|
|
18
|
+
histogram[Math.floor(data[i + 2] / 16) + 32]++;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
for (let i = 0; i < 48; i++) {
|
|
22
|
+
histogram[i] = Math.round((histogram[i] / pixelCount) * 100);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return histogram;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function delay(milliseconds) {
|
|
29
|
+
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function extractWireframe(page, elementGroups) {
|
|
33
|
+
const wireframeData = await page.evaluate(async ({elementGroups}) => {
|
|
34
|
+
|
|
35
|
+
function createBoundingRect(element) {
|
|
36
|
+
const rect = element.getBoundingClientRect();
|
|
37
|
+
return {
|
|
38
|
+
top: Math.round(rect.top),
|
|
39
|
+
left: Math.round(rect.left),
|
|
40
|
+
bottom: Math.round(rect.bottom),
|
|
41
|
+
right: Math.round(rect.right)
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getTextNodeBoundingBox(textNode) {
|
|
46
|
+
const range = document.createRange();
|
|
47
|
+
range.selectNodeContents(textNode);
|
|
48
|
+
const rect = range.getBoundingClientRect();
|
|
49
|
+
return {
|
|
50
|
+
top: Math.round(rect.top),
|
|
51
|
+
left: Math.round(rect.left),
|
|
52
|
+
bottom: Math.round(rect.bottom),
|
|
53
|
+
right: Math.round(rect.right)
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isElementVisible(element) {
|
|
58
|
+
const style = window.getComputedStyle(element);
|
|
59
|
+
if (style.display === 'none' ||
|
|
60
|
+
style.visibility === 'hidden' ||
|
|
61
|
+
style.opacity === '0' ||
|
|
62
|
+
element.offsetWidth <= 0 ||
|
|
63
|
+
element.offsetHeight <= 0) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const rect = element.getBoundingClientRect()
|
|
68
|
+
|
|
69
|
+
return rect.right >= 0 &&
|
|
70
|
+
rect.left < window.innerWidth &&
|
|
71
|
+
rect.bottom >= 0 &&
|
|
72
|
+
rect.top < window.innerHeight;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
for (const elementGroup of elementGroups) {
|
|
77
|
+
elementGroup.strictPosition = elementGroup.strictPosition !== false;
|
|
78
|
+
elementGroup.elements = [];
|
|
79
|
+
|
|
80
|
+
let index = 0;
|
|
81
|
+
const matchedElements = document.querySelectorAll(elementGroup.selector);
|
|
82
|
+
|
|
83
|
+
for (const element of matchedElements) {
|
|
84
|
+
if (!isElementVisible(element))
|
|
85
|
+
continue;
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
const texts = [];
|
|
89
|
+
|
|
90
|
+
function getElementsWithDirectText(root, textIgnoreClasses) {
|
|
91
|
+
const results = [];
|
|
92
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
|
93
|
+
acceptNode: node =>
|
|
94
|
+
node.nodeValue.trim()
|
|
95
|
+
? NodeFilter.FILTER_ACCEPT
|
|
96
|
+
: NodeFilter.FILTER_REJECT
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
while (walker.nextNode()) {
|
|
100
|
+
if (walker.currentNode.parentElement.classList.contains('screen-reader-text'))
|
|
101
|
+
continue;
|
|
102
|
+
|
|
103
|
+
if (textIgnoreClasses) {
|
|
104
|
+
let ignored = false;
|
|
105
|
+
console.log(textIgnoreClasses);
|
|
106
|
+
let parent = walker.currentNode.parentElement;
|
|
107
|
+
while (parent != null && !ignored) {
|
|
108
|
+
for (const className of parent.classList) {
|
|
109
|
+
if (textIgnoreClasses.includes(className)) {
|
|
110
|
+
ignored = true;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
parent = parent.parentElement;
|
|
115
|
+
}
|
|
116
|
+
if (ignored)
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (['select', 'option', 'input'].includes(walker.currentNode.parentElement.tagName))
|
|
121
|
+
continue;
|
|
122
|
+
|
|
123
|
+
if (!results.includes(walker.currentNode)) results.push(walker.currentNode);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return results;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (elementGroup.type === 'text') {
|
|
130
|
+
|
|
131
|
+
const descendantArray = getElementsWithDirectText(element, elementGroup.textIgnoreClasses)
|
|
132
|
+
|
|
133
|
+
for (const descendant of descendantArray) {
|
|
134
|
+
if (!isElementVisible(descendant.parentElement))
|
|
135
|
+
continue;
|
|
136
|
+
|
|
137
|
+
const text = descendant.textContent.trim().replaceAll(/\s+/g, ' ')
|
|
138
|
+
if (text.length === 0)
|
|
139
|
+
continue;
|
|
140
|
+
|
|
141
|
+
const isNested = descendantArray.some(other =>
|
|
142
|
+
other !== descendant && other.contains(descendant)
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
if (isNested)
|
|
146
|
+
continue;
|
|
147
|
+
|
|
148
|
+
texts.push({
|
|
149
|
+
text: text,
|
|
150
|
+
boundingRect: getTextNodeBoundingBox(descendant)
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
elementGroup.elements.push({
|
|
156
|
+
index: index,
|
|
157
|
+
boundingRect: createBoundingRect(element),
|
|
158
|
+
texts: texts,
|
|
159
|
+
type: elementGroup.type
|
|
160
|
+
});
|
|
161
|
+
index += 1;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return elementGroups;
|
|
166
|
+
}, {elementGroups});
|
|
167
|
+
|
|
168
|
+
const scrollPosition = await page.evaluate(() => ({
|
|
169
|
+
x: window.scrollX,
|
|
170
|
+
y: window.scrollY
|
|
171
|
+
}));
|
|
172
|
+
|
|
173
|
+
for (const elementGroup of wireframeData) {
|
|
174
|
+
for (const element of elementGroup.elements) {
|
|
175
|
+
if (element.type === 'image') {
|
|
176
|
+
const locator = page.locator(elementGroup.selector).nth(element.index);
|
|
177
|
+
await locator.waitFor({state: 'visible'});
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
|
|
181
|
+
await page.waitForFunction((el) => {
|
|
182
|
+
const images = Array.from(el.querySelectorAll('img'));
|
|
183
|
+
if (images.length === 0) return true;
|
|
184
|
+
return images.every(img => img.complete && img.naturalWidth > 0);
|
|
185
|
+
}, await locator.elementHandle(), {timeout: 3000});
|
|
186
|
+
} catch {
|
|
187
|
+
logTimestamp('could not load images for ' + elementGroup.selector)
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
const screenshot = await locator.screenshot({timeout: 3000});
|
|
191
|
+
element.histogram = await getRGBHistogramFromBuffer(screenshot);
|
|
192
|
+
} catch {
|
|
193
|
+
element.type = 'box'
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
await page.evaluate(({scrollPosition}) => {
|
|
200
|
+
window.scrollTo(scrollPosition.x, scrollPosition.y);
|
|
201
|
+
}, {scrollPosition});
|
|
202
|
+
return wireframeData;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function cloneElementGroups(elementGroups) {
|
|
206
|
+
return elementGroups.map(elementGroup => ({...elementGroup, elements: undefined}));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function extractStableWireframe(page, elementGroups, retryDelay, maxRetryCount) {
|
|
210
|
+
let previousWireframeData = null;
|
|
211
|
+
let currentWireframeData = null;
|
|
212
|
+
|
|
213
|
+
for (let attempt = 0; attempt < maxRetryCount; attempt++) {
|
|
214
|
+
const extractionStartTime = Date.now();
|
|
215
|
+
currentWireframeData = await extractWireframe(page, cloneElementGroups(elementGroups));
|
|
216
|
+
const extractionDuration = Date.now() - extractionStartTime;
|
|
217
|
+
logTimestamp(`Wireframe candidate captured after ${extractionDuration/1000} seconds.`)
|
|
218
|
+
|
|
219
|
+
if (previousWireframeData !== null &&
|
|
220
|
+
areWireframesStable(previousWireframeData, currentWireframeData)) {
|
|
221
|
+
logTimestamp(`Wireframe stabilized after ${attempt + 1} extraction(s)`);
|
|
222
|
+
return currentWireframeData;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
previousWireframeData = currentWireframeData;
|
|
226
|
+
|
|
227
|
+
if (attempt < maxRetryCount - 1) {
|
|
228
|
+
const remainingDelay = Math.max(0, retryDelay - extractionDuration);
|
|
229
|
+
if (remainingDelay > 0)
|
|
230
|
+
await delay(remainingDelay);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
logTimestamp(`Wireframe did not stabilize within ${maxRetryCount} extraction(s)`);
|
|
235
|
+
return currentWireframeData;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function expectWireframe(page, elementGroups, configName, outputFile, outputName, retryDelay = 1000, maxRetryCount = 10) {
|
|
239
|
+
logTimestamp(`Starting wireframe capture for: ${outputFile}`);
|
|
240
|
+
const wireframeData = await extractStableWireframe(page, elementGroups, retryDelay, maxRetryCount);
|
|
241
|
+
|
|
242
|
+
const userType = process.env.USER_TYPE;
|
|
243
|
+
const deviceType = process.env.DEVICE_TYPE;
|
|
244
|
+
const testType = process.env.TEST_TYPE;
|
|
245
|
+
|
|
246
|
+
const wireframeOutput = {
|
|
247
|
+
name: outputName,
|
|
248
|
+
timestamp: new Date().toISOString(),
|
|
249
|
+
deviceType: process.env.DEVICE_TYPE,
|
|
250
|
+
userType: process.env.USER_TYPE,
|
|
251
|
+
elementGroups: wireframeData
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const fileName = `${outputFile}.json`;
|
|
255
|
+
const filePath = path.join(process.cwd(), 'wireframes', 'test', testType, configName, deviceType, userType, fileName);
|
|
256
|
+
const fileDir = path.dirname(filePath);
|
|
257
|
+
fs.mkdirSync(fileDir, {recursive: true});
|
|
258
|
+
fs.writeFileSync(filePath, JSON.stringify(wireframeOutput, null, 2));
|
|
259
|
+
logTimestamp(`Wireframe captured and saved to: ${fileName}`);
|
|
260
|
+
|
|
261
|
+
const screenshotPath = path.join(fileDir, `${outputFile}.png`);
|
|
262
|
+
await page.screenshot({ path: screenshotPath });
|
|
263
|
+
logTimestamp(`Wireframe screenshot saved to: ${screenshotPath}`);
|
|
264
|
+
|
|
265
|
+
return {wireframeOutput, screenshotPath};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export { expectWireframe, getRGBHistogramFromBuffer };
|