@matware/e2e-runner 1.3.0 → 1.3.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/.claude-plugin/marketplace.json +37 -6
- package/.claude-plugin/plugin.json +17 -3
- package/LICENSE +190 -0
- package/README.md +61 -526
- package/bin/cli.js +5 -4
- package/commands/capture.md +45 -0
- package/package.json +1 -1
- package/src/actions.js +151 -0
- package/src/ai-generate.js +81 -0
- package/src/app-pool.js +339 -0
- package/src/config.js +125 -7
- package/src/dashboard.js +75 -8
- package/src/db.js +63 -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 +251 -32
- package/src/narrate.js +28 -0
- package/src/pool-manager.js +22 -16
- package/src/pool.js +301 -31
- package/src/reporter.js +4 -1
- package/src/runner.js +335 -55
- package/src/visual-diff.js +446 -0
- package/templates/dashboard/js/api.js +2 -0
- package/templates/dashboard/js/utils.js +20 -0
- package/templates/dashboard/js/view-live.js +40 -2
- package/templates/dashboard/js/view-runs.js +161 -57
- package/templates/dashboard/js/websocket.js +6 -0
- package/templates/dashboard/styles/components.css +7 -0
- package/templates/dashboard/styles/view-live.css +24 -1
- package/templates/dashboard/styles/view-runs.css +36 -0
- package/templates/dashboard/template.html +24 -9
- package/templates/dashboard.html +322 -310
|
@@ -0,0 +1,446 @@
|
|
|
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
|
+
}
|
|
@@ -6,6 +6,8 @@ function triggerRun(suite,projectId){
|
|
|
6
6
|
if(suite)body.suite=suite;
|
|
7
7
|
if(projectId)body.projectId=projectId;
|
|
8
8
|
else if(S.project)body.projectId=S.project;
|
|
9
|
+
var scToggle=$('#screencastToggle');
|
|
10
|
+
if(scToggle&&scToggle.checked)body.screencast=true;
|
|
9
11
|
fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
10
12
|
}
|
|
11
13
|
|
|
@@ -162,6 +162,26 @@ function createTriggerBadge(source){
|
|
|
162
162
|
return badge;
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
function createDriverBadge(driver){
|
|
166
|
+
if(!driver)return document.createTextNode('--');
|
|
167
|
+
var labels={browserless:'Browserless',cdp:'CDP',steel:'Steel',auto:'Auto'};
|
|
168
|
+
var colors={browserless:'var(--accent)',cdp:'var(--purple)',steel:'var(--amber)'};
|
|
169
|
+
var icons={browserless:'\u{1F310}',cdp:'\u{1F50C}',steel:'\u{1F6E1}'};
|
|
170
|
+
// Handle multi-driver (e.g. "browserless,steel")
|
|
171
|
+
var parts=driver.split(',');
|
|
172
|
+
if(parts.length>1){
|
|
173
|
+
var wrap=el('span',{style:'display:inline-flex;gap:4px'});
|
|
174
|
+
parts.forEach(function(d){wrap.appendChild(createDriverBadge(d.trim()))});
|
|
175
|
+
return wrap;
|
|
176
|
+
}
|
|
177
|
+
var d=driver.trim();
|
|
178
|
+
var badge=el('span',{className:'driver-badge drv-'+d,style:'color:'+(colors[d]||'var(--text3)')},[
|
|
179
|
+
el('span',{className:'drv-icon'},icons[d]||'\u2699'),
|
|
180
|
+
document.createTextNode(labels[d]||d)
|
|
181
|
+
]);
|
|
182
|
+
return badge;
|
|
183
|
+
}
|
|
184
|
+
|
|
165
185
|
/* ── Pool Distribution Summary ── */
|
|
166
186
|
var POOL_COLORS=['#6366f1','#22d3ee','#f59e0b','#10b981','#ef4444','#8b5cf6','#ec4899','#14b8a6'];
|
|
167
187
|
function buildPoolDistribution(tests){
|
|
@@ -1,10 +1,41 @@
|
|
|
1
1
|
/* ══════════════════════════════════════════════════════════════════
|
|
2
2
|
Live Execution View
|
|
3
3
|
══════════════════════════════════════════════════════════════════ */
|
|
4
|
-
function clearFinishedLiveRuns(){for(var k in S.liveRuns){if(S.liveRuns[k].done||!S.liveRuns[k].on)delete S.liveRuns[k]}renderLive()}
|
|
4
|
+
function clearFinishedLiveRuns(){for(var k in S.liveRuns){if(S.liveRuns[k].done||!S.liveRuns[k].on)delete S.liveRuns[k]}S.screencastTest=null;renderLive()}
|
|
5
5
|
function dismissLiveRun(rid){delete S.liveRuns[rid];renderLive()}
|
|
6
6
|
$('#liveClearBtn').addEventListener('click',clearFinishedLiveRuns);
|
|
7
7
|
|
|
8
|
+
// Screencast state
|
|
9
|
+
S.screencastTest=null;
|
|
10
|
+
|
|
11
|
+
$('#screencastSelect').addEventListener('change',function(){
|
|
12
|
+
S.screencastTest=this.value||null;
|
|
13
|
+
var img=$('#screencastImg'),ph=$('#screencastPlaceholder');
|
|
14
|
+
if(S.screencastTest){img.style.display='block';ph.style.display='none';img.src=''}
|
|
15
|
+
else{img.style.display='none';ph.style.display='flex'}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function updateScreencastSelect(){
|
|
19
|
+
var sel=$('#screencastSelect'),panel=$('#screencastPanel');
|
|
20
|
+
var runningTests=[];
|
|
21
|
+
for(var k in S.liveRuns){var r=S.liveRuns[k];for(var n in r.tests){if(n!=='__error'&&r.tests[n].status==='running')runningTests.push(n)}}
|
|
22
|
+
// Show panel if any run is active
|
|
23
|
+
var anyActive=false;for(var k2 in S.liveRuns)if(S.liveRuns[k2].on)anyActive=true;
|
|
24
|
+
panel.style.display=anyActive?'':'none';
|
|
25
|
+
// Rebuild options
|
|
26
|
+
var prev=sel.value;
|
|
27
|
+
while(sel.options.length>1)sel.remove(1);
|
|
28
|
+
runningTests.forEach(function(n){var o=document.createElement('option');o.value=n;o.textContent=n;sel.appendChild(o)});
|
|
29
|
+
// Auto-select first running test if nothing selected
|
|
30
|
+
if(!S.screencastTest&&runningTests.length>0){S.screencastTest=runningTests[0];sel.value=S.screencastTest;$('#screencastImg').style.display='block';$('#screencastPlaceholder').style.display='none'}
|
|
31
|
+
else if(S.screencastTest&&runningTests.indexOf(S.screencastTest)===-1){
|
|
32
|
+
// Current test finished — pick next running or clear
|
|
33
|
+
if(runningTests.length>0){S.screencastTest=runningTests[0];sel.value=S.screencastTest}
|
|
34
|
+
else{S.screencastTest=null;sel.value='';$('#screencastImg').style.display='none';$('#screencastPlaceholder').style.display='flex';$('#screencastPlaceholder').textContent='No running tests'}
|
|
35
|
+
}
|
|
36
|
+
else{sel.value=S.screencastTest||''}
|
|
37
|
+
}
|
|
38
|
+
|
|
8
39
|
function renderLive(){
|
|
9
40
|
var panel=$('#livePanel'),grid=$('#liveTests'),navLive=$('#navLive'),liveEmpty=$('#liveEmpty');
|
|
10
41
|
var runs=S.liveRuns;var runIds=Object.keys(runs);
|
|
@@ -115,12 +146,18 @@ function renderLive(){
|
|
|
115
146
|
ssEl=el('div',{className:'lt-screenshots'},[toggle,ssGridEl]);
|
|
116
147
|
}
|
|
117
148
|
|
|
149
|
+
// Screencast focus indicator
|
|
150
|
+
var scFocusBadge=null;
|
|
151
|
+
if(t.status==='running'){
|
|
152
|
+
var isFocused=S.screencastTest===name;
|
|
153
|
+
scFocusBadge=el('span',{className:'sc-focus-badge'+(isFocused?' active':''),title:'Watch this test',onclick:function(e){e.stopPropagation();S.screencastTest=name;$('#screencastSelect').value=name;$('#screencastImg').style.display='block';$('#screencastPlaceholder').style.display='none';renderLive()}},'\uD83C\uDFA5');
|
|
154
|
+
}
|
|
118
155
|
var serialBadge=t.serial?el('span',{className:'serial-badge'},'Serial'):null;
|
|
119
156
|
var poolBadge=t.poolUrl?el('span',{className:'pool-badge'},t.poolUrl.replace('ws://','').replace('wss://','')):null;
|
|
120
157
|
var card=el('div',{className:'live-test '+t.status+(isCollapsed?' collapsed':'')},[
|
|
121
158
|
el('div',{className:'lt-name'},[
|
|
122
159
|
t.status==='running'?el('span',{className:'spinner'}):el('span',{className:'lt-icon',style:iconColor},iconText),
|
|
123
|
-
document.createTextNode(' '+name),serialBadge,poolBadge,summaryEl
|
|
160
|
+
document.createTextNode(' '+name),scFocusBadge,serialBadge,poolBadge,summaryEl
|
|
124
161
|
]),
|
|
125
162
|
el('div',{className:'lt-meta'},meta),stepsEl
|
|
126
163
|
]);
|
|
@@ -140,4 +177,5 @@ function renderLive(){
|
|
|
140
177
|
});
|
|
141
178
|
grid.appendChild(testGrid);
|
|
142
179
|
});
|
|
180
|
+
updateScreencastSelect();
|
|
143
181
|
}
|