@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.
Files changed (56) hide show
  1. package/.claude-plugin/marketplace.json +37 -6
  2. package/.claude-plugin/plugin.json +17 -3
  3. package/LICENSE +190 -0
  4. package/README.md +151 -527
  5. package/agents/test-creator.md +4 -2
  6. package/agents/test-improver.md +5 -3
  7. package/bin/cli.js +84 -20
  8. package/commands/capture.md +45 -0
  9. package/package.json +3 -2
  10. package/skills/e2e-testing/SKILL.md +3 -2
  11. package/skills/e2e-testing/references/action-types.md +22 -4
  12. package/skills/e2e-testing/references/test-json-format.md +23 -0
  13. package/src/actions.js +321 -14
  14. package/src/ai-generate.js +81 -0
  15. package/src/app-pool.js +339 -0
  16. package/src/config.js +131 -7
  17. package/src/dashboard.js +209 -11
  18. package/src/db.js +74 -7
  19. package/src/index.js +6 -4
  20. package/src/learner-sqlite.js +154 -0
  21. package/src/learner.js +70 -3
  22. package/src/mcp-tools.js +259 -34
  23. package/src/module-analysis.js +247 -0
  24. package/src/module-resolver.js +35 -2
  25. package/src/narrate.js +42 -1
  26. package/src/pool-manager.js +68 -17
  27. package/src/pool.js +464 -37
  28. package/src/reporter.js +4 -1
  29. package/src/runner.js +410 -63
  30. package/src/visual-diff.js +515 -0
  31. package/src/websocket.js +14 -3
  32. package/src/wizard.js +184 -0
  33. package/templates/build-dashboard.js +3 -0
  34. package/templates/dashboard/js/api.js +62 -3
  35. package/templates/dashboard/js/init.js +46 -0
  36. package/templates/dashboard/js/keyboard.js +8 -7
  37. package/templates/dashboard/js/quicksearch.js +277 -0
  38. package/templates/dashboard/js/state.js +61 -7
  39. package/templates/dashboard/js/toast.js +1 -1
  40. package/templates/dashboard/js/utils.js +20 -0
  41. package/templates/dashboard/js/view-live.js +240 -9
  42. package/templates/dashboard/js/view-runs.js +540 -94
  43. package/templates/dashboard/js/view-tests.js +157 -16
  44. package/templates/dashboard/js/view-tools.js +234 -0
  45. package/templates/dashboard/js/view-watch.js +2 -2
  46. package/templates/dashboard/js/websocket.js +36 -0
  47. package/templates/dashboard/styles/base.css +489 -53
  48. package/templates/dashboard/styles/components.css +719 -77
  49. package/templates/dashboard/styles/view-live.css +463 -59
  50. package/templates/dashboard/styles/view-runs.css +793 -155
  51. package/templates/dashboard/styles/view-tests.css +440 -77
  52. package/templates/dashboard/styles/view-tools.css +206 -0
  53. package/templates/dashboard/styles/view-watch.css +198 -41
  54. package/templates/dashboard/template.html +369 -56
  55. package/templates/dashboard.html +5375 -901
  56. 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
- if (origin && options.allowedOrigins) {
87
- if (!options.allowedOrigins.includes(origin)) {
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;