flexysnap 0.1.2-alpha.3 → 0.1.3-alpha.2
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/package.json +3 -4
- package/src/annotateWireframe.js +49 -18
- package/src/wireframeUtils.js +63 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flexysnap",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3-alpha.2",
|
|
4
4
|
"description": "Flexible visual snapshot testing for Playwright, built for e-commerce.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"playwright",
|
|
@@ -44,10 +44,9 @@
|
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@playwright/test": "^1.61.1",
|
|
47
|
-
"canvas": "^3.2.3"
|
|
48
|
-
"sharp": "^0.35.3"
|
|
47
|
+
"canvas": "^3.2.3"
|
|
49
48
|
},
|
|
50
49
|
"devDependencies": {
|
|
51
50
|
"eslint": "^9.9.0"
|
|
52
51
|
}
|
|
53
|
-
}
|
|
52
|
+
}
|
package/src/annotateWireframe.js
CHANGED
|
@@ -1,7 +1,30 @@
|
|
|
1
|
-
import sharp from 'sharp';
|
|
2
1
|
import fs from 'fs';
|
|
3
2
|
import path from 'path';
|
|
4
|
-
import { createCanvas } from 'canvas';
|
|
3
|
+
import { createCanvas, loadImage } from 'canvas';
|
|
4
|
+
|
|
5
|
+
function applyGrayscale(ctx, width, height) {
|
|
6
|
+
const imageData = ctx.getImageData(0, 0, width, height);
|
|
7
|
+
const data = imageData.data;
|
|
8
|
+
|
|
9
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
10
|
+
const gray = Math.round(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]);
|
|
11
|
+
data[i] = gray;
|
|
12
|
+
data[i + 1] = gray;
|
|
13
|
+
data[i + 2] = gray;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
ctx.putImageData(imageData, 0, 0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getScrollOffset(wireframe) {
|
|
20
|
+
const extractionScrollPosition = wireframe.scrollPosition || { x: 0, y: 0 };
|
|
21
|
+
const screenshotScrollPosition = wireframe.screenshotScrollPosition || extractionScrollPosition;
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
offsetX: extractionScrollPosition.x - screenshotScrollPosition.x,
|
|
25
|
+
offsetY: extractionScrollPosition.y - screenshotScrollPosition.y
|
|
26
|
+
};
|
|
27
|
+
}
|
|
5
28
|
|
|
6
29
|
async function annotateWireframeFile(wireframeFile, screenshotFile) {
|
|
7
30
|
if (!fs.existsSync(wireframeFile)) {
|
|
@@ -17,13 +40,22 @@ async function annotateWireframeFile(wireframeFile, screenshotFile) {
|
|
|
17
40
|
const wireframeContent = fs.readFileSync(wireframeFile, 'utf-8');
|
|
18
41
|
const wireframe = JSON.parse(wireframeContent);
|
|
19
42
|
|
|
20
|
-
const
|
|
21
|
-
const width =
|
|
22
|
-
const height =
|
|
43
|
+
const screenshot = await loadImage(screenshotFile);
|
|
44
|
+
const width = screenshot.width;
|
|
45
|
+
const height = screenshot.height;
|
|
23
46
|
|
|
24
47
|
const canvas = createCanvas(width, height);
|
|
25
48
|
const ctx = canvas.getContext('2d');
|
|
26
49
|
|
|
50
|
+
ctx.drawImage(screenshot, 0, 0);
|
|
51
|
+
applyGrayscale(ctx, width, height);
|
|
52
|
+
|
|
53
|
+
const { offsetX, offsetY } = getScrollOffset(wireframe);
|
|
54
|
+
|
|
55
|
+
if (offsetX !== 0 || offsetY !== 0) {
|
|
56
|
+
console.log(`Compensating for scroll drift between extraction and screenshot: offsetX=${offsetX}, offsetY=${offsetY}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
27
59
|
const colors = {
|
|
28
60
|
box: { fill: 'rgba(220, 160, 40, 0.4)', stroke: '#E6A500' },
|
|
29
61
|
image: { fill: 'rgba(70, 180, 120, 0.4)', stroke: '#2EBC6F' },
|
|
@@ -36,16 +68,23 @@ async function annotateWireframeFile(wireframeFile, screenshotFile) {
|
|
|
36
68
|
}
|
|
37
69
|
|
|
38
70
|
function drawBoundingBox(rect, colorKey, dashed = false) {
|
|
39
|
-
const
|
|
40
|
-
|
|
71
|
+
const adjustedRect = {
|
|
72
|
+
left: rect.left + offsetX,
|
|
73
|
+
right: rect.right + offsetX,
|
|
74
|
+
top: rect.top + offsetY,
|
|
75
|
+
bottom: rect.bottom + offsetY
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const boxWidth = adjustedRect.right - adjustedRect.left;
|
|
79
|
+
const boxHeight = adjustedRect.bottom - adjustedRect.top;
|
|
41
80
|
const color = colors[colorKey];
|
|
42
81
|
|
|
43
82
|
ctx.fillStyle = color.fill;
|
|
44
|
-
ctx.fillRect(
|
|
83
|
+
ctx.fillRect(adjustedRect.left, adjustedRect.top, boxWidth, boxHeight);
|
|
45
84
|
ctx.strokeStyle = color.stroke;
|
|
46
85
|
ctx.lineWidth = 2;
|
|
47
86
|
ctx.setLineDash(dashed ? [6, 4] : []);
|
|
48
|
-
ctx.strokeRect(
|
|
87
|
+
ctx.strokeRect(adjustedRect.left, adjustedRect.top, boxWidth, boxHeight);
|
|
49
88
|
ctx.setLineDash([]);
|
|
50
89
|
}
|
|
51
90
|
|
|
@@ -70,19 +109,11 @@ async function annotateWireframeFile(wireframeFile, screenshotFile) {
|
|
|
70
109
|
}
|
|
71
110
|
}
|
|
72
111
|
|
|
73
|
-
const overlayBuffer = canvas.toBuffer('image/png');
|
|
74
|
-
|
|
75
112
|
const outputDir = path.dirname(screenshotFile);
|
|
76
113
|
const basename = path.basename(screenshotFile, '.png');
|
|
77
114
|
const outputPath = path.join(outputDir, `${basename}_wireframe.png`);
|
|
78
115
|
|
|
79
|
-
|
|
80
|
-
.grayscale()
|
|
81
|
-
.composite([{
|
|
82
|
-
input: overlayBuffer,
|
|
83
|
-
blend: 'over'
|
|
84
|
-
}])
|
|
85
|
-
.toFile(outputPath);
|
|
116
|
+
fs.writeFileSync(outputPath, canvas.toBuffer('image/png'));
|
|
86
117
|
|
|
87
118
|
console.log(`Annotated wireframe saved to: ${outputPath}`);
|
|
88
119
|
}
|
package/src/wireframeUtils.js
CHANGED
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { logTimestamp } from './testUtils.js';
|
|
4
|
-
import
|
|
4
|
+
import { loadImage, createCanvas } from 'canvas';
|
|
5
5
|
import { areWireframesStable } from './wireframeStability.js';
|
|
6
6
|
|
|
7
|
+
const FLEXYSNAP_ELEMENT_ID_ATTRIBUTE = 'data-flexysnap-id';
|
|
8
|
+
|
|
7
9
|
async function getRGBHistogramFromBuffer(buffer) {
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
const image = await loadImage(buffer);
|
|
11
|
+
const canvas = createCanvas(image.width, image.height);
|
|
12
|
+
const ctx = canvas.getContext('2d');
|
|
13
|
+
ctx.drawImage(image, 0, 0);
|
|
14
|
+
|
|
15
|
+
const { data } = ctx.getImageData(0, 0, image.width, image.height);
|
|
11
16
|
|
|
12
17
|
const histogram = new Array(48).fill(0);
|
|
13
|
-
const pixelCount =
|
|
18
|
+
const pixelCount = image.width * image.height;
|
|
19
|
+
|
|
20
|
+
if (pixelCount === 0) {
|
|
21
|
+
return histogram;
|
|
22
|
+
}
|
|
14
23
|
|
|
15
|
-
for (let i = 0; i < data.length; i +=
|
|
24
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
16
25
|
histogram[Math.floor(data[i] / 16)]++;
|
|
17
26
|
histogram[Math.floor(data[i + 1] / 16) + 16]++;
|
|
18
27
|
histogram[Math.floor(data[i + 2] / 16) + 32]++;
|
|
@@ -30,7 +39,7 @@ function delay(milliseconds) {
|
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
async function extractWireframe(page, elementGroups) {
|
|
33
|
-
const wireframeData = await page.evaluate(async ({elementGroups}) => {
|
|
42
|
+
const wireframeData = await page.evaluate(async ({elementGroups, elementIdAttribute}) => {
|
|
34
43
|
|
|
35
44
|
function createBoundingRect(element) {
|
|
36
45
|
const rect = element.getBoundingClientRect();
|
|
@@ -73,7 +82,8 @@ async function extractWireframe(page, elementGroups) {
|
|
|
73
82
|
}
|
|
74
83
|
|
|
75
84
|
|
|
76
|
-
for (
|
|
85
|
+
for (let groupIndex = 0; groupIndex < elementGroups.length; groupIndex++) {
|
|
86
|
+
const elementGroup = elementGroups[groupIndex];
|
|
77
87
|
elementGroup.strictPosition = elementGroup.strictPosition !== false;
|
|
78
88
|
elementGroup.elements = [];
|
|
79
89
|
|
|
@@ -152,18 +162,26 @@ async function extractWireframe(page, elementGroups) {
|
|
|
152
162
|
}
|
|
153
163
|
}
|
|
154
164
|
|
|
155
|
-
|
|
165
|
+
const elementData = {
|
|
156
166
|
index: index,
|
|
157
167
|
boundingRect: createBoundingRect(element),
|
|
158
168
|
texts: texts,
|
|
159
169
|
type: elementGroup.type
|
|
160
|
-
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
if (elementGroup.type === 'image') {
|
|
173
|
+
const elementId = `fsnap-g${groupIndex}-e${index}`;
|
|
174
|
+
element.setAttribute(elementIdAttribute, elementId);
|
|
175
|
+
elementData.elementId = elementId;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
elementGroup.elements.push(elementData);
|
|
161
179
|
index += 1;
|
|
162
180
|
}
|
|
163
181
|
}
|
|
164
182
|
|
|
165
183
|
return elementGroups;
|
|
166
|
-
}, {elementGroups});
|
|
184
|
+
}, {elementGroups, elementIdAttribute: FLEXYSNAP_ELEMENT_ID_ATTRIBUTE});
|
|
167
185
|
|
|
168
186
|
const scrollPosition = await page.evaluate(() => ({
|
|
169
187
|
x: window.scrollX,
|
|
@@ -172,8 +190,8 @@ async function extractWireframe(page, elementGroups) {
|
|
|
172
190
|
|
|
173
191
|
for (const elementGroup of wireframeData) {
|
|
174
192
|
for (const element of elementGroup.elements) {
|
|
175
|
-
if (element.type === 'image') {
|
|
176
|
-
const locator = page.locator(
|
|
193
|
+
if (element.type === 'image' && element.elementId) {
|
|
194
|
+
const locator = page.locator(`[${FLEXYSNAP_ELEMENT_ID_ATTRIBUTE}="${element.elementId}"]`);
|
|
177
195
|
await locator.waitFor({state: 'visible'});
|
|
178
196
|
|
|
179
197
|
try {
|
|
@@ -192,14 +210,28 @@ async function extractWireframe(page, elementGroups) {
|
|
|
192
210
|
} catch {
|
|
193
211
|
element.type = 'box'
|
|
194
212
|
}
|
|
213
|
+
|
|
214
|
+
delete element.elementId;
|
|
195
215
|
}
|
|
196
216
|
}
|
|
197
217
|
}
|
|
198
218
|
|
|
199
|
-
await page.evaluate(({scrollPosition}) => {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
219
|
+
await page.evaluate(({scrollPosition, elementIdAttribute}) => {
|
|
220
|
+
for (const el of document.querySelectorAll(`[${elementIdAttribute}]`)) {
|
|
221
|
+
el.removeAttribute(elementIdAttribute);
|
|
222
|
+
}
|
|
223
|
+
window.scrollTo({top: scrollPosition.y, left: scrollPosition.x, behavior: 'instant'});
|
|
224
|
+
}, {scrollPosition, elementIdAttribute: FLEXYSNAP_ELEMENT_ID_ATTRIBUTE});
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
await page.waitForFunction((expectedScrollPosition) => {
|
|
228
|
+
return window.scrollX === expectedScrollPosition.x && window.scrollY === expectedScrollPosition.y;
|
|
229
|
+
}, scrollPosition, {timeout: 2000});
|
|
230
|
+
} catch {
|
|
231
|
+
logTimestamp('Scroll position did not settle back to the extraction position.');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {wireframeData, scrollPosition};
|
|
203
235
|
}
|
|
204
236
|
|
|
205
237
|
function cloneElementGroups(elementGroups) {
|
|
@@ -209,17 +241,20 @@ function cloneElementGroups(elementGroups) {
|
|
|
209
241
|
async function extractStableWireframe(page, elementGroups, retryDelay, maxRetryCount) {
|
|
210
242
|
let previousWireframeData = null;
|
|
211
243
|
let currentWireframeData = null;
|
|
244
|
+
let currentScrollPosition = null;
|
|
212
245
|
|
|
213
246
|
for (let attempt = 0; attempt < maxRetryCount; attempt++) {
|
|
214
247
|
const extractionStartTime = Date.now();
|
|
215
|
-
|
|
248
|
+
const extractionResult = await extractWireframe(page, cloneElementGroups(elementGroups));
|
|
249
|
+
currentWireframeData = extractionResult.wireframeData;
|
|
250
|
+
currentScrollPosition = extractionResult.scrollPosition;
|
|
216
251
|
const extractionDuration = Date.now() - extractionStartTime;
|
|
217
252
|
logTimestamp(`Wireframe candidate captured after ${extractionDuration/1000} seconds.`)
|
|
218
253
|
|
|
219
254
|
if (previousWireframeData !== null &&
|
|
220
255
|
areWireframesStable(previousWireframeData, currentWireframeData)) {
|
|
221
256
|
logTimestamp(`Wireframe stabilized after ${attempt + 1} extraction(s)`);
|
|
222
|
-
return currentWireframeData;
|
|
257
|
+
return {wireframeData: currentWireframeData, scrollPosition: currentScrollPosition};
|
|
223
258
|
}
|
|
224
259
|
|
|
225
260
|
previousWireframeData = currentWireframeData;
|
|
@@ -232,22 +267,29 @@ async function extractStableWireframe(page, elementGroups, retryDelay, maxRetryC
|
|
|
232
267
|
}
|
|
233
268
|
|
|
234
269
|
logTimestamp(`Wireframe did not stabilize within ${maxRetryCount} extraction(s)`);
|
|
235
|
-
return currentWireframeData;
|
|
270
|
+
return {wireframeData: currentWireframeData, scrollPosition: currentScrollPosition};
|
|
236
271
|
}
|
|
237
272
|
|
|
238
273
|
async function expectWireframe(page, elementGroups, configName, outputFile, outputName, retryDelay = 1000, maxRetryCount = 10) {
|
|
239
274
|
logTimestamp(`Starting wireframe capture for: ${outputFile}`);
|
|
240
|
-
const wireframeData = await extractStableWireframe(page, elementGroups, retryDelay, maxRetryCount);
|
|
275
|
+
const { wireframeData, scrollPosition } = await extractStableWireframe(page, elementGroups, retryDelay, maxRetryCount);
|
|
241
276
|
|
|
242
277
|
const userType = process.env.USER_TYPE;
|
|
243
278
|
const deviceType = process.env.DEVICE_TYPE;
|
|
244
279
|
const testType = process.env.TEST_TYPE;
|
|
245
280
|
|
|
281
|
+
const screenshotScrollPosition = await page.evaluate(() => ({
|
|
282
|
+
x: window.scrollX,
|
|
283
|
+
y: window.scrollY
|
|
284
|
+
}));
|
|
285
|
+
|
|
246
286
|
const wireframeOutput = {
|
|
247
287
|
name: outputName,
|
|
248
288
|
timestamp: new Date().toISOString(),
|
|
249
289
|
deviceType: process.env.DEVICE_TYPE,
|
|
250
290
|
userType: process.env.USER_TYPE,
|
|
291
|
+
scrollPosition,
|
|
292
|
+
screenshotScrollPosition,
|
|
251
293
|
elementGroups: wireframeData
|
|
252
294
|
};
|
|
253
295
|
|