@matware/e2e-runner 1.3.0 → 1.5.0
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/.claude-plugin/marketplace.json +37 -6
- package/.claude-plugin/plugin.json +17 -3
- package/LICENSE +190 -0
- package/README.md +151 -527
- package/agents/test-creator.md +4 -2
- package/agents/test-improver.md +5 -3
- package/bin/cli.js +84 -20
- package/commands/capture.md +45 -0
- package/package.json +3 -2
- package/skills/e2e-testing/SKILL.md +3 -2
- package/skills/e2e-testing/references/action-types.md +22 -4
- package/skills/e2e-testing/references/test-json-format.md +23 -0
- package/src/actions.js +321 -14
- package/src/ai-generate.js +81 -0
- package/src/app-pool.js +339 -0
- package/src/config.js +131 -7
- package/src/dashboard.js +209 -11
- package/src/db.js +74 -7
- package/src/index.js +6 -4
- package/src/learner-sqlite.js +154 -0
- package/src/learner.js +70 -3
- package/src/mcp-tools.js +259 -34
- package/src/module-analysis.js +247 -0
- package/src/module-resolver.js +35 -2
- package/src/narrate.js +42 -1
- package/src/pool-manager.js +68 -17
- package/src/pool.js +464 -37
- package/src/reporter.js +4 -1
- package/src/runner.js +410 -63
- package/src/visual-diff.js +515 -0
- package/src/websocket.js +14 -3
- package/src/wizard.js +184 -0
- package/templates/build-dashboard.js +3 -0
- package/templates/dashboard/js/api.js +62 -3
- package/templates/dashboard/js/init.js +46 -0
- package/templates/dashboard/js/keyboard.js +8 -7
- package/templates/dashboard/js/quicksearch.js +277 -0
- package/templates/dashboard/js/state.js +61 -7
- package/templates/dashboard/js/toast.js +1 -1
- package/templates/dashboard/js/utils.js +20 -0
- package/templates/dashboard/js/view-live.js +240 -9
- package/templates/dashboard/js/view-runs.js +540 -94
- package/templates/dashboard/js/view-tests.js +157 -16
- package/templates/dashboard/js/view-tools.js +234 -0
- package/templates/dashboard/js/view-watch.js +2 -2
- package/templates/dashboard/js/websocket.js +36 -0
- package/templates/dashboard/styles/base.css +489 -53
- package/templates/dashboard/styles/components.css +719 -77
- package/templates/dashboard/styles/view-live.css +463 -59
- package/templates/dashboard/styles/view-runs.css +793 -155
- package/templates/dashboard/styles/view-tests.css +440 -77
- package/templates/dashboard/styles/view-tools.css +206 -0
- package/templates/dashboard/styles/view-watch.css +198 -41
- package/templates/dashboard/template.html +369 -56
- package/templates/dashboard.html +5375 -901
- package/templates/docker-compose-lightpanda.yml +7 -0
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visual Diff Engine — zero-dependency pixel comparison for PNG screenshots.
|
|
3
|
+
*
|
|
4
|
+
* Uses Node's built-in zlib for PNG inflate/deflate. No external image libs.
|
|
5
|
+
*
|
|
6
|
+
* Comparison algorithm:
|
|
7
|
+
* 1. Decode both PNGs to raw RGBA buffers
|
|
8
|
+
* 2. For each pixel, compute YIQ perceptual color distance
|
|
9
|
+
* 3. Pixels exceeding the threshold are marked as different
|
|
10
|
+
* 4. Anti-aliasing pixels (edge pixels) can be optionally ignored
|
|
11
|
+
* 5. Masked regions are skipped entirely
|
|
12
|
+
* 6. Output: diff stats + optional diff PNG (red overlay on changed pixels)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'fs';
|
|
16
|
+
import zlib from 'zlib';
|
|
17
|
+
|
|
18
|
+
// ── PNG Decoder ───────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
21
|
+
|
|
22
|
+
function readChunks(buf) {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
let offset = 8; // skip signature
|
|
25
|
+
while (offset < buf.length) {
|
|
26
|
+
const length = buf.readUInt32BE(offset);
|
|
27
|
+
const type = buf.toString('ascii', offset + 4, offset + 8);
|
|
28
|
+
const data = buf.subarray(offset + 8, offset + 8 + length);
|
|
29
|
+
chunks.push({ type, data });
|
|
30
|
+
offset += 12 + length; // 4 length + 4 type + data + 4 crc
|
|
31
|
+
}
|
|
32
|
+
return chunks;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function decodePNG(filePath) {
|
|
36
|
+
const buf = fs.readFileSync(filePath);
|
|
37
|
+
|
|
38
|
+
if (buf.compare(PNG_SIGNATURE, 0, 8, 0, 8) !== 0) {
|
|
39
|
+
throw new Error(`Not a valid PNG file: ${filePath}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const chunks = readChunks(buf);
|
|
43
|
+
const ihdr = chunks.find(c => c.type === 'IHDR');
|
|
44
|
+
if (!ihdr) throw new Error('Missing IHDR chunk');
|
|
45
|
+
|
|
46
|
+
const width = ihdr.data.readUInt32BE(0);
|
|
47
|
+
const height = ihdr.data.readUInt32BE(4);
|
|
48
|
+
const bitDepth = ihdr.data[8];
|
|
49
|
+
const colorType = ihdr.data[9];
|
|
50
|
+
|
|
51
|
+
if (bitDepth !== 8) throw new Error(`Unsupported bit depth: ${bitDepth} (only 8-bit supported)`);
|
|
52
|
+
if (colorType !== 2 && colorType !== 6) {
|
|
53
|
+
throw new Error(`Unsupported color type: ${colorType} (only RGB=2 and RGBA=6 supported)`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const hasAlpha = colorType === 6;
|
|
57
|
+
const bpp = hasAlpha ? 4 : 3; // bytes per pixel in the raw data
|
|
58
|
+
|
|
59
|
+
// Concatenate all IDAT chunks and decompress
|
|
60
|
+
const idatData = Buffer.concat(chunks.filter(c => c.type === 'IDAT').map(c => c.data));
|
|
61
|
+
const inflated = zlib.inflateSync(idatData);
|
|
62
|
+
|
|
63
|
+
// Reverse scanline filters → raw RGBA
|
|
64
|
+
const stride = width * bpp + 1; // +1 for filter byte
|
|
65
|
+
const rgba = Buffer.alloc(width * height * 4);
|
|
66
|
+
|
|
67
|
+
// Previous row for filter operations (starts as zeros)
|
|
68
|
+
const prevRow = Buffer.alloc(width * bpp);
|
|
69
|
+
|
|
70
|
+
for (let y = 0; y < height; y++) {
|
|
71
|
+
const filterType = inflated[y * stride];
|
|
72
|
+
const rowStart = y * stride + 1;
|
|
73
|
+
const row = Buffer.alloc(width * bpp);
|
|
74
|
+
|
|
75
|
+
for (let x = 0; x < width * bpp; x++) {
|
|
76
|
+
const raw = inflated[rowStart + x];
|
|
77
|
+
const a = x >= bpp ? row[x - bpp] : 0; // left
|
|
78
|
+
const b = prevRow[x]; // above
|
|
79
|
+
const c = x >= bpp ? prevRow[x - bpp] : 0; // upper-left
|
|
80
|
+
|
|
81
|
+
switch (filterType) {
|
|
82
|
+
case 0: row[x] = raw; break; // None
|
|
83
|
+
case 1: row[x] = (raw + a) & 0xff; break; // Sub
|
|
84
|
+
case 2: row[x] = (raw + b) & 0xff; break; // Up
|
|
85
|
+
case 3: row[x] = (raw + ((a + b) >> 1)) & 0xff; break; // Average
|
|
86
|
+
case 4: row[x] = (raw + paethPredictor(a, b, c)) & 0xff; break; // Paeth
|
|
87
|
+
default: throw new Error(`Unknown PNG filter type: ${filterType}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Copy to RGBA output
|
|
92
|
+
for (let x = 0; x < width; x++) {
|
|
93
|
+
const srcIdx = x * bpp;
|
|
94
|
+
const dstIdx = (y * width + x) * 4;
|
|
95
|
+
rgba[dstIdx] = row[srcIdx]; // R
|
|
96
|
+
rgba[dstIdx + 1] = row[srcIdx + 1]; // G
|
|
97
|
+
rgba[dstIdx + 2] = row[srcIdx + 2]; // B
|
|
98
|
+
rgba[dstIdx + 3] = hasAlpha ? row[srcIdx + 3] : 255; // A
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
row.copy(prevRow);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { width, height, data: rgba };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function paethPredictor(a, b, c) {
|
|
108
|
+
const p = a + b - c;
|
|
109
|
+
const pa = Math.abs(p - a);
|
|
110
|
+
const pb = Math.abs(p - b);
|
|
111
|
+
const pc = Math.abs(p - c);
|
|
112
|
+
if (pa <= pb && pa <= pc) return a;
|
|
113
|
+
if (pb <= pc) return b;
|
|
114
|
+
return c;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── PNG Encoder (minimal, for diff images) ────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
function encodePNG(width, height, rgba) {
|
|
120
|
+
// Build unfiltered scanlines (filter byte 0 = None for each row)
|
|
121
|
+
const rawLines = Buffer.alloc(height * (1 + width * 4));
|
|
122
|
+
for (let y = 0; y < height; y++) {
|
|
123
|
+
rawLines[y * (1 + width * 4)] = 0; // filter: None
|
|
124
|
+
rgba.copy(rawLines, y * (1 + width * 4) + 1, y * width * 4, (y + 1) * width * 4);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const compressed = zlib.deflateSync(rawLines);
|
|
128
|
+
|
|
129
|
+
// Build PNG file
|
|
130
|
+
const chunks = [];
|
|
131
|
+
|
|
132
|
+
// Signature
|
|
133
|
+
chunks.push(PNG_SIGNATURE);
|
|
134
|
+
|
|
135
|
+
// IHDR
|
|
136
|
+
const ihdr = Buffer.alloc(13);
|
|
137
|
+
ihdr.writeUInt32BE(width, 0);
|
|
138
|
+
ihdr.writeUInt32BE(height, 4);
|
|
139
|
+
ihdr[8] = 8; // bit depth
|
|
140
|
+
ihdr[9] = 6; // color type: RGBA
|
|
141
|
+
ihdr[10] = 0; // compression
|
|
142
|
+
ihdr[11] = 0; // filter
|
|
143
|
+
ihdr[12] = 0; // interlace
|
|
144
|
+
chunks.push(makeChunk('IHDR', ihdr));
|
|
145
|
+
|
|
146
|
+
// IDAT
|
|
147
|
+
chunks.push(makeChunk('IDAT', compressed));
|
|
148
|
+
|
|
149
|
+
// IEND
|
|
150
|
+
chunks.push(makeChunk('IEND', Buffer.alloc(0)));
|
|
151
|
+
|
|
152
|
+
return Buffer.concat(chunks);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function makeChunk(type, data) {
|
|
156
|
+
const buf = Buffer.alloc(12 + data.length);
|
|
157
|
+
buf.writeUInt32BE(data.length, 0);
|
|
158
|
+
buf.write(type, 4, 4, 'ascii');
|
|
159
|
+
data.copy(buf, 8);
|
|
160
|
+
buf.writeUInt32BE(crc32(buf.subarray(4, 8 + data.length)), 8 + data.length);
|
|
161
|
+
return buf;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// CRC-32 (PNG uses CRC-32/ISO-HDLC)
|
|
165
|
+
const crcTable = (() => {
|
|
166
|
+
const table = new Uint32Array(256);
|
|
167
|
+
for (let n = 0; n < 256; n++) {
|
|
168
|
+
let c = n;
|
|
169
|
+
for (let k = 0; k < 8; k++) {
|
|
170
|
+
c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
|
171
|
+
}
|
|
172
|
+
table[n] = c;
|
|
173
|
+
}
|
|
174
|
+
return table;
|
|
175
|
+
})();
|
|
176
|
+
|
|
177
|
+
function crc32(buf) {
|
|
178
|
+
let crc = 0xffffffff;
|
|
179
|
+
for (let i = 0; i < buf.length; i++) {
|
|
180
|
+
crc = crcTable[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
|
|
181
|
+
}
|
|
182
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Pixel Comparison ──────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Computes perceptual color distance using YIQ color space.
|
|
189
|
+
* YIQ weights human perception: luminance matters most, chrominance less.
|
|
190
|
+
*/
|
|
191
|
+
function colorDelta(img1, img2, idx1, idx2) {
|
|
192
|
+
const r1 = img1[idx1], g1 = img1[idx1 + 1], b1 = img1[idx1 + 2], a1 = img1[idx1 + 3];
|
|
193
|
+
const r2 = img2[idx2], g2 = img2[idx2 + 1], b2 = img2[idx2 + 2], a2 = img2[idx2 + 3];
|
|
194
|
+
|
|
195
|
+
// Blend alpha to white background for fair comparison
|
|
196
|
+
const rb1 = blend(r1, a1), gb1 = blend(g1, a1), bb1 = blend(b1, a1);
|
|
197
|
+
const rb2 = blend(r2, a2), gb2 = blend(g2, a2), bb2 = blend(b2, a2);
|
|
198
|
+
|
|
199
|
+
const y = rgb2y(rb1, gb1, bb1) - rgb2y(rb2, gb2, bb2);
|
|
200
|
+
const i = rgb2i(rb1, gb1, bb1) - rgb2i(rb2, gb2, bb2);
|
|
201
|
+
const q = rgb2q(rb1, gb1, bb1) - rgb2q(rb2, gb2, bb2);
|
|
202
|
+
|
|
203
|
+
// Weight: Y (luminance) is most important, I and Q (chrominance) less
|
|
204
|
+
return 0.5053 * y * y + 0.299 * i * i + 0.1957 * q * q;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function blend(c, a) {
|
|
208
|
+
return 255 + (c - 255) * (a / 255);
|
|
209
|
+
}
|
|
210
|
+
function rgb2y(r, g, b) { return r * 0.29889531 + g * 0.58662247 + b * 0.11448223; }
|
|
211
|
+
function rgb2i(r, g, b) { return r * 0.59597799 - g * 0.27417610 - b * 0.32180189; }
|
|
212
|
+
function rgb2q(r, g, b) { return r * 0.21147017 - g * 0.52261711 + b * 0.31114694; }
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Checks if a pixel is likely an anti-aliased edge pixel.
|
|
216
|
+
* Looks at immediate neighbors — if few are similar and many differ, it's an edge.
|
|
217
|
+
*/
|
|
218
|
+
function isAntiAliased(img, x, y, width, height, otherImg) {
|
|
219
|
+
const idx = (y * width + x) * 4;
|
|
220
|
+
let similar = 0;
|
|
221
|
+
let different = 0;
|
|
222
|
+
|
|
223
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
224
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
225
|
+
if (dx === 0 && dy === 0) continue;
|
|
226
|
+
const nx = x + dx;
|
|
227
|
+
const ny = y + dy;
|
|
228
|
+
if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue;
|
|
229
|
+
const nIdx = (ny * width + nx) * 4;
|
|
230
|
+
const delta = colorDelta(img, img, idx, nIdx);
|
|
231
|
+
if (delta < 15) similar++;
|
|
232
|
+
else different++;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Anti-aliased: pixel differs from most neighbors (it's on an edge)
|
|
237
|
+
// AND the corresponding pixel in the other image is also on an edge
|
|
238
|
+
if (different < 3) return false;
|
|
239
|
+
|
|
240
|
+
const otherIdx = idx;
|
|
241
|
+
let otherSimilar = 0;
|
|
242
|
+
let otherDifferent = 0;
|
|
243
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
244
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
245
|
+
if (dx === 0 && dy === 0) continue;
|
|
246
|
+
const nx = x + dx;
|
|
247
|
+
const ny = y + dy;
|
|
248
|
+
if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue;
|
|
249
|
+
const nIdx = (ny * width + nx) * 4;
|
|
250
|
+
const delta = colorDelta(otherImg, otherImg, otherIdx, nIdx);
|
|
251
|
+
if (delta < 15) otherSimilar++;
|
|
252
|
+
else otherDifferent++;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return otherDifferent >= 3;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Compares two PNG images and returns diff statistics + optional diff image.
|
|
263
|
+
*
|
|
264
|
+
* @param {string} baselinePath — Path to the "golden" reference PNG
|
|
265
|
+
* @param {string} currentPath — Path to the current/new screenshot PNG
|
|
266
|
+
* @param {object} [options] — Comparison options
|
|
267
|
+
* @param {number} [options.threshold=0.1] — Color distance threshold (0-1). Lower = stricter.
|
|
268
|
+
* @param {boolean} [options.includeAntiAlias=false] — Count anti-aliased pixels as different
|
|
269
|
+
* @param {Array} [options.maskRegions=[]] — Regions to ignore: [{ x, y, width, height }]
|
|
270
|
+
* @param {string} [options.diffOutputPath] — If set, writes a diff PNG highlighting changes
|
|
271
|
+
* @param {object} [options.diffColor] — Diff highlight color: { r, g, b } (default: red)
|
|
272
|
+
* @returns {{ totalPixels, differentPixels, diffPercentage, matchPercentage, dimensions, passed, diffImagePath? }}
|
|
273
|
+
*/
|
|
274
|
+
export function compareImages(baselinePath, currentPath, options = {}) {
|
|
275
|
+
const {
|
|
276
|
+
threshold = 0.1,
|
|
277
|
+
includeAntiAlias = false,
|
|
278
|
+
maskRegions = [],
|
|
279
|
+
diffOutputPath = null,
|
|
280
|
+
diffColor = { r: 255, g: 0, b: 60 },
|
|
281
|
+
} = options;
|
|
282
|
+
|
|
283
|
+
const baseline = decodePNG(baselinePath);
|
|
284
|
+
const current = decodePNG(currentPath);
|
|
285
|
+
|
|
286
|
+
// Images must have the same dimensions for pixel comparison
|
|
287
|
+
if (baseline.width !== current.width || baseline.height !== current.height) {
|
|
288
|
+
return {
|
|
289
|
+
totalPixels: baseline.width * baseline.height,
|
|
290
|
+
differentPixels: baseline.width * baseline.height,
|
|
291
|
+
diffPercentage: 1,
|
|
292
|
+
matchPercentage: 0,
|
|
293
|
+
dimensions: {
|
|
294
|
+
baseline: { width: baseline.width, height: baseline.height },
|
|
295
|
+
current: { width: current.width, height: current.height },
|
|
296
|
+
},
|
|
297
|
+
dimensionMismatch: true,
|
|
298
|
+
passed: false,
|
|
299
|
+
diffImagePath: null,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const { width, height } = baseline;
|
|
304
|
+
const totalPixels = width * height;
|
|
305
|
+
const maxDelta = 35215 * threshold * threshold; // max YIQ distance scaled by threshold
|
|
306
|
+
let differentPixels = 0;
|
|
307
|
+
|
|
308
|
+
// Build mask lookup for O(1) checks
|
|
309
|
+
const isMasked = buildMaskLookup(maskRegions, width, height);
|
|
310
|
+
|
|
311
|
+
// Diff image buffer (semi-transparent overlay of original with red diff pixels)
|
|
312
|
+
const diffData = diffOutputPath ? Buffer.alloc(width * height * 4) : null;
|
|
313
|
+
|
|
314
|
+
for (let y = 0; y < height; y++) {
|
|
315
|
+
for (let x = 0; x < width; x++) {
|
|
316
|
+
const idx = (y * width + x) * 4;
|
|
317
|
+
|
|
318
|
+
// Skip masked regions
|
|
319
|
+
if (isMasked(x, y)) {
|
|
320
|
+
if (diffData) {
|
|
321
|
+
// Show masked areas as semi-transparent gray
|
|
322
|
+
diffData[idx] = 128;
|
|
323
|
+
diffData[idx + 1] = 128;
|
|
324
|
+
diffData[idx + 2] = 128;
|
|
325
|
+
diffData[idx + 3] = 80;
|
|
326
|
+
}
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const delta = colorDelta(baseline.data, current.data, idx, idx);
|
|
331
|
+
|
|
332
|
+
if (delta > maxDelta) {
|
|
333
|
+
// Check anti-aliasing
|
|
334
|
+
if (!includeAntiAlias &&
|
|
335
|
+
(isAntiAliased(baseline.data, x, y, width, height, current.data) ||
|
|
336
|
+
isAntiAliased(current.data, x, y, width, height, baseline.data))) {
|
|
337
|
+
// Anti-aliased pixel — show as faded yellow in diff
|
|
338
|
+
if (diffData) {
|
|
339
|
+
diffData[idx] = 255;
|
|
340
|
+
diffData[idx + 1] = 255;
|
|
341
|
+
diffData[idx + 2] = 0;
|
|
342
|
+
diffData[idx + 3] = 40;
|
|
343
|
+
}
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
differentPixels++;
|
|
348
|
+
if (diffData) {
|
|
349
|
+
// Highlight difference in diff color
|
|
350
|
+
diffData[idx] = diffColor.r;
|
|
351
|
+
diffData[idx + 1] = diffColor.g;
|
|
352
|
+
diffData[idx + 2] = diffColor.b;
|
|
353
|
+
diffData[idx + 3] = 220;
|
|
354
|
+
}
|
|
355
|
+
} else if (diffData) {
|
|
356
|
+
// Matching pixel — show dimmed version of current image
|
|
357
|
+
diffData[idx] = current.data[idx];
|
|
358
|
+
diffData[idx + 1] = current.data[idx + 1];
|
|
359
|
+
diffData[idx + 2] = current.data[idx + 2];
|
|
360
|
+
diffData[idx + 3] = 60; // very transparent
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const diffPercentage = totalPixels > 0 ? differentPixels / totalPixels : 0;
|
|
366
|
+
let diffImagePath = null;
|
|
367
|
+
|
|
368
|
+
if (diffData && diffOutputPath) {
|
|
369
|
+
const pngBuf = encodePNG(width, height, diffData);
|
|
370
|
+
fs.writeFileSync(diffOutputPath, pngBuf);
|
|
371
|
+
diffImagePath = diffOutputPath;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
totalPixels,
|
|
376
|
+
differentPixels,
|
|
377
|
+
diffPercentage,
|
|
378
|
+
matchPercentage: 1 - diffPercentage,
|
|
379
|
+
dimensions: { width, height },
|
|
380
|
+
passed: diffPercentage === 0,
|
|
381
|
+
diffImagePath,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Compares two images with a pass/fail threshold.
|
|
387
|
+
*
|
|
388
|
+
* @param {string} baselinePath — Path to golden reference
|
|
389
|
+
* @param {string} currentPath — Path to current screenshot
|
|
390
|
+
* @param {number} [maxDiffPercent=0.02] — Max allowed diff percentage (0.02 = 2%)
|
|
391
|
+
* @param {object} [options] — Same options as compareImages()
|
|
392
|
+
* @returns {{ passed, diffPercentage, differentPixels, totalPixels, diffImagePath?, error? }}
|
|
393
|
+
*/
|
|
394
|
+
export function assertVisualMatch(baselinePath, currentPath, maxDiffPercent = 0.02, options = {}) {
|
|
395
|
+
if (!fs.existsSync(baselinePath)) {
|
|
396
|
+
return { passed: false, error: `Baseline not found: ${baselinePath}`, diffPercentage: 1 };
|
|
397
|
+
}
|
|
398
|
+
if (!fs.existsSync(currentPath)) {
|
|
399
|
+
return { passed: false, error: `Current screenshot not found: ${currentPath}`, diffPercentage: 1 };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const result = compareImages(baselinePath, currentPath, options);
|
|
403
|
+
result.passed = result.diffPercentage <= maxDiffPercent;
|
|
404
|
+
result.maxDiffPercent = maxDiffPercent;
|
|
405
|
+
return result;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Builds a fast mask lookup function from region definitions.
|
|
410
|
+
* @param {Array<{x,y,width,height}>} regions
|
|
411
|
+
* @param {number} imgWidth
|
|
412
|
+
* @param {number} imgHeight
|
|
413
|
+
* @returns {(x: number, y: number) => boolean}
|
|
414
|
+
*/
|
|
415
|
+
function buildMaskLookup(regions, imgWidth, imgHeight) {
|
|
416
|
+
if (!regions || regions.length === 0) return () => false;
|
|
417
|
+
|
|
418
|
+
// For small numbers of regions, direct check is fast enough
|
|
419
|
+
if (regions.length <= 4) {
|
|
420
|
+
return (x, y) => {
|
|
421
|
+
for (const r of regions) {
|
|
422
|
+
if (x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height) return true;
|
|
423
|
+
}
|
|
424
|
+
return false;
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// For many regions, build a bitfield
|
|
429
|
+
const mask = new Uint8Array(Math.ceil(imgWidth * imgHeight / 8));
|
|
430
|
+
for (const r of regions) {
|
|
431
|
+
const x0 = Math.max(0, r.x);
|
|
432
|
+
const y0 = Math.max(0, r.y);
|
|
433
|
+
const x1 = Math.min(imgWidth, r.x + r.width);
|
|
434
|
+
const y1 = Math.min(imgHeight, r.y + r.height);
|
|
435
|
+
for (let y = y0; y < y1; y++) {
|
|
436
|
+
for (let x = x0; x < x1; x++) {
|
|
437
|
+
const bit = y * imgWidth + x;
|
|
438
|
+
mask[bit >> 3] |= (1 << (bit & 7));
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return (x, y) => {
|
|
443
|
+
const bit = y * imgWidth + x;
|
|
444
|
+
return (mask[bit >> 3] & (1 << (bit & 7))) !== 0;
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ── Blank screenshot detection ─────────────────────────────────────────────
|
|
449
|
+
/**
|
|
450
|
+
* Detects whether a PNG screenshot is "completely blank" — i.e. a single
|
|
451
|
+
* uniform fill color (a white/empty page, a solid error frame, etc.).
|
|
452
|
+
*
|
|
453
|
+
* Strategy: decode to RGBA, sample pixels evenly (capped for speed), compute
|
|
454
|
+
* the mean color, then count how many sampled pixels deviate from that mean by
|
|
455
|
+
* more than `tolerance` on any channel. An image is blank when the fraction of
|
|
456
|
+
* deviating pixels stays at/under `maxOutlierFraction` — this tolerates a few
|
|
457
|
+
* stray pixels (a cursor, a 1px border) while still requiring a near-uniform
|
|
458
|
+
* frame. Non-PNG or undecodable files are reported as not-blank so they are
|
|
459
|
+
* never deleted by mistake.
|
|
460
|
+
*
|
|
461
|
+
* @param {string} filePath
|
|
462
|
+
* @param {{tolerance?:number, maxOutlierFraction?:number, maxSamples?:number}} [opts]
|
|
463
|
+
* @returns {{blank:boolean, color?:{r:number,g:number,b:number}, brightness?:number,
|
|
464
|
+
* width?:number, height?:number, outlierFraction?:number, error?:string}}
|
|
465
|
+
*/
|
|
466
|
+
export function isBlankImage(filePath, opts = {}) {
|
|
467
|
+
const tolerance = opts.tolerance ?? 10;
|
|
468
|
+
const maxOutlierFraction = opts.maxOutlierFraction ?? 0.005; // ≤0.5% off-color pixels
|
|
469
|
+
const maxSamples = opts.maxSamples ?? 120000;
|
|
470
|
+
|
|
471
|
+
let img;
|
|
472
|
+
try {
|
|
473
|
+
img = decodePNG(filePath);
|
|
474
|
+
} catch (error) {
|
|
475
|
+
return { blank: false, error: error.message };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const { width, height, data } = img;
|
|
479
|
+
const totalPixels = width * height;
|
|
480
|
+
if (totalPixels === 0) return { blank: false, width, height };
|
|
481
|
+
|
|
482
|
+
// Even sampling stride so huge captures stay fast without missing regions.
|
|
483
|
+
const step = Math.max(1, Math.floor(totalPixels / maxSamples));
|
|
484
|
+
|
|
485
|
+
let sumR = 0, sumG = 0, sumB = 0, n = 0;
|
|
486
|
+
for (let p = 0; p < totalPixels; p += step) {
|
|
487
|
+
const i = p * 4;
|
|
488
|
+
sumR += data[i]; sumG += data[i + 1]; sumB += data[i + 2];
|
|
489
|
+
n++;
|
|
490
|
+
}
|
|
491
|
+
const meanR = sumR / n, meanG = sumG / n, meanB = sumB / n;
|
|
492
|
+
|
|
493
|
+
let outliers = 0;
|
|
494
|
+
for (let p = 0; p < totalPixels; p += step) {
|
|
495
|
+
const i = p * 4;
|
|
496
|
+
if (Math.abs(data[i] - meanR) > tolerance ||
|
|
497
|
+
Math.abs(data[i + 1] - meanG) > tolerance ||
|
|
498
|
+
Math.abs(data[i + 2] - meanB) > tolerance) {
|
|
499
|
+
outliers++;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const outlierFraction = outliers / n;
|
|
504
|
+
const color = { r: Math.round(meanR), g: Math.round(meanG), b: Math.round(meanB) };
|
|
505
|
+
const brightness = Math.round((meanR + meanG + meanB) / 3);
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
blank: outlierFraction <= maxOutlierFraction,
|
|
509
|
+
color,
|
|
510
|
+
brightness,
|
|
511
|
+
width,
|
|
512
|
+
height,
|
|
513
|
+
outlierFraction: Math.round(outlierFraction * 1e4) / 1e4,
|
|
514
|
+
};
|
|
515
|
+
}
|
package/src/websocket.js
CHANGED
|
@@ -81,10 +81,21 @@ export function createWebSocketServer(httpServer, options = {}) {
|
|
|
81
81
|
const clients = new Set();
|
|
82
82
|
|
|
83
83
|
httpServer.on('upgrade', (req, socket, head) => {
|
|
84
|
-
// Validate Origin to prevent cross-site WebSocket hijacking
|
|
84
|
+
// Validate Origin to prevent cross-site WebSocket hijacking.
|
|
85
|
+
// Allow if: no Origin (curl/scripts), explicit whitelist match, or same-origin
|
|
86
|
+
// (Origin's host == the Host header the client connected to).
|
|
85
87
|
const origin = req.headers.origin;
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
const host = req.headers.host;
|
|
89
|
+
if (origin) {
|
|
90
|
+
let allowed = false;
|
|
91
|
+
if (options.allowedOrigins && options.allowedOrigins.includes(origin)) allowed = true;
|
|
92
|
+
if (!allowed && host) {
|
|
93
|
+
try {
|
|
94
|
+
const u = new URL(origin);
|
|
95
|
+
if (u.host === host) allowed = true;
|
|
96
|
+
} catch { /* malformed origin */ }
|
|
97
|
+
}
|
|
98
|
+
if (!allowed) {
|
|
88
99
|
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
89
100
|
socket.destroy();
|
|
90
101
|
return;
|