flexysnap 0.1.0 → 0.1.2-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
@@ -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
- - **Rich HTML reports** — generate an interactive report with baseline/current image sliders, annotated wireframe overlays, and categorized differences (text, image, layout).
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/wireframeUtils.js';
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
- ## Comparing against a baseline
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
- ```bash
101
- TEST_TYPE=smoke TEST_CONFIG=production DEVICE_TYPE=mobile USER_TYPE=guest \
102
- npx playwright test compare-wireframes.spec.js
131
+ ```
132
+ wireframes/test/<TEST_TYPE>/<configName>/<DEVICE_TYPE>/<USER_TYPE>/
103
133
  ```
104
134
 
105
- It reads from:
135
+ where `configName` is passed directly to `expectWireframe`.
106
136
 
107
- ```
108
- wireframes/baseline/<TEST_TYPE>/<TEST_CONFIG>/<DEVICE_TYPE>/<USER_TYPE>/
109
- wireframes/test/<TEST_TYPE>/<TEST_CONFIG>/<DEVICE_TYPE>/<USER_TYPE>/
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
- Each matched wireframe is diffed and re-written with annotated `differences`, then every difference is asserted as a soft failure so the whole page is reported at once.
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
- ## Annotating screenshots
160
+ ## CLI
128
161
 
129
- Overlay the captured wireframe onto its screenshot to visualize what was checked and what differed:
162
+ `flexysnap` ships a small CLI:
130
163
 
131
164
  ```bash
132
- # annotate a single pair
133
- node annotateWireframe.mjs wireframe.json screenshot.png
165
+ # print usage
166
+ flexysnap
134
167
 
135
- # annotate every matching .json/.png pair in a folder
136
- node annotateWireframe.mjs ./wireframes/test/smoke/production/mobile/guest
137
- ```
168
+ # regenerate baseline snapshots (not implemented yet)
169
+ flexysnap update
138
170
 
139
- 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 as `<name>_wireframe.png`.
171
+ # annotate a single wireframe/screenshot pair
172
+ flexysnap annotate wireframe.json screenshot.png
140
173
 
141
- ## Generating an HTML report
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
- The report includes:
178
+ ### Annotating screenshots
157
179
 
158
- - Summary statistics (elements checked, total / text / image / layout differences) with click-to-jump navigation.
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
- - [ ] Plugin-change tracking integrated into the HTML report
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.0",
3
+ "version": "0.1.2-alpha.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": "./src/cli.js"
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
- switch (command) {
13
- case 'update':
14
- console.log('flexysnap: baseline update not implemented yet.');
15
- break;
16
- default:
17
- console.log('flexysnap');
18
- console.log('');
19
- console.log('Usage:');
20
- console.log(' flexysnap update Regenerate baseline snapshots');
21
- break;
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
- * flexysnap — flexible visual snapshot testing for Playwright.
3
- *
4
- * This is a placeholder entry point. Replace with the real
5
- * snapshot-comparison implementation.
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
- * Capture and compare a snapshot for the given Playwright page.
10
- *
11
- * @param {import('@playwright/test').Page} page
12
- * @param {string} name - snapshot identifier, used for the stored baseline file
13
- * @param {object} [options]
14
- * @param {number} [options.tolerance=0.02] - allowed pixel-drift ratio (0-1)
15
- * @param {Record<string, 'strict'|'presence'|'ignore'>} [options.regions] - per-selector comparison policy
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
- // TODO: implement capture + diff logic (sharp/canvas-based comparison).
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 default { expectSnapshot };
24
+ export { expectWireframe, getRGBHistogramFromBuffer } from './wireframeUtils.js';
@@ -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.js';
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 };