@bughunters/vision 1.0.4 → 1.0.6

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,CAqK/E"}
package/dist/check.js CHANGED
@@ -156,16 +156,22 @@ 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);
163
163
  fs.mkdirSync(resolvedDir, { recursive: true });
164
- // Build unique, collision-free filenames scoped to both the test title and the step name.
165
- // Format: <safe-test-title>--<safe-step-name>.baseline.png
164
+ // Build unique, collision-free filenames scoped to project + test title + step name.
165
+ // Format: [<safe-project>--]<safe-test-title>--<safe-step-name>.baseline.png
166
+ // Project prefix is required when multiple Playwright projects run the same test
167
+ // (e.g. widget-chromium, widget-firefox) — without it all projects share one file.
168
+ const projectName = testInfo?.project?.name || undefined;
169
+ const safeProject = projectName ? sanitize(projectName) : '';
166
170
  const safeTitle = testInfo?.title ? sanitize(testInfo.title) : 'unknown';
167
171
  const safeName = sanitize(name);
168
- const filePrefix = `${safeTitle}--${safeName}`;
172
+ const filePrefix = safeProject
173
+ ? `${safeProject}--${safeTitle}--${safeName}`
174
+ : `${safeTitle}--${safeName}`;
169
175
  // Human-readable label used in the HTML report: "Test title › step name"
170
176
  const testName = testInfo?.title ? `${testInfo.title} › ${name}` : name;
171
177
  const baselinePath = path.join(resolvedDir, `${filePrefix}.baseline.png`);
