@bughunters/vision 1.0.4 → 1.0.5

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
@@ -130,6 +130,27 @@ The report includes side-by-side Baseline / Current / Diff views with full-scree
130
130
 
131
131
  ---
132
132
 
133
+ ## Terminal output
134
+
135
+ BugHunters Vision keeps terminal output compact and linear — one line per step, no duplicates:
136
+
137
+ ```
138
+ šŸ“ø [BugHunters Vision] Baseline created: cart-page
139
+ āœ… [BugHunters Vision] FAST PIXEL MATCH: shipping-form
140
+ šŸ” [BugHunters Vision] AI evaluating: payment-page (12 pixel(s) differ)…
141
+ šŸ¤– [BugHunters Vision] AI PASS: payment-page
142
+ āš ļø [BugHunters Vision] AI error: header — Claude AI is temporarily overloaded.
143
+ ā­ [BugHunters Vision] off: footer
144
+ ```
145
+
146
+ When a check fails, a single clear error is thrown (no repeated messages):
147
+
148
+ ```
149
+ Error: [BugHunters Vision] Visual regression detected in step "payment-page": Button label changed from "Pay now" to "Subscribe".
150
+ ```
151
+
152
+ ---
153
+
133
154
  ## IDE & VS Code integration
134
155
 
135
156
  BugHunters Vision is fully compatible with the **VS Code Playwright extension** and **Playwright UI mode**.