@@ -173,10 +179,10 @@ async function runVisionCheck(opts) {
173
179
  if (!fs.existsSync(baselinePath) || updateBaseline) {
174
180
  // First run — save as baseline
175
181
  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');
182
+ console.log(`šŸ“ø [BugHunters Vision] Baseline created: ${name}`);
178
183
  (0, results_1.appendResult)(resolvedDir, {
179
184
  testName,
185
+ projectName,
180
186
  status: 'BASELINE_CREATED',
181
187
  reason: 'First run detected — screenshot saved as the new baseline.',
182
188
  method: null,
@@ -194,10 +200,10 @@ async function runVisionCheck(opts) {
194
200
  fs.writeFileSync(path.join(resolvedDir, currentFileName), currentBuffer);
195
201
  if (diffPixels === 0) {
196
202
  // āœ… 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');
203
+ console.log(`āœ… [BugHunters Vision] FAST PIXEL MATCH: ${name}`);
199
204
  (0, results_1.appendResult)(resolvedDir, {
200
205
  testName,
206
+ projectName,
201
207
  status: 'PASS',
202
208
  reason: 'Identical (Fast Pixel Match) — pixel-perfect match, no AI evaluation needed.',
203
209
  method: 'FAST_PIXEL_MATCH',
@@ -212,9 +218,9 @@ async function runVisionCheck(opts) {
212
218
  const diffLabel = diffPixels === -1 ? 'dimension mismatch' : `${diffPixels} pixel(s) differ`;
213
219
  if (mode === 'strict') {
214
220
  // strict: fail immediately without calling AI
215
- console.log(`\nāŒ [BugHunters Vision] "${name}" — ${diffLabel}. FAILED (strict mode — no AI call).\n`);
216
221
  (0, results_1.appendResult)(resolvedDir, {
217
222
  testName,
223
+ projectName,
218
224
  status: 'FAIL',
219
225
  reason: `Pixel mismatch detected (${diffLabel}). Strict mode — AI evaluation disabled.`,
220
226
  method: 'FAST_PIXEL_MATCH',
@@ -223,20 +229,20 @@ async function runVisionCheck(opts) {
223
229
  timestamp: new Date().toISOString(),
224
230
  });
225
231
  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);
232
+ (0, test_1.expect)(false, `[BugHunters Vision] Visual regression detected in step "${name}": ${diffLabel} (strict mode)`).toBe(true);
227
233
  return;
228
234
  }
229
235
  // ai mode — escalate to AI for intelligent evaluation
230
- console.log(`\nšŸ” [BugHunters Vision] "${name}" — ${diffLabel}. Sending to AI…`);
236
+ console.log(`šŸ” [BugHunters Vision] AI evaluating: ${name} (${diffLabel})…`);
231
237
  let result;
232
238
  try {
233
239
  result = await callVisionApi(baselineBuffer.toString('base64'), currentBuffer.toString('base64'), prompt, apiUrl, token);
234
240
  }
235
241
  catch (err) {
236
242
  const reason = humanReadableApiError(err instanceof Error ? err.message : String(err));
237
- console.log(`\nāŒ [BugHunters Vision] ${reason}\n`);
238
243
  (0, results_1.appendResult)(resolvedDir, {
239
244
  testName,
245
+ projectName,
240
246
  status: 'FAIL',
241
247
  reason,
242
248
  method: 'AI',
@@ -245,13 +251,14 @@ async function runVisionCheck(opts) {
245
251
  timestamp: new Date().toISOString(),
246
252
  });
247
253
  await pushToPlaywright(testInfo, name, 'FAIL', reason, resolvedDir, `${filePrefix}.baseline.png`, currentFileName);
248
- throw err;
254
+ throw new Error(`[BugHunters Vision] Visual regression detected in step "${name}": ${reason}`);
249
255
  }
250
256
  // ERROR: AI temporarily unavailable — record but do NOT fail the Playwright test
251
257
  if (result.status === 'ERROR') {
252
- console.log(`\nāš ļø [BugHunters Vision] "${name}" — AI unavailable: ${result.reason}\n`);
258
+ console.log(`āš ļø [BugHunters Vision] AI error: ${name} — ${result.reason}`);
253
259
  (0, results_1.appendResult)(resolvedDir, {
254
260
  testName,
261
+ projectName,
255
262
  status: 'ERROR',
256
263
  reason: result.reason,
257
264
  method: 'AI',
@@ -262,10 +269,9 @@ async function runVisionCheck(opts) {
262
269
  await pushToPlaywright(testInfo, name, 'ERROR', result.reason, resolvedDir, `${filePrefix}.baseline.png`, currentFileName);
263
270
  return; // test stays green
264
271
  }
265
- const icon = result.status === 'PASS' ? 'āœ…' : 'āŒ';
266
- console.log(`${icon} [BugHunters Vision] ${result.status}: ${result.reason}\n`);
267
272
  (0, results_1.appendResult)(resolvedDir, {
268
273
  testName,
274
+ projectName,
269
275
  status: result.status,
270
276
  reason: result.reason,
271
277
  method: 'AI',
@@ -274,5 +280,10 @@ async function runVisionCheck(opts) {
274
280
  timestamp: new Date().toISOString(),
275
281
  });
276
282
  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');
283
+ if (result.status === 'PASS') {
284
+ console.log(`šŸ¤– [BugHunters Vision] AI PASS: ${name}`);
285
+ }
286
+ else {
287
+ throw new Error(`[BugHunters Vision] Visual regression detected in step "${name}": ${result.reason}`);
288
+ }
278
289
  }
@@ -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;AA4iCD,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,46 @@ 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 project + test title ───────────────
41
+ // The grouping key is "projectName||testTitle" so that the same test running
42
+ // on different browser projects (chromium, firefox, android…) produces separate
43
+ // groups — each with its own baseline files and its own row in the report.
44
+ function groupResults(results) {
45
+ const order = [];
46
+ const map = new Map();
47
+ for (const r of results) {
48
+ const sep = r.testName.indexOf(' › ');
49
+ const testTitle = sep >= 0 ? r.testName.slice(0, sep) : r.testName;
50
+ // Composite key keeps project-variants separate while remaining stable
51
+ const key = r.projectName ? `${r.projectName}||${testTitle}` : testTitle;
52
+ if (!map.has(key)) {
53
+ map.set(key, []);
54
+ order.push(key);
55
+ }
56
+ map.get(key).push(r);
57
+ }
58
+ return order.map(key => {
59
+ const steps = map.get(key);
60
+ const firstResult = steps[0];
61
+ const sep = firstResult.testName.indexOf(' › ');
62
+ const testTitle = sep >= 0 ? firstResult.testName.slice(0, sep) : firstResult.testName;
63
+ const projectName = firstResult.projectName;
64
+ const hasFail = steps.some(s => s.status === 'FAIL');
65
+ const hasError = steps.some(s => s.status === 'ERROR');
66
+ const allBase = steps.every(s => s.status === 'BASELINE_CREATED');
67
+ const status = hasFail ? 'FAIL' : hasError ? 'ERROR' : allBase ? 'BASELINE_CREATED' : 'PASS';
68
+ return {
69
+ testTitle,
70
+ projectName,
71
+ status,
72
+ passedSteps: steps.filter(s => s.status === 'PASS').length,
73
+ failedSteps: steps.filter(s => s.status === 'FAIL').length,
74
+ erroredSteps: steps.filter(s => s.status === 'ERROR').length,
75
+ baselineSteps: steps.filter(s => s.status === 'BASELINE_CREATED').length,
76
+ steps,
77
+ };
78
+ });
79
+ }
40
80
  // ─── CSS ─────────────────────────────────────────────────────────────────────
41
81
  function getCss() {
42
82
  return `
@@ -192,27 +232,36 @@ body {
192
232
 
193
233
  /* ── Test list ── */
194
234
  .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
235
 
200
- .test-row {
236
+ /* ── Test group — one Playwright test ── */
237
+ .test-group {
238
+ background: var(--surface); border: 1px solid var(--border);
239
+ border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow-sm);
240
+ }
241
+ .test-group.s-fail { border-left: 3px solid var(--fail); }
242
+ .test-group.s-base { border-left: 3px solid var(--base); }
243
+ .test-group.s-warn { border-left: 3px solid var(--pixel); }
244
+
245
+ /* ── Group header row (clickable, expands step list) ── */
246
+ .group-row {
201
247
  display: flex; align-items: center; gap: 11px;
202
248
  padding: 12px 14px; cursor: pointer;
203
249
  transition: background .1s; user-select: none;
204
250
  }
205
- .test-row:hover { background: var(--surface-2); }
251
+ .group-row:hover { background: var(--surface-2); }
206
252
 
253
+ /* ── Status dot ── */
207
254
  .s-dot {
208
255
  width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
209
256
  margin-top: 1px;
210
257
  }
258
+ .s-dot-sm { width: 6px; height: 6px; margin-top: 0; }
211
259
  .s-pass .s-dot { background: var(--pass); }
212
260
  .s-fail .s-dot { background: var(--fail); }
213
261
  .s-base .s-dot { background: var(--base); }
214
262
  .s-warn .s-dot { background: var(--pixel); }
215
263
 
264
+ /* ── Group text ── */
216
265
  .test-meta { flex: 1; min-width: 0; }
217
266
  .test-name {
218
267
  font-family: ui-monospace, 'SFMono-Regular', 'Fira Code', monospace;
@@ -224,12 +273,14 @@ body {
224
273
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
225
274
  }
226
275
 
276
+ /* ── Badges ── */
227
277
  .badges { display: flex; gap: 5px; align-items: center; flex-shrink: 0; }
228
278
  .badge {
229
279
  font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 4px;
230
280
  letter-spacing: .4px; font-family: ui-monospace, monospace; white-space: nowrap;
231
281
  }
232
282
  .badge.m-method { background: var(--surface-2); color: var(--text-3); border: 1px solid var(--border); }
283
+ .badge.m-proj { background: var(--surface-2); color: var(--text-2); border: 1px solid var(--border); font-family: inherit; letter-spacing: 0; }
233
284
  .badge.m-ai { background: var(--accent-soft); color: var(--accent); border: 1px solid var(--accent-border); }
234
285
  .badge.m-warn { background: var(--pixel-soft); color: var(--pixel); border: 1px solid var(--pixel-border); }
235
286
  .badge.m-pixel { background: var(--pixel-soft); color: var(--pixel); border: 1px solid var(--pixel-border); }
@@ -240,9 +291,35 @@ body {
240
291
  .chev { font-size: 16px; color: var(--text-3); flex-shrink: 0; line-height: 1; transition: transform .2s; display: inline-block; }
241
292
  .chev.open { transform: rotate(90deg); }
242
293
 
243
- /* ── Detail panel ── */
244
- .test-detail { border-top: 1px solid var(--border); padding: 18px 14px 18px 33px; display: none; }
294
+ /* ── Step list (expands when group is open) ── */
295
+ .group-steps { display: none; }
296
+
297
+ /* ── Individual step ── */
298
+ .step-item { border-top: 1px solid var(--border); }
299
+
300
+ /* ── Step header row (clickable, expands detail) ── */
301
+ .step-row {
302
+ display: flex; align-items: center; gap: 9px;
303
+ padding: 9px 14px 9px 34px; cursor: pointer;
304
+ transition: background .1s; user-select: none;
305
+ }
306
+ .step-row:hover { background: var(--surface-2); }
307
+
308
+ /* ── Step text ── */
309
+ .step-meta { flex: 1; min-width: 0; }
310
+ .step-name {
311
+ font-family: ui-monospace, 'SFMono-Regular', 'Fira Code', monospace;
312
+ font-size: 12px; font-weight: 500; color: var(--text-2);
313
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
314
+ }
315
+
316
+ /* ── Step detail (verdict + images) ── */
317
+ .step-detail {
318
+ display: none; border-top: 1px solid var(--border);
319
+ padding: 16px 14px 16px 34px;
320
+ }
245
321
 
322
+ /* ── Verdict box ── */
246
323
  .verdict {
247
324
  background: var(--accent-soft); border-left: 3px solid var(--accent);
248
325
  border-radius: 0 6px 6px 0; padding: 12px 14px; margin-bottom: 18px;
@@ -267,9 +344,7 @@ body {
267
344
  .view-tab.diff-tab.on { color: var(--fail); }
268
345
 
269
346
  /* ── Image views ── */
270
- .img-section { }
271
347
  .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
348
  .view-img {
274
349
  width: 100%; display: block; border-radius: 6px; border: 1px solid var(--border);
275
350
  max-height: 360px; object-fit: contain; object-position: top center;
@@ -380,8 +455,8 @@ function getJs() {
380
455
  (function () {
381
456
  'use strict';
382
457
 
383
- var RESULTS = JSON.parse(document.getElementById('bv-r').textContent);
384
- var META = JSON.parse(document.getElementById('bv-m').textContent);
458
+ var GROUPS = JSON.parse(document.getElementById('bv-g').textContent);
459
+ var META = JSON.parse(document.getElementById('bv-m').textContent);
385
460
 
386
461
  /* ── State ── */
387
462
  var state = {
@@ -423,23 +498,19 @@ function getJs() {
423
498
 
424
499
  /* ── Pixel diff engine (100% client-side, zero server calls) ── */
425
500
 
426
- /* Weighted squared RGB distance — perceptually accurate, fast (no sqrt needed) */
427
501
  function colorDist(r1, g1, b1, r2, g2, b2) {
428
502
  var rd = r1 - r2, gd = g1 - g2, bd = b1 - b2;
429
503
  return rd * rd * 0.299 + gd * gd * 0.587 + bd * bd * 0.114;
430
504
  }
431
505
 
432
- /* Render diff image: changed pixels = neon red, unchanged = desaturated baseline */
433
506
  function renderDiff(d1, d2, w, h) {
434
507
  var n = w * h * 4;
435
508
  var out = new Uint8ClampedArray(n);
436
- var thr = 900; /* squared perceptual threshold */
509
+ var thr = 900;
437
510
  for (var i = 0; i < n; i += 4) {
438
511
  if (colorDist(d1[i], d1[i+1], d1[i+2], d2[i], d2[i+1], d2[i+2]) > thr) {
439
- /* neon red highlight */
440
512
  out[i] = 220; out[i+1] = 38; out[i+2] = 38; out[i+3] = 255;
441
513
  } else {
442
- /* desaturated baseline — guides the eye to red pixels */
443
514
  var lum = (d1[i] * 77 + d1[i+1] * 150 + d1[i+2] * 29) >> 8;
444
515
  out[i] = lum; out[i+1] = lum; out[i+2] = lum; out[i+3] = 210;
445
516
  }
@@ -447,7 +518,6 @@ function getJs() {
447
518
  return new ImageData(out, w, h);
448
519
  }
449
520
 
450
- /* Load image and extract raw pixel data via canvas */
451
521
  function loadImgPixels(url, cb) {
452
522
  var img = new Image();
453
523
  img.crossOrigin = 'anonymous';
@@ -469,7 +539,6 @@ function getJs() {
469
539
  var diffCache = {};
470
540
 
471
541
  function genDiff(idx, baseUrl, curUrl, container) {
472
- /* Return cached result if already computed */
473
542
  if (diffCache[idx]) {
474
543
  var cached = diffCache[idx];
475
544
  var clone = document.createElement('canvas');
@@ -525,7 +594,6 @@ function getJs() {
525
594
 
526
595
  function lbReset() { lb.scale = 1; lb.tx = 0; lb.ty = 0; lbApply(); }
527
596
 
528
- /* Zoom towards a viewport-center-relative point (mx, my) */
529
597
  function lbZoom(factor, mx, my) {
530
598
  var ns = Math.max(lb.minScale, Math.min(lb.maxScale, lb.scale * factor));
531
599
  var r = ns / lb.scale;
@@ -535,7 +603,6 @@ function getJs() {
535
603
  lbApply();
536
604
  }
537
605
 
538
- /* Auto-fit: scale image to fill viewport on first open */
539
606
  function lbAutoFit(w, h) {
540
607
  var vw = lb.inner.clientWidth - 48;
541
608
  var vh = lb.inner.clientHeight - 48;
@@ -556,7 +623,6 @@ function getJs() {
556
623
  el.onload = function () { lbAutoFit(el.naturalWidth, el.naturalHeight); };
557
624
  el.src = payload;
558
625
  } else {
559
- /* diff canvas */
560
626
  var src = diffCache[payload];
561
627
  if (!src) return;
562
628
  el = document.createElement('canvas');
@@ -603,28 +669,19 @@ function getJs() {
603
669
  lb.wrap = ov.querySelector('.lb-img-wrap');
604
670
  lb.zoomLbl = ov.querySelector('.lb-zoom-label');
605
671
 
606
- /* close button */
607
672
  ov.querySelector('.lb-close').addEventListener('click', lbClose);
608
-
609
- /* click on backdrop (not on image) */
610
673
  lb.inner.addEventListener('click', function (e) {
611
674
  if (e.target === lb.inner) lbClose();
612
675
  });
613
-
614
- /* double-click: reset zoom */
615
676
  lb.inner.addEventListener('dblclick', function (e) {
616
677
  e.preventDefault();
617
678
  lbReset();
618
679
  });
619
-
620
- /* scroll-to-zoom */
621
680
  lb.inner.addEventListener('wheel', function (e) {
622
681
  e.preventDefault();
623
682
  var p = lbCenterPos(e);
624
683
  lbZoom(e.deltaY < 0 ? 1.12 : 1 / 1.12, p.x, p.y);
625
684
  }, { passive: false });
626
-
627
- /* mouse drag: pan */
628
685
  lb.inner.addEventListener('mousedown', function (e) {
629
686
  if (e.button !== 0) return;
630
687
  lb.dragging = true;
@@ -644,8 +701,6 @@ function getJs() {
644
701
  lb.dragging = false;
645
702
  lb.wrap.classList.remove('dragging');
646
703
  });
647
-
648
- /* touch: pan + pinch-to-zoom */
649
704
  lb.inner.addEventListener('touchstart', function (e) {
650
705
  if (e.touches.length === 2) {
651
706
  var dx = e.touches[0].clientX - e.touches[1].clientX;
@@ -674,8 +729,6 @@ function getJs() {
674
729
  }
675
730
  }, { passive: false });
676
731
  lb.inner.addEventListener('touchend', function () { lb.dragging = false; }, { passive: true });
677
-
678
- /* keyboard shortcuts */
679
732
  document.addEventListener('keydown', function (e) {
680
733
  if (!lb.overlay.classList.contains('open')) return;
681
734
  if (e.key === 'Escape') lbClose();
@@ -684,6 +737,7 @@ function getJs() {
684
737
  }
685
738
 
686
739
  /* ── HTML builders ── */
740
+
687
741
  function statusInfo(r) {
688
742
  if (r.status === 'PASS') return { cls:'s-pass', badge:'m-pass', label:'PASS' };
689
743
  if (r.status === 'FAIL') return { cls:'s-fail', badge:'m-fail', label:'FAIL' };
@@ -749,40 +803,79 @@ function getJs() {
749
803
  + tabs + basePanel + curPanel + diffPanel;
750
804
  }
751
805
 
752
- function buildDetail(r) {
753
- var imgWrap = r.status === 'PASS' ? 'img-section pass-imgs' : 'img-section';
806
+ /* Build a single visual-check step row + expandable detail */
807
+ function buildStep(r) {
808
+ var s = statusInfo(r);
809
+ var method = methodLabel(r);
810
+ var methodCls = methodBadgeCls(r);
811
+
812
+ /* Extract step name: "Test title › step name" → "step name" */
813
+ var raw = r.testName || '';
814
+ var sepIdx = raw.indexOf(' \u203a ');
815
+ var stepName = sepIdx >= 0 ? raw.slice(sepIdx + 3) : raw;
816
+
754
817
  var verdictLbl = r.method === 'FAST_PIXEL_MATCH'
755
818
  ? '\u26A1 Fast Pixel Match'
756
819
  : '\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>'
820
+ var imgWrap = r.status === 'PASS' ? 'img-section pass-imgs' : 'img-section';
821
+
822
+ return '<div class="step-item ' + s.cls + '">'
823
+ + '<div class="step-row" data-s="' + r.index + '">'
824
+ + '<div class="s-dot s-dot-sm"></div>'
825
+ + '<div class="step-meta">'
826
+ + '<div class="step-name">' + esc(stepName) + '</div>'
827
+ + '</div>'
828
+ + '<div class="badges">'
829
+ + '<span class="badge ' + methodCls + '">' + method + '</span>'
830
+ + '<span class="badge ' + s.badge + '">' + s.label + '</span>'
831
+ + '</div>'
832
+ + '<span class="chev" id="sc' + r.index + '">\u203a</span>'
761
833
  + '</div>'
762
- + '<div class="' + imgWrap + '">' + buildImages(r) + '</div>'
763
- + '</div>';
834
+ + '<div class="step-detail" id="sd' + r.index + '">'
835
+ + '<div class="verdict">'
836
+ + '<div class="verdict-lbl">' + verdictLbl + '</div>'
837
+ + '<p class="verdict-txt">\u201C' + esc(r.reason) + '\u201D</p>'
838
+ + '</div>'
839
+ + '<div class="' + imgWrap + '">' + buildImages(r) + '</div>'
840
+ + '</div>'
841
+ + '</div>';
764
842
  }
765
843
 
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 + '">'
844
+ /* Build a test group row (Playwright test + its visual-check steps) */
845
+ function buildGroup(g, gIdx) {
846
+ var s = statusInfo(g);
847
+ var n = g.steps.length;
848
+
849
+ /* Snip: "5 visual checks Ā· 2 failed" or "3 visual checks Ā· all passed" */
850
+ var parts = [];
851
+ if (g.failedSteps > 0) parts.push(g.failedSteps + ' failed');
852
+ if (g.erroredSteps > 0) parts.push(g.erroredSteps + (g.erroredSteps !== 1 ? ' errors' : ' error'));
853
+ if (g.baselineSteps > 0 && g.failedSteps === 0 && g.erroredSteps === 0)
854
+ parts.push('baselines created');
855
+ var checkWord = n === 1 ? 'visual check' : 'visual checks';
856
+ var snip = n + ' ' + checkWord
857
+ + (parts.length > 0 ? ' \u00b7 ' + parts.join(' \u00b7 ') : ' \u00b7 all passed');
858
+
859
+ var stepsHtml = g.steps.map(buildStep).join('');
860
+ var projBadge = g.projectName
861
+ ? '<span class="badge m-proj">' + esc(g.projectName) + '</span>'
862
+ : '';
863
+
864
+ return '<div class="test-group ' + s.cls + '" data-gst="' + g.status + '">'
865
+ + '<div class="group-row" data-g="' + gIdx + '">'
773
866
  + '<div class="s-dot"></div>'
774
867
  + '<div class="test-meta">'
775
- + '<div class="test-name">' + esc(r.testName) + '</div>'
776
- + '<div class="test-snip">' + esc(snip) + '</div>'
868
+ + '<div class="test-name">' + esc(g.testTitle) + '</div>'
869
+ + '<div class="test-snip">' + esc(snip) + '</div>'
777
870
  + '</div>'
778
871
  + '<div class="badges">'
779
- + '<span class="badge ' + methodCls + '">' + method + '</span>'
780
- + '<span class="badge ' + s.badge + '">' + s.label + '</span>'
872
+ + projBadge
873
+ + '<span class="badge ' + s.badge + '">' + s.label + '</span>'
781
874
  + '</div>'
782
- + '<span class="chev" id="c' + r.index + '">\u203A</span>'
875
+ + '<span class="chev" id="gc' + gIdx + '">\u203a</span>'
783
876
  + '</div>'
784
- + buildDetail(r)
785
- + '</div>';
877
+ + '<div class="group-steps" id="gs' + gIdx + '">' + stepsHtml + '</div>'
878
+ + '</div>';
786
879
  }
787
880
 
788
881
  function buildThemeBtns() {
@@ -812,7 +905,7 @@ function getJs() {
812
905
  var warnFilter = hasErrors
813
906
  ? '<button class="f-btn" data-f="warn">Errors (' + META.errored + ')</button>'
814
907
  : '';
815
- var items = RESULTS.map(buildItem).join('');
908
+ var items = GROUPS.map(function (g, i) { return buildGroup(g, i); }).join('');
816
909
 
817
910
  return '<header class="hdr">'
818
911
  + '<div class="wrap"><div class="hdr-inner">'
@@ -826,7 +919,7 @@ function getJs() {
826
919
  + '</header>'
827
920
  + '<div class="wrap">'
828
921
  + '<div class="summary">'
829
- + '<div class="stat"><div class="stat-lbl">Total</div><div class="stat-val">' + META.total + '</div></div>'
922
+ + '<div class="stat"><div class="stat-lbl">Tests</div><div class="stat-val">' + META.total + '</div></div>'
830
923
  + '<div class="stat s-pass"><div class="stat-lbl">Passed</div><div class="stat-val">' + META.passed + '</div></div>'
831
924
  + '<div class="stat' + failCls + '"><div class="stat-lbl">Failed</div><div class="stat-val">' + META.failed + '</div></div>'
832
925
  + warnStat
@@ -853,35 +946,45 @@ function getJs() {
853
946
  function bind() {
854
947
  var tlist = document.getElementById('tlist');
855
948
 
856
- /* accordion expand/collapse — only fires from .test-row[data-t] */
949
+ /* Level 1: test group accordion — expand/collapse step list */
950
+ tlist.addEventListener('click', function (e) {
951
+ var row = e.target.closest('[data-g]');
952
+ if (!row) return;
953
+ var idx = row.getAttribute('data-g');
954
+ var steps = document.getElementById('gs' + idx);
955
+ var chev = document.getElementById('gc' + idx);
956
+ if (!steps) return;
957
+ var open = steps.style.display !== 'none';
958
+ steps.style.display = open ? 'none' : 'block';
959
+ if (chev) chev.classList.toggle('open', !open);
960
+ });
961
+
962
+ /* Level 2: step accordion — expand/collapse verdict + images */
857
963
  tlist.addEventListener('click', function (e) {
858
- var row = e.target.closest('[data-t]');
964
+ var row = e.target.closest('[data-s]');
859
965
  if (!row) return;
860
- var idx = row.getAttribute('data-t');
861
- var detail = document.getElementById('d' + idx);
862
- var chevron = document.getElementById('c' + idx);
966
+ var idx = row.getAttribute('data-s');
967
+ var detail = document.getElementById('sd' + idx);
968
+ var chev = document.getElementById('sc' + idx);
863
969
  if (!detail) return;
864
970
  var open = detail.style.display !== 'none';
865
971
  detail.style.display = open ? 'none' : 'block';
866
- if (chevron) chevron.classList.toggle('open', !open);
972
+ if (chev) chev.classList.toggle('open', !open);
867
973
  });
868
974
 
869
- /* view-tab switching: Baseline / Current / Diff */
975
+ /* View-tab switching: Baseline / Current / Diff */
870
976
  tlist.addEventListener('click', function (e) {
871
977
  var tab = e.target.closest('[data-tab]');
872
978
  if (!tab) return;
873
979
  var tidx = tab.getAttribute('data-tidx');
874
980
  var tabName = tab.getAttribute('data-tab');
875
- /* update active tab */
876
981
  tab.closest('.view-tabs').querySelectorAll('.view-tab').forEach(function (t) {
877
982
  t.classList.toggle('on', t === tab);
878
983
  });
879
- /* show correct panel, hide others */
880
984
  ['base', 'cur', 'diff'].forEach(function (key) {
881
985
  var panel = document.getElementById('v' + key + '-' + tidx);
882
986
  if (panel) panel.style.display = key === tabName ? '' : 'none';
883
987
  });
884
- /* lazy diff generation — only on first click */
885
988
  if (tabName === 'diff') {
886
989
  var dp = document.getElementById('vdiff-' + tidx);
887
990
  if (dp && dp.getAttribute('data-ready') === '0') {
@@ -891,7 +994,7 @@ function getJs() {
891
994
  }
892
995
  });
893
996
 
894
- /* image / diff-canvas click → open lightbox */
997
+ /* Image / diff-canvas click → open lightbox */
895
998
  tlist.addEventListener('click', function (e) {
896
999
  var el = e.target.closest('[data-lb]');
897
1000
  if (!el) return;
@@ -900,7 +1003,7 @@ function getJs() {
900
1003
  if (type === 'diff') lbOpen('diff', el.getAttribute('data-lb-idx'));
901
1004
  });
902
1005
 
903
- /* theme */
1006
+ /* Theme */
904
1007
  document.getElementById('t-toggle').addEventListener('click', function (e) {
905
1008
  var btn = e.target.closest('[data-th]');
906
1009
  if (!btn) return;
@@ -912,7 +1015,7 @@ function getJs() {
912
1015
  });
913
1016
  });
914
1017
 
915
- /* screenshots on pass toggle */
1018
+ /* Screenshots on pass toggle */
916
1019
  document.getElementById('img-btn').addEventListener('click', function () {
917
1020
  state.showPassImg = !state.showPassImg;
918
1021
  localStorage.setItem('bv-pass-img', String(state.showPassImg));
@@ -920,18 +1023,20 @@ function getJs() {
920
1023
  this.classList.toggle('on', state.showPassImg);
921
1024
  });
922
1025
 
923
- /* filter */
1026
+ /* Filter — operates at test-group level */
924
1027
  document.querySelectorAll('[data-f]').forEach(function (btn) {
925
1028
  btn.addEventListener('click', function () {
926
1029
  var f = btn.getAttribute('data-f');
927
1030
  document.querySelectorAll('[data-f]').forEach(function (b) {
928
1031
  b.classList.toggle('on', b.getAttribute('data-f') === f);
929
1032
  });
930
- document.querySelectorAll('.test-item').forEach(function (el) {
1033
+ document.querySelectorAll('.test-group').forEach(function (el) {
1034
+ var gst = el.getAttribute('data-gst');
931
1035
  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'));
1036
+ || (f === 'pass' && gst === 'PASS')
1037
+ || (f === 'fail' && gst === 'FAIL')
1038
+ || (f === 'warn' && gst === 'ERROR')
1039
+ || (f === 'base' && gst === 'BASELINE_CREATED');
935
1040
  el.style.display = show ? '' : 'none';
936
1041
  });
937
1042
  });
@@ -952,8 +1057,8 @@ function getJs() {
952
1057
  `.trim();
953
1058
  }
954
1059
  // ─── HTML Assembly ───────────────────────────────────────────────────────────
955
- function generateHTML(results, meta) {
956
- const resultsJson = JSON.stringify(results);
1060
+ function generateHTML(groups, meta) {
1061
+ const groupsJson = JSON.stringify(groups);
957
1062
  const metaJson = JSON.stringify(meta);
958
1063
  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
1064
  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 +1082,7 @@ function generateHTML(results, meta) {
977
1082
  </head>
978
1083
  <body>
979
1084
  <div id="root"></div>
980
- <script type="application/json" id="bv-r">${resultsJson}</script>
1085
+ <script type="application/json" id="bv-g">${groupsJson}</script>
981
1086
  <script type="application/json" id="bv-m">${metaJson}</script>
982
1087
  <script>${getJs()}</script>
983
1088
  </body>
@@ -1010,13 +1115,14 @@ class BugHuntersVisionReporter {
1010
1115
  return;
1011
1116
  }
1012
1117
  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;
1118
+ const groups = groupResults(results);
1119
+ const passed = groups.filter(g => g.status === 'PASS').length;
1120
+ const failed = groups.filter(g => g.status === 'FAIL').length;
1121
+ const baselines = groups.filter(g => g.status === 'BASELINE_CREATED').length;
1122
+ const errored = groups.filter(g => g.status === 'ERROR').length;
1017
1123
  const meta = {
1018
1124
  generatedAt: new Date().toISOString(),
1019
- total: results.length,
1125
+ total: groups.length,
1020
1126
  passed,
1021
1127
  failed,
1022
1128
  baselines,
@@ -1024,11 +1130,13 @@ class BugHuntersVisionReporter {
1024
1130
  };
1025
1131
  const reportPath = path.join(this.reportDir, 'index.html');
1026
1132
  fs.mkdirSync(this.reportDir, { recursive: true });
1027
- fs.writeFileSync(reportPath, generateHTML(results, meta), 'utf-8');
1133
+ fs.writeFileSync(reportPath, generateHTML(groups, meta), 'utf-8');
1134
+ const testWord = groups.length === 1 ? 'test' : 'tests';
1135
+ const stepWord = results.length === 1 ? 'check' : 'checks';
1028
1136
  console.log('\n\uD83D\uDC1E BugHunters Vision Report generated:');
1029
1137
  console.log(' \uD83D\uDCC4 ' + reportPath);
1030
1138
  const erroredStr = errored > 0 ? ' \u26A0\uFE0F ' + errored + ' errored' : '';
1031
- console.log(' \u2705 ' + passed + ' passed \u274C ' + failed + ' failed' + erroredStr + ' (' + results.length + ' total)');
1139
+ console.log(' \u2705 ' + passed + ' passed \u274C ' + failed + ' failed' + erroredStr + ' (' + groups.length + ' ' + testWord + ' \u00B7 ' + results.length + ' ' + stepWord + ' total)');
1032
1140
  // Show remaining API balance if an AI call was made this run
1033
1141
  const balance = (0, check_1.getLastRemainingBalance)();
1034
1142
  if (balance !== null) {
package/dist/types.d.ts CHANGED
@@ -2,6 +2,7 @@ export type VisionMode = 'ai' | 'strict' | 'off';
2
2
  export interface TestResult {
3
3
  index: number;
4
4
  testName: string;
5
+ projectName?: string;
5
6
  status: 'PASS' | 'FAIL' | 'BASELINE_CREATED' | 'ERROR';
6
7
  reason: string;
7
8
  method: 'AI' | 'FAST_PIXEL_MATCH' | null;
@@ -9,6 +10,16 @@ export interface TestResult {
9
10
  currentFile: string | null;
10
11
  timestamp: string;
11
12
  }
13
+ export interface TestGroup {
14
+ testTitle: string;
15
+ projectName?: string;
16
+ status: 'PASS' | 'FAIL' | 'BASELINE_CREATED' | 'ERROR';
17
+ passedSteps: number;
18
+ failedSteps: number;
19
+ erroredSteps: number;
20
+ baselineSteps: number;
21
+ steps: TestResult[];
22
+ }
12
23
  export interface Meta {
13
24
  generatedAt: string;
14
25
  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,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,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,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,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.6",
4
4
  "description": "InfoSec-friendly AI Visual Testing plugin for Playwright.",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -24,7 +24,7 @@
24
24
  "reporter.d.ts",
25
25
  "README.md"
26
26
  ],
27
- "homepage": "https://bughunters.vision",
27
+ "homepage": "https://bughunters.dev",
28
28
  "repository": {
29
29
  "type": "git",
30
30
  "url": "git+https://github.com/bughunters/vision.git"