@@ -1 +1 @@
1
- {"version":3,"file":"check.d.ts","sourceRoot":"","sources":["../src/check.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,OAAO,EAAY,MAAM,kBAAkB,CAAC;AAOhE,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,IAAI,GAAG,OAAO,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAKD,wBAAgB,uBAAuB,IAAI,MAAM,GAAG,IAAI,CAEvD;AAoHD,wBAAsB,cAAc,CAAC,IAAI,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CA4J/E"}
1
+ {"version":3,"file":"check.d.ts","sourceRoot":"","sources":["../src/check.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,OAAO,EAAY,MAAM,kBAAkB,CAAC;AAOhE,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,IAAI,GAAG,OAAO,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAKD,wBAAgB,uBAAuB,IAAI,MAAM,GAAG,IAAI,CAEvD;AAoHD,wBAAsB,cAAc,CAAC,IAAI,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAyJ/E"}
package/dist/check.js CHANGED
@@ -156,7 +156,7 @@ async function runVisionCheck(opts) {
156
156
  const token = projectUse['bvToken'] ?? process.env.BUGHUNTERS_VISION_TOKEN ?? undefined;
157
157
  // Mode: off → skip all visual testing immediately
158
158
  if (mode === 'off') {
159
- console.log(`\nā­ [BugHunters Vision] "${name}" — skipped (BHV_MODE=off)\n`);
159
+ console.log(`ā­ [BugHunters Vision] off: ${name}`);
160
160
  return;
161
161
  }
162
162
  const resolvedDir = path.resolve(process.cwd(), snapshotsDir);
@@ -173,8 +173,7 @@ async function runVisionCheck(opts) {
173
173
  if (!fs.existsSync(baselinePath) || updateBaseline) {
174
174
  // First run — save as baseline
175
175
  fs.writeFileSync(baselinePath, currentBuffer);
176
- console.log(`\nšŸ“ø [BugHunters Vision] Baseline saved: ${baselinePath}`);
177
- console.log(' āœ… Test PASSED (baseline created — no previous reference exists)\n');
176
+ console.log(`šŸ“ø [BugHunters Vision] Baseline created: ${name}`);
178
177
  (0, results_1.appendResult)(resolvedDir, {
179
178
  testName,
180
179
  status: 'BASELINE_CREATED',
@@ -194,8 +193,7 @@ async function runVisionCheck(opts) {
194
193
  fs.writeFileSync(path.join(resolvedDir, currentFileName), currentBuffer);
195
194
  if (diffPixels === 0) {
196
195
  // āœ… Pixel-perfect match — skip AI call entirely
197
- console.log(`\n⚔ [BugHunters Vision] "${name}" — pixel-perfect match. Skipping AI call.`);
198
- console.log(' āœ… Test PASSED (Fast Pixel Match)\n');
196
+ console.log(`āœ… [BugHunters Vision] FAST PIXEL MATCH: ${name}`);
199
197
  (0, results_1.appendResult)(resolvedDir, {
200
198
  testName,
201
199
  status: 'PASS',
@@ -212,7 +210,6 @@ async function runVisionCheck(opts) {
212
210
  const diffLabel = diffPixels === -1 ? 'dimension mismatch' : `${diffPixels} pixel(s) differ`;
213
211
  if (mode === 'strict') {
214
212
  // strict: fail immediately without calling AI
215
- console.log(`\nāŒ [BugHunters Vision] "${name}" — ${diffLabel}. FAILED (strict mode — no AI call).\n`);
216
213
  (0, results_1.appendResult)(resolvedDir, {
217
214
  testName,
218
215
  status: 'FAIL',
@@ -223,18 +220,17 @@ async function runVisionCheck(opts) {
223
220
  timestamp: new Date().toISOString(),
224
221
  });
225
222
  await pushToPlaywright(testInfo, name, 'FAIL', `Pixel mismatch detected (${diffLabel}). Strict mode.`, resolvedDir, `${filePrefix}.baseline.png`, currentFileName);
226
- (0, test_1.expect)(false, `[BugHunters Vision strict] Pixel mismatch: ${diffLabel}`).toBe(true);
223
+ (0, test_1.expect)(false, `[BugHunters Vision] Visual regression detected in step "${name}": ${diffLabel} (strict mode)`).toBe(true);
227
224
  return;
228
225
  }
229
226
  // ai mode — escalate to AI for intelligent evaluation
230
- console.log(`\nšŸ” [BugHunters Vision] "${name}" — ${diffLabel}. Sending to AI…`);
227
+ console.log(`šŸ” [BugHunters Vision] AI evaluating: ${name} (${diffLabel})…`);
231
228
  let result;
232
229
  try {
233
230
  result = await callVisionApi(baselineBuffer.toString('base64'), currentBuffer.toString('base64'), prompt, apiUrl, token);
234
231
  }
235
232
  catch (err) {
236
233
  const reason = humanReadableApiError(err instanceof Error ? err.message : String(err));
237
- console.log(`\nāŒ [BugHunters Vision] ${reason}\n`);
238
234
  (0, results_1.appendResult)(resolvedDir, {
239
235
  testName,
240
236
  status: 'FAIL',
@@ -245,11 +241,11 @@ async function runVisionCheck(opts) {
245
241
  timestamp: new Date().toISOString(),
246
242
  });
247
243
  await pushToPlaywright(testInfo, name, 'FAIL', reason, resolvedDir, `${filePrefix}.baseline.png`, currentFileName);
248
- throw err;
244
+ throw new Error(`[BugHunters Vision] Visual regression detected in step "${name}": ${reason}`);
249
245
  }
250
246
  // ERROR: AI temporarily unavailable — record but do NOT fail the Playwright test
251
247
  if (result.status === 'ERROR') {
252
- console.log(`\nāš ļø [BugHunters Vision] "${name}" — AI unavailable: ${result.reason}\n`);
248
+ console.log(`āš ļø [BugHunters Vision] AI error: ${name} — ${result.reason}`);
253
249
  (0, results_1.appendResult)(resolvedDir, {
254
250
  testName,
255
251
  status: 'ERROR',
@@ -262,8 +258,6 @@ async function runVisionCheck(opts) {
262
258
  await pushToPlaywright(testInfo, name, 'ERROR', result.reason, resolvedDir, `${filePrefix}.baseline.png`, currentFileName);
263
259
  return; // test stays green
264
260
  }
265
- const icon = result.status === 'PASS' ? 'āœ…' : 'āŒ';
266
- console.log(`${icon} [BugHunters Vision] ${result.status}: ${result.reason}\n`);
267
261
  (0, results_1.appendResult)(resolvedDir, {
268
262
  testName,
269
263
  status: result.status,
@@ -274,5 +268,10 @@ async function runVisionCheck(opts) {
274
268
  timestamp: new Date().toISOString(),
275
269
  });
276
270
  await pushToPlaywright(testInfo, name, result.status, result.reason, resolvedDir, `${filePrefix}.baseline.png`, currentFileName);
277
- (0, test_1.expect)(result.status, `Visual regression detected: ${result.reason}`).toBe('PASS');
271
+ if (result.status === 'PASS') {
272
+ console.log(`šŸ¤– [BugHunters Vision] AI PASS: ${name}`);
273
+ }
274
+ else {
275
+ throw new Error(`[BugHunters Vision] Visual regression detected in step "${name}": ${result.reason}`);
276
+ }
278
277
  }
@@ -1 +1 @@
1
- {"version":3,"file":"reporter.d.ts","sourceRoot":"","sources":["../src/reporter.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AAI1D,MAAM,WAAW,+BAA+B;IAC9C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AA87BD,cAAM,wBAAyB,YAAW,QAAQ;IAChD,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,SAAS,CAAS;gBAEd,OAAO,CAAC,EAAE,+BAA+B;IAKrD,aAAa,IAAI,OAAO;IAIxB,OAAO,IAAI,IAAI;IAKf,KAAK,IAAI,IAAI;CA8Cd;AAED,eAAe,wBAAwB,CAAC;AACxC,OAAO,EAAE,wBAAwB,EAAE,CAAC"}
1
+ {"version":3,"file":"reporter.d.ts","sourceRoot":"","sources":["../src/reporter.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AAI1D,MAAM,WAAW,+BAA+B;IAC9C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AA4hCD,cAAM,wBAAyB,YAAW,QAAQ;IAChD,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,SAAS,CAAS;gBAEd,OAAO,CAAC,EAAE,+BAA+B;IAKrD,aAAa,IAAI,OAAO;IAIxB,OAAO,IAAI,IAAI;IAKf,KAAK,IAAI,IAAI;CAkDd;AAED,eAAe,wBAAwB,CAAC;AACxC,OAAO,EAAE,wBAAwB,EAAE,CAAC"}
package/dist/reporter.js CHANGED
@@ -37,6 +37,36 @@ exports.BugHuntersVisionReporter = void 0;
37
37
  const fs = __importStar(require("fs"));
38
38
  const path = __importStar(require("path"));
39
39
  const check_1 = require("./check");
40
+ // ─── Group flat step results by Playwright test title ─────────────────────────
41
+ function groupResults(results) {
42
+ const order = [];
43
+ const map = new Map();
44
+ for (const r of results) {
45
+ const sep = r.testName.indexOf(' › ');
46
+ const title = sep >= 0 ? r.testName.slice(0, sep) : r.testName;
47
+ if (!map.has(title)) {
48
+ map.set(title, []);
49
+ order.push(title);
50
+ }
51
+ map.get(title).push(r);
52
+ }
53
+ return order.map(testTitle => {
54
+ const steps = map.get(testTitle);
55
+ const hasFail = steps.some(s => s.status === 'FAIL');
56
+ const hasError = steps.some(s => s.status === 'ERROR');
57
+ const allBase = steps.every(s => s.status === 'BASELINE_CREATED');
58
+ const status = hasFail ? 'FAIL' : hasError ? 'ERROR' : allBase ? 'BASELINE_CREATED' : 'PASS';
59
+ return {
60
+ testTitle,
61
+ status,
62
+ passedSteps: steps.filter(s => s.status === 'PASS').length,
63
+ failedSteps: steps.filter(s => s.status === 'FAIL').length,
64
+ erroredSteps: steps.filter(s => s.status === 'ERROR').length,
65
+ baselineSteps: steps.filter(s => s.status === 'BASELINE_CREATED').length,
66
+ steps,
67
+ };
68
+ });
69
+ }
40
70
  // ─── CSS ─────────────────────────────────────────────────────────────────────
41
71
  function getCss() {
42
72
  return `
@@ -192,27 +222,36 @@ body {
192
222
 
193
223
  /* ── Test list ── */
194
224
  .test-list { display: flex; flex-direction: column; gap: 6px; padding-bottom: 48px; }
195
- .test-item { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow-sm); }
196
- .test-item.s-fail { border-left: 3px solid var(--fail); }
197
- .test-item.s-base { border-left: 3px solid var(--base); }
198
- .test-item.s-warn { border-left: 3px solid var(--pixel); }
199
225
 
200
- .test-row {
226
+ /* ── Test group — one Playwright test ── */
227
+ .test-group {
228
+ background: var(--surface); border: 1px solid var(--border);
229
+ border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow-sm);
230
+ }
231
+ .test-group.s-fail { border-left: 3px solid var(--fail); }
232
+ .test-group.s-base { border-left: 3px solid var(--base); }
233
+ .test-group.s-warn { border-left: 3px solid var(--pixel); }
234
+
235
+ /* ── Group header row (clickable, expands step list) ── */
236
+ .group-row {
201
237
  display: flex; align-items: center; gap: 11px;
202
238
  padding: 12px 14px; cursor: pointer;
203
239
  transition: background .1s; user-select: none;
204
240
  }
205
- .test-row:hover { background: var(--surface-2); }
241
+ .group-row:hover { background: var(--surface-2); }
206
242
 
243
+ /* ── Status dot ── */
207
244
  .s-dot {
208
245
  width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
209
246
  margin-top: 1px;
210
247
  }
248
+ .s-dot-sm { width: 6px; height: 6px; margin-top: 0; }
211
249
  .s-pass .s-dot { background: var(--pass); }
212
250
  .s-fail .s-dot { background: var(--fail); }
213
251
  .s-base .s-dot { background: var(--base); }
214
252
  .s-warn .s-dot { background: var(--pixel); }
215
253
 
254
+ /* ── Group text ── */
216
255
  .test-meta { flex: 1; min-width: 0; }
217
256
  .test-name {
218
257
  font-family: ui-monospace, 'SFMono-Regular', 'Fira Code', monospace;
@@ -224,6 +263,7 @@ body {
224
263
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
225
264
  }
226
265
 
266
+ /* ── Badges ── */
227
267
  .badges { display: flex; gap: 5px; align-items: center; flex-shrink: 0; }
228
268
  .badge {
229
269
  font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 4px;
@@ -240,9 +280,35 @@ body {
240
280
  .chev { font-size: 16px; color: var(--text-3); flex-shrink: 0; line-height: 1; transition: transform .2s; display: inline-block; }
241
281
  .chev.open { transform: rotate(90deg); }
242
282
 
243
- /* ── Detail panel ── */
244
- .test-detail { border-top: 1px solid var(--border); padding: 18px 14px 18px 33px; display: none; }
283
+ /* ── Step list (expands when group is open) ── */
284
+ .group-steps { display: none; }
285
+
286
+ /* ── Individual step ── */
287
+ .step-item { border-top: 1px solid var(--border); }
288
+
289
+ /* ── Step header row (clickable, expands detail) ── */
290
+ .step-row {
291
+ display: flex; align-items: center; gap: 9px;
292
+ padding: 9px 14px 9px 34px; cursor: pointer;
293
+ transition: background .1s; user-select: none;
294
+ }
295
+ .step-row:hover { background: var(--surface-2); }
245
296
 
297
+ /* ── Step text ── */
298
+ .step-meta { flex: 1; min-width: 0; }
299
+ .step-name {
300
+ font-family: ui-monospace, 'SFMono-Regular', 'Fira Code', monospace;
301
+ font-size: 12px; font-weight: 500; color: var(--text-2);
302
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
303
+ }
304
+
305
+ /* ── Step detail (verdict + images) ── */
306
+ .step-detail {
307
+ display: none; border-top: 1px solid var(--border);
308
+ padding: 16px 14px 16px 34px;
309
+ }
310
+
311
+ /* ── Verdict box ── */
246
312
  .verdict {
247
313
  background: var(--accent-soft); border-left: 3px solid var(--accent);
248
314
  border-radius: 0 6px 6px 0; padding: 12px 14px; margin-bottom: 18px;
@@ -267,9 +333,7 @@ body {
267
333
  .view-tab.diff-tab.on { color: var(--fail); }
268
334
 
269
335
  /* ── Image views ── */
270
- .img-section { }
271
336
  .img-lbl { font-size: 10px; text-transform: uppercase; letter-spacing: 1.2px; color: var(--text-3); font-weight: 500; margin-bottom: 12px; }
272
- .img-view { }
273
337
  .view-img {
274
338
  width: 100%; display: block; border-radius: 6px; border: 1px solid var(--border);
275
339
  max-height: 360px; object-fit: contain; object-position: top center;
@@ -380,8 +444,8 @@ function getJs() {
380
444
  (function () {
381
445
  'use strict';
382
446
 
383
- var RESULTS = JSON.parse(document.getElementById('bv-r').textContent);
384
- var META = JSON.parse(document.getElementById('bv-m').textContent);
447
+ var GROUPS = JSON.parse(document.getElementById('bv-g').textContent);
448
+ var META = JSON.parse(document.getElementById('bv-m').textContent);
385
449
 
386
450
  /* ── State ── */
387
451
  var state = {
@@ -423,23 +487,19 @@ function getJs() {
423
487
 
424
488
  /* ── Pixel diff engine (100% client-side, zero server calls) ── */
425
489
 
426
- /* Weighted squared RGB distance — perceptually accurate, fast (no sqrt needed) */
427
490
  function colorDist(r1, g1, b1, r2, g2, b2) {
428
491
  var rd = r1 - r2, gd = g1 - g2, bd = b1 - b2;
429
492
  return rd * rd * 0.299 + gd * gd * 0.587 + bd * bd * 0.114;
430
493
  }
431
494
 
432
- /* Render diff image: changed pixels = neon red, unchanged = desaturated baseline */
433
495
  function renderDiff(d1, d2, w, h) {
434
496
  var n = w * h * 4;
435
497
  var out = new Uint8ClampedArray(n);
436
- var thr = 900; /* squared perceptual threshold */
498
+ var thr = 900;
437
499
  for (var i = 0; i < n; i += 4) {
438
500
  if (colorDist(d1[i], d1[i+1], d1[i+2], d2[i], d2[i+1], d2[i+2]) > thr) {
439
- /* neon red highlight */
440
501
  out[i] = 220; out[i+1] = 38; out[i+2] = 38; out[i+3] = 255;
441
502
  } else {
442
- /* desaturated baseline — guides the eye to red pixels */
443
503
  var lum = (d1[i] * 77 + d1[i+1] * 150 + d1[i+2] * 29) >> 8;
444
504
  out[i] = lum; out[i+1] = lum; out[i+2] = lum; out[i+3] = 210;
445
505
  }
@@ -447,7 +507,6 @@ function getJs() {
447
507
  return new ImageData(out, w, h);
448
508
  }
449
509
 
450
- /* Load image and extract raw pixel data via canvas */
451
510
  function loadImgPixels(url, cb) {
452
511
  var img = new Image();
453
512
  img.crossOrigin = 'anonymous';
@@ -469,7 +528,6 @@ function getJs() {
469
528
  var diffCache = {};
470
529
 
471
530
  function genDiff(idx, baseUrl, curUrl, container) {
472
- /* Return cached result if already computed */
473
531
  if (diffCache[idx]) {
474
532
  var cached = diffCache[idx];
475
533
  var clone = document.createElement('canvas');
@@ -525,7 +583,6 @@ function getJs() {
525
583
 
526
584
  function lbReset() { lb.scale = 1; lb.tx = 0; lb.ty = 0; lbApply(); }
527
585
 
528
- /* Zoom towards a viewport-center-relative point (mx, my) */
529
586
  function lbZoom(factor, mx, my) {
530
587
  var ns = Math.max(lb.minScale, Math.min(lb.maxScale, lb.scale * factor));
531
588
  var r = ns / lb.scale;
@@ -535,7 +592,6 @@ function getJs() {
535
592
  lbApply();
536
593
  }
537
594
 
538
- /* Auto-fit: scale image to fill viewport on first open */
539
595
  function lbAutoFit(w, h) {
540
596
  var vw = lb.inner.clientWidth - 48;
541
597
  var vh = lb.inner.clientHeight - 48;
@@ -556,7 +612,6 @@ function getJs() {
556
612
  el.onload = function () { lbAutoFit(el.naturalWidth, el.naturalHeight); };
557
613
  el.src = payload;
558
614
  } else {
559
- /* diff canvas */
560
615
  var src = diffCache[payload];
561
616
  if (!src) return;
562
617
  el = document.createElement('canvas');
@@ -603,28 +658,19 @@ function getJs() {
603
658
  lb.wrap = ov.querySelector('.lb-img-wrap');
604
659
  lb.zoomLbl = ov.querySelector('.lb-zoom-label');
605
660
 
606
- /* close button */
607
661
  ov.querySelector('.lb-close').addEventListener('click', lbClose);
608
-
609
- /* click on backdrop (not on image) */
610
662
  lb.inner.addEventListener('click', function (e) {
611
663
  if (e.target === lb.inner) lbClose();
612
664
  });
613
-
614
- /* double-click: reset zoom */
615
665
  lb.inner.addEventListener('dblclick', function (e) {
616
666
  e.preventDefault();
617
667
  lbReset();
618
668
  });
619
-
620
- /* scroll-to-zoom */
621
669
  lb.inner.addEventListener('wheel', function (e) {
622
670
  e.preventDefault();
623
671
  var p = lbCenterPos(e);
624
672
  lbZoom(e.deltaY < 0 ? 1.12 : 1 / 1.12, p.x, p.y);
625
673
  }, { passive: false });
626
-
627
- /* mouse drag: pan */
628
674
  lb.inner.addEventListener('mousedown', function (e) {
629
675
  if (e.button !== 0) return;
630
676
  lb.dragging = true;
@@ -644,8 +690,6 @@ function getJs() {
644
690
  lb.dragging = false;
645
691
  lb.wrap.classList.remove('dragging');
646
692
  });
647
-
648
- /* touch: pan + pinch-to-zoom */
649
693
  lb.inner.addEventListener('touchstart', function (e) {
650
694
  if (e.touches.length === 2) {
651
695
  var dx = e.touches[0].clientX - e.touches[1].clientX;
@@ -674,8 +718,6 @@ function getJs() {
674
718
  }
675
719
  }, { passive: false });
676
720
  lb.inner.addEventListener('touchend', function () { lb.dragging = false; }, { passive: true });
677
-
678
- /* keyboard shortcuts */
679
721
  document.addEventListener('keydown', function (e) {
680
722
  if (!lb.overlay.classList.contains('open')) return;
681
723
  if (e.key === 'Escape') lbClose();
@@ -684,6 +726,7 @@ function getJs() {
684
726
  }
685
727
 
686
728
  /* ── HTML builders ── */
729
+
687
730
  function statusInfo(r) {
688
731
  if (r.status === 'PASS') return { cls:'s-pass', badge:'m-pass', label:'PASS' };
689
732
  if (r.status === 'FAIL') return { cls:'s-fail', badge:'m-fail', label:'FAIL' };
@@ -749,40 +792,75 @@ function getJs() {
749
792
  + tabs + basePanel + curPanel + diffPanel;
750
793
  }
751
794
 
752
- function buildDetail(r) {
753
- var imgWrap = r.status === 'PASS' ? 'img-section pass-imgs' : 'img-section';
795
+ /* Build a single visual-check step row + expandable detail */
796
+ function buildStep(r) {
797
+ var s = statusInfo(r);
798
+ var method = methodLabel(r);
799
+ var methodCls = methodBadgeCls(r);
800
+
801
+ /* Extract step name: "Test title › step name" → "step name" */
802
+ var raw = r.testName || '';
803
+ var sepIdx = raw.indexOf(' \u203a ');
804
+ var stepName = sepIdx >= 0 ? raw.slice(sepIdx + 3) : raw;
805
+
754
806
  var verdictLbl = r.method === 'FAST_PIXEL_MATCH'
755
807
  ? '\u26A1 Fast Pixel Match'
756
808
  : '\uD83E\uDD16 AI Verdict';
757
- return '<div class="test-detail" id="d' + r.index + '">'
758
- + '<div class="verdict">'
759
- + '<div class="verdict-lbl">' + verdictLbl + '</div>'
760
- + '<p class="verdict-txt">\u201C' + esc(r.reason) + '\u201D</p>'
809
+ var imgWrap = r.status === 'PASS' ? 'img-section pass-imgs' : 'img-section';
810
+
811
+ return '<div class="step-item ' + s.cls + '">'
812
+ + '<div class="step-row" data-s="' + r.index + '">'
813
+ + '<div class="s-dot s-dot-sm"></div>'
814
+ + '<div class="step-meta">'
815
+ + '<div class="step-name">' + esc(stepName) + '</div>'
816
+ + '</div>'
817
+ + '<div class="badges">'
818
+ + '<span class="badge ' + methodCls + '">' + method + '</span>'
819
+ + '<span class="badge ' + s.badge + '">' + s.label + '</span>'
820
+ + '</div>'
821
+ + '<span class="chev" id="sc' + r.index + '">\u203a</span>'
822
+ + '</div>'
823
+ + '<div class="step-detail" id="sd' + r.index + '">'
824
+ + '<div class="verdict">'
825
+ + '<div class="verdict-lbl">' + verdictLbl + '</div>'
826
+ + '<p class="verdict-txt">\u201C' + esc(r.reason) + '\u201D</p>'
827
+ + '</div>'
828
+ + '<div class="' + imgWrap + '">' + buildImages(r) + '</div>'
761
829
  + '</div>'
762
- + '<div class="' + imgWrap + '">' + buildImages(r) + '</div>'
763
- + '</div>';
830
+ + '</div>';
764
831
  }
765
832
 
766
- function buildItem(r) {
767
- var s = statusInfo(r);
768
- var method = methodLabel(r);
769
- var methodCls = methodBadgeCls(r);
770
- var snip = trunc(r.reason, 88);
771
- return '<div class="test-item ' + s.cls + '">'
772
- + '<div class="test-row" data-t="' + r.index + '">'
833
+ /* Build a test group row (Playwright test + its visual-check steps) */
834
+ function buildGroup(g, gIdx) {
835
+ var s = statusInfo(g);
836
+ var n = g.steps.length;
837
+
838
+ /* Snip: "5 visual checks Ā· 2 failed" or "3 visual checks Ā· all passed" */
839
+ var parts = [];
840
+ if (g.failedSteps > 0) parts.push(g.failedSteps + ' failed');
841
+ if (g.erroredSteps > 0) parts.push(g.erroredSteps + (g.erroredSteps !== 1 ? ' errors' : ' error'));
842
+ if (g.baselineSteps > 0 && g.failedSteps === 0 && g.erroredSteps === 0)
843
+ parts.push('baselines created');
844
+ var checkWord = n === 1 ? 'visual check' : 'visual checks';
845
+ var snip = n + ' ' + checkWord
846
+ + (parts.length > 0 ? ' \u00b7 ' + parts.join(' \u00b7 ') : ' \u00b7 all passed');
847
+
848
+ var stepsHtml = g.steps.map(buildStep).join('');
849
+
850
+ return '<div class="test-group ' + s.cls + '" data-gst="' + g.status + '">'
851
+ + '<div class="group-row" data-g="' + gIdx + '">'
773
852
  + '<div class="s-dot"></div>'
774
853
  + '<div class="test-meta">'
775
- + '<div class="test-name">' + esc(r.testName) + '</div>'
776
- + '<div class="test-snip">' + esc(snip) + '</div>'
854
+ + '<div class="test-name">' + esc(g.testTitle) + '</div>'
855
+ + '<div class="test-snip">' + esc(snip) + '</div>'
777
856
  + '</div>'
778
857
  + '<div class="badges">'
779
- + '<span class="badge ' + methodCls + '">' + method + '</span>'
780
- + '<span class="badge ' + s.badge + '">' + s.label + '</span>'
858
+ + '<span class="badge ' + s.badge + '">' + s.label + '</span>'
781
859
  + '</div>'
782
- + '<span class="chev" id="c' + r.index + '">\u203A</span>'
860
+ + '<span class="chev" id="gc' + gIdx + '">\u203a</span>'
783
861
  + '</div>'
784
- + buildDetail(r)
785
- + '</div>';
862
+ + '<div class="group-steps" id="gs' + gIdx + '">' + stepsHtml + '</div>'
863
+ + '</div>';
786
864
  }
787
865
 
788
866
  function buildThemeBtns() {
@@ -812,7 +890,7 @@ function getJs() {
812
890
  var warnFilter = hasErrors
813
891
  ? '<button class="f-btn" data-f="warn">Errors (' + META.errored + ')</button>'
814
892
  : '';
815
- var items = RESULTS.map(buildItem).join('');
893
+ var items = GROUPS.map(function (g, i) { return buildGroup(g, i); }).join('');
816
894
 
817
895
  return '<header class="hdr">'
818
896
  + '<div class="wrap"><div class="hdr-inner">'
@@ -826,7 +904,7 @@ function getJs() {
826
904
  + '</header>'
827
905
  + '<div class="wrap">'
828
906
  + '<div class="summary">'
829
- + '<div class="stat"><div class="stat-lbl">Total</div><div class="stat-val">' + META.total + '</div></div>'
907
+ + '<div class="stat"><div class="stat-lbl">Tests</div><div class="stat-val">' + META.total + '</div></div>'
830
908
  + '<div class="stat s-pass"><div class="stat-lbl">Passed</div><div class="stat-val">' + META.passed + '</div></div>'
831
909
  + '<div class="stat' + failCls + '"><div class="stat-lbl">Failed</div><div class="stat-val">' + META.failed + '</div></div>'
832
910
  + warnStat
@@ -853,35 +931,45 @@ function getJs() {
853
931
  function bind() {
854
932
  var tlist = document.getElementById('tlist');
855
933
 
856
- /* accordion expand/collapse — only fires from .test-row[data-t] */
934
+ /* Level 1: test group accordion — expand/collapse step list */
935
+ tlist.addEventListener('click', function (e) {
936
+ var row = e.target.closest('[data-g]');
937
+ if (!row) return;
938
+ var idx = row.getAttribute('data-g');
939
+ var steps = document.getElementById('gs' + idx);
940
+ var chev = document.getElementById('gc' + idx);
941
+ if (!steps) return;
942
+ var open = steps.style.display !== 'none';
943
+ steps.style.display = open ? 'none' : 'block';
944
+ if (chev) chev.classList.toggle('open', !open);
945
+ });
946
+
947
+ /* Level 2: step accordion — expand/collapse verdict + images */
857
948
  tlist.addEventListener('click', function (e) {
858
- var row = e.target.closest('[data-t]');
949
+ var row = e.target.closest('[data-s]');
859
950
  if (!row) return;
860
- var idx = row.getAttribute('data-t');
861
- var detail = document.getElementById('d' + idx);
862
- var chevron = document.getElementById('c' + idx);
951
+ var idx = row.getAttribute('data-s');
952
+ var detail = document.getElementById('sd' + idx);
953
+ var chev = document.getElementById('sc' + idx);
863
954
  if (!detail) return;
864
955
  var open = detail.style.display !== 'none';
865
956
  detail.style.display = open ? 'none' : 'block';
866
- if (chevron) chevron.classList.toggle('open', !open);
957
+ if (chev) chev.classList.toggle('open', !open);
867
958
  });
868
959
 
869
- /* view-tab switching: Baseline / Current / Diff */
960
+ /* View-tab switching: Baseline / Current / Diff */
870
961
  tlist.addEventListener('click', function (e) {
871
962
  var tab = e.target.closest('[data-tab]');
872
963
  if (!tab) return;
873
964
  var tidx = tab.getAttribute('data-tidx');
874
965
  var tabName = tab.getAttribute('data-tab');
875
- /* update active tab */
876
966
  tab.closest('.view-tabs').querySelectorAll('.view-tab').forEach(function (t) {
877
967
  t.classList.toggle('on', t === tab);
878
968
  });
879
- /* show correct panel, hide others */
880
969
  ['base', 'cur', 'diff'].forEach(function (key) {
881
970
  var panel = document.getElementById('v' + key + '-' + tidx);
882
971
  if (panel) panel.style.display = key === tabName ? '' : 'none';
883
972
  });
884
- /* lazy diff generation — only on first click */
885
973
  if (tabName === 'diff') {
886
974
  var dp = document.getElementById('vdiff-' + tidx);
887
975
  if (dp && dp.getAttribute('data-ready') === '0') {
@@ -891,7 +979,7 @@ function getJs() {
891
979
  }
892
980
  });
893
981
 
894
- /* image / diff-canvas click → open lightbox */
982
+ /* Image / diff-canvas click → open lightbox */
895
983
  tlist.addEventListener('click', function (e) {
896
984
  var el = e.target.closest('[data-lb]');
897
985
  if (!el) return;
@@ -900,7 +988,7 @@ function getJs() {
900
988
  if (type === 'diff') lbOpen('diff', el.getAttribute('data-lb-idx'));
901
989
  });
902
990
 
903
- /* theme */
991
+ /* Theme */
904
992
  document.getElementById('t-toggle').addEventListener('click', function (e) {
905
993
  var btn = e.target.closest('[data-th]');
906
994
  if (!btn) return;
@@ -912,7 +1000,7 @@ function getJs() {
912
1000
  });
913
1001
  });
914
1002
 
915
- /* screenshots on pass toggle */
1003
+ /* Screenshots on pass toggle */
916
1004
  document.getElementById('img-btn').addEventListener('click', function () {
917
1005
  state.showPassImg = !state.showPassImg;
918
1006
  localStorage.setItem('bv-pass-img', String(state.showPassImg));
@@ -920,18 +1008,20 @@ function getJs() {
920
1008
  this.classList.toggle('on', state.showPassImg);
921
1009
  });
922
1010
 
923
- /* filter */
1011
+ /* Filter — operates at test-group level */
924
1012
  document.querySelectorAll('[data-f]').forEach(function (btn) {
925
1013
  btn.addEventListener('click', function () {
926
1014
  var f = btn.getAttribute('data-f');
927
1015
  document.querySelectorAll('[data-f]').forEach(function (b) {
928
1016
  b.classList.toggle('on', b.getAttribute('data-f') === f);
929
1017
  });
930
- document.querySelectorAll('.test-item').forEach(function (el) {
1018
+ document.querySelectorAll('.test-group').forEach(function (el) {
1019
+ var gst = el.getAttribute('data-gst');
931
1020
  var show = f === 'all'
932
- || (f === 'pass' && el.classList.contains('s-pass'))
933
- || (f === 'fail' && el.classList.contains('s-fail'))
934
- || (f === 'warn' && el.classList.contains('s-warn'));
1021
+ || (f === 'pass' && gst === 'PASS')
1022
+ || (f === 'fail' && gst === 'FAIL')
1023
+ || (f === 'warn' && gst === 'ERROR')
1024
+ || (f === 'base' && gst === 'BASELINE_CREATED');
935
1025
  el.style.display = show ? '' : 'none';
936
1026
  });
937
1027
  });
@@ -952,8 +1042,8 @@ function getJs() {
952
1042
  `.trim();
953
1043
  }
954
1044
  // ─── HTML Assembly ───────────────────────────────────────────────────────────
955
- function generateHTML(results, meta) {
956
- const resultsJson = JSON.stringify(results);
1045
+ function generateHTML(groups, meta) {
1046
+ const groupsJson = JSON.stringify(groups);
957
1047
  const metaJson = JSON.stringify(meta);
958
1048
  const svgPass = encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#1a2e22"/><path d="M7 16.5L13 22L25 10" stroke="#73c991" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>');
959
1049
  const svgFail = encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#2a1515"/><path d="M10 10L22 22M22 10L10 22" stroke="#f14c4c" stroke-width="3" stroke-linecap="round"/></svg>');
@@ -977,7 +1067,7 @@ function generateHTML(results, meta) {
977
1067
  </head>
978
1068
  <body>
979
1069
  <div id="root"></div>
980
- <script type="application/json" id="bv-r">${resultsJson}</script>
1070
+ <script type="application/json" id="bv-g">${groupsJson}</script>
981
1071
  <script type="application/json" id="bv-m">${metaJson}</script>
982
1072
  <script>${getJs()}</script>
983
1073
  </body>
@@ -1010,13 +1100,14 @@ class BugHuntersVisionReporter {
1010
1100
  return;
1011
1101
  }
1012
1102
  const results = JSON.parse(raw);
1013
- const passed = results.filter(r => r.status === 'PASS').length;
1014
- const failed = results.filter(r => r.status === 'FAIL').length;
1015
- const baselines = results.filter(r => r.status === 'BASELINE_CREATED').length;
1016
- const errored = results.filter(r => r.status === 'ERROR').length;
1103
+ const groups = groupResults(results);
1104
+ const passed = groups.filter(g => g.status === 'PASS').length;
1105
+ const failed = groups.filter(g => g.status === 'FAIL').length;
1106
+ const baselines = groups.filter(g => g.status === 'BASELINE_CREATED').length;
1107
+ const errored = groups.filter(g => g.status === 'ERROR').length;
1017
1108
  const meta = {
1018
1109
  generatedAt: new Date().toISOString(),
1019
- total: results.length,
1110
+ total: groups.length,
1020
1111
  passed,
1021
1112
  failed,
1022
1113
  baselines,
@@ -1024,11 +1115,13 @@ class BugHuntersVisionReporter {
1024
1115
  };
1025
1116
  const reportPath = path.join(this.reportDir, 'index.html');
1026
1117
  fs.mkdirSync(this.reportDir, { recursive: true });
1027
- fs.writeFileSync(reportPath, generateHTML(results, meta), 'utf-8');
1118
+ fs.writeFileSync(reportPath, generateHTML(groups, meta), 'utf-8');
1119
+ const testWord = groups.length === 1 ? 'test' : 'tests';
1120
+ const stepWord = results.length === 1 ? 'check' : 'checks';
1028
1121
  console.log('\n\uD83D\uDC1E BugHunters Vision Report generated:');
1029
1122
  console.log(' \uD83D\uDCC4 ' + reportPath);
1030
1123
  const erroredStr = errored > 0 ? ' \u26A0\uFE0F ' + errored + ' errored' : '';
1031
- console.log(' \u2705 ' + passed + ' passed \u274C ' + failed + ' failed' + erroredStr + ' (' + results.length + ' total)');
1124
+ console.log(' \u2705 ' + passed + ' passed \u274C ' + failed + ' failed' + erroredStr + ' (' + groups.length + ' ' + testWord + ' \u00B7 ' + results.length + ' ' + stepWord + ' total)');
1032
1125
  // Show remaining API balance if an AI call was made this run
1033
1126
  const balance = (0, check_1.getLastRemainingBalance)();
1034
1127
  if (balance !== null) {
package/dist/types.d.ts CHANGED
@@ -9,6 +9,15 @@ export interface TestResult {
9
9
  currentFile: string | null;
10
10
  timestamp: string;
11
11
  }
12
+ export interface TestGroup {
13
+ testTitle: string;
14
+ status: 'PASS' | 'FAIL' | 'BASELINE_CREATED' | 'ERROR';
15
+ passedSteps: number;
16
+ failedSteps: number;
17
+ erroredSteps: number;
18
+ baselineSteps: number;
19
+ steps: TestResult[];
20
+ }
12
21
  export interface Meta {
13
22
  generatedAt: string;
14
23
  total: number;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,UAAU,GAAG,IAAI,GAAG,QAAQ,GAAG,KAAK,CAAC;AAEjD,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,kBAAkB,GAAG,OAAO,CAAC;IACvD,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,IAAI,GAAG,kBAAkB,GAAG,IAAI,CAAC;IACzC,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,IAAI;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;CACjB"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,UAAU,GAAG,IAAI,GAAG,QAAQ,GAAG,KAAK,CAAC;AAEjD,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,kBAAkB,GAAG,OAAO,CAAC;IACvD,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,IAAI,GAAG,kBAAkB,GAAG,IAAI,CAAC;IACzC,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,SAAS,EAAE,MAAM,CAAC;CACnB;AAGD,MAAM,WAAW,SAAS;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,kBAAkB,GAAG,OAAO,CAAC;IACvD,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,UAAU,EAAE,CAAC;CACrB;AAED,MAAM,WAAW,IAAI;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;CACjB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bughunters/vision",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "InfoSec-friendly AI Visual Testing plugin for Playwright.",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",