@vizzly-testing/cli 0.15.1 → 0.16.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.
@@ -357,4 +357,46 @@ export class ApiService {
357
357
  }
358
358
  });
359
359
  }
360
+
361
+ /**
362
+ * Get hotspot analysis for a single screenshot
363
+ * @param {string} screenshotName - Screenshot name to get hotspots for
364
+ * @param {Object} options - Optional settings
365
+ * @param {number} [options.windowSize=20] - Number of historical builds to analyze
366
+ * @returns {Promise<Object>} Hotspot analysis data
367
+ */
368
+ async getScreenshotHotspots(screenshotName, options = {}) {
369
+ let {
370
+ windowSize = 20
371
+ } = options;
372
+ let queryParams = new URLSearchParams({
373
+ windowSize: String(windowSize)
374
+ });
375
+ let encodedName = encodeURIComponent(screenshotName);
376
+ return this.request(`/api/sdk/screenshots/${encodedName}/hotspots?${queryParams}`);
377
+ }
378
+
379
+ /**
380
+ * Batch get hotspot analysis for multiple screenshots
381
+ * More efficient than calling getScreenshotHotspots for each screenshot
382
+ * @param {string[]} screenshotNames - Array of screenshot names
383
+ * @param {Object} options - Optional settings
384
+ * @param {number} [options.windowSize=20] - Number of historical builds to analyze
385
+ * @returns {Promise<Object>} Hotspots keyed by screenshot name
386
+ */
387
+ async getBatchHotspots(screenshotNames, options = {}) {
388
+ let {
389
+ windowSize = 20
390
+ } = options;
391
+ return this.request('/api/sdk/screenshots/hotspots', {
392
+ method: 'POST',
393
+ headers: {
394
+ 'Content-Type': 'application/json'
395
+ },
396
+ body: JSON.stringify({
397
+ screenshot_names: screenshotNames,
398
+ windowSize
399
+ })
400
+ });
401
+ }
360
402
  }
@@ -420,6 +420,9 @@ export class TddService {
420
420
  const metadataPath = join(this.baselinePath, 'metadata.json');
421
421
  writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
422
422
 
423
+ // Download hotspot data for noise filtering
424
+ await this.downloadHotspots(buildDetails.screenshots);
425
+
423
426
  // Save baseline build metadata for MCP plugin
424
427
  const baselineMetadataPath = safePath(this.workingDir, '.vizzly', 'baseline-metadata.json');
425
428
  const buildMetadata = {
@@ -459,6 +462,152 @@ export class TddService {
459
462
  }
460
463
  }
461
464
 
465
+ /**
466
+ * Download hotspot data for screenshots from the cloud
467
+ * Hotspots identify regions that frequently change (timestamps, IDs, etc.)
468
+ * Used to filter out known dynamic content during comparisons
469
+ * @param {Array} screenshots - Array of screenshot objects with name property
470
+ */
471
+ async downloadHotspots(screenshots) {
472
+ // Only attempt if we have an API token
473
+ if (!this.config.apiKey) {
474
+ output.debug('tdd', 'Skipping hotspot download - no API token configured');
475
+ return;
476
+ }
477
+ try {
478
+ // Get unique screenshot names
479
+ let screenshotNames = [...new Set(screenshots.map(s => s.name))];
480
+ if (screenshotNames.length === 0) {
481
+ return;
482
+ }
483
+ output.info(`🔥 Fetching hotspot data for ${screenshotNames.length} screenshots...`);
484
+
485
+ // Use batch endpoint for efficiency
486
+ let response = await this.api.getBatchHotspots(screenshotNames);
487
+ if (!response.hotspots || Object.keys(response.hotspots).length === 0) {
488
+ output.debug('tdd', 'No hotspot data available from cloud');
489
+ return;
490
+ }
491
+
492
+ // Store hotspots in a separate file for easy access during comparisons
493
+ this.hotspotData = response.hotspots;
494
+ let hotspotsPath = safePath(this.workingDir, '.vizzly', 'hotspots.json');
495
+ writeFileSync(hotspotsPath, JSON.stringify({
496
+ downloadedAt: new Date().toISOString(),
497
+ summary: response.summary,
498
+ hotspots: response.hotspots
499
+ }, null, 2));
500
+ let hotspotCount = Object.keys(response.hotspots).length;
501
+ let totalRegions = Object.values(response.hotspots).reduce((sum, h) => sum + (h.regions?.length || 0), 0);
502
+ output.info(`✅ Downloaded hotspot data for ${hotspotCount} screenshots (${totalRegions} regions total)`);
503
+ } catch (error) {
504
+ // Don't fail baseline download if hotspot fetch fails
505
+ output.debug('tdd', `Hotspot download failed: ${error.message}`);
506
+ output.warn('⚠️ Could not fetch hotspot data - comparisons will run without noise filtering');
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Load hotspot data from disk
512
+ * @returns {Object|null} Hotspot data keyed by screenshot name, or null if not available
513
+ */
514
+ loadHotspots() {
515
+ try {
516
+ let hotspotsPath = safePath(this.workingDir, '.vizzly', 'hotspots.json');
517
+ if (!existsSync(hotspotsPath)) {
518
+ return null;
519
+ }
520
+ let data = JSON.parse(readFileSync(hotspotsPath, 'utf8'));
521
+ return data.hotspots || null;
522
+ } catch (error) {
523
+ output.debug('tdd', `Failed to load hotspots: ${error.message}`);
524
+ return null;
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Get hotspot analysis for a specific screenshot
530
+ * @param {string} screenshotName - Name of the screenshot
531
+ * @returns {Object|null} Hotspot analysis or null if not available
532
+ */
533
+ getHotspotForScreenshot(screenshotName) {
534
+ // Check memory cache first
535
+ if (this.hotspotData && this.hotspotData[screenshotName]) {
536
+ return this.hotspotData[screenshotName];
537
+ }
538
+
539
+ // Try loading from disk
540
+ if (!this.hotspotData) {
541
+ this.hotspotData = this.loadHotspots();
542
+ }
543
+ return this.hotspotData?.[screenshotName] || null;
544
+ }
545
+
546
+ /**
547
+ * Calculate what percentage of diff falls within hotspot regions
548
+ * Uses 1D Y-coordinate matching (same algorithm as cloud)
549
+ * @param {Array} diffClusters - Array of diff clusters from honeydiff
550
+ * @param {Object} hotspotAnalysis - Hotspot data with regions array
551
+ * @returns {Object} Coverage info { coverage, linesInHotspots, totalLines }
552
+ */
553
+ calculateHotspotCoverage(diffClusters, hotspotAnalysis) {
554
+ if (!diffClusters || diffClusters.length === 0) {
555
+ return {
556
+ coverage: 0,
557
+ linesInHotspots: 0,
558
+ totalLines: 0
559
+ };
560
+ }
561
+ if (!hotspotAnalysis || !hotspotAnalysis.regions || hotspotAnalysis.regions.length === 0) {
562
+ return {
563
+ coverage: 0,
564
+ linesInHotspots: 0,
565
+ totalLines: 0
566
+ };
567
+ }
568
+
569
+ // Extract Y-coordinates (diff lines) from clusters
570
+ // Each cluster has a boundingBox with y and height
571
+ let diffLines = [];
572
+ for (let cluster of diffClusters) {
573
+ if (cluster.boundingBox) {
574
+ let {
575
+ y,
576
+ height
577
+ } = cluster.boundingBox;
578
+ // Add all Y lines covered by this cluster
579
+ for (let line = y; line < y + height; line++) {
580
+ diffLines.push(line);
581
+ }
582
+ }
583
+ }
584
+ if (diffLines.length === 0) {
585
+ return {
586
+ coverage: 0,
587
+ linesInHotspots: 0,
588
+ totalLines: 0
589
+ };
590
+ }
591
+
592
+ // Remove duplicates and sort
593
+ diffLines = [...new Set(diffLines)].sort((a, b) => a - b);
594
+
595
+ // Check how many diff lines fall within hotspot regions
596
+ let linesInHotspots = 0;
597
+ for (let line of diffLines) {
598
+ let inHotspot = hotspotAnalysis.regions.some(region => line >= region.y1 && line <= region.y2);
599
+ if (inHotspot) {
600
+ linesInHotspots++;
601
+ }
602
+ }
603
+ let coverage = linesInHotspots / diffLines.length;
604
+ return {
605
+ coverage,
606
+ linesInHotspots,
607
+ totalLines: diffLines.length
608
+ };
609
+ }
610
+
462
611
  /**
463
612
  * Download baselines using OAuth authentication
464
613
  * Used when user is logged in via device flow but no API token is configured
@@ -784,17 +933,36 @@ export class TddService {
784
933
  this.comparisons.push(comparison);
785
934
  return comparison;
786
935
  } else {
787
- // Images differ
936
+ // Images differ - check if differences are in known hotspot regions
937
+ let hotspotAnalysis = this.getHotspotForScreenshot(name);
938
+ let hotspotCoverage = null;
939
+ let isHotspotFiltered = false;
940
+ if (hotspotAnalysis && result.diffClusters && result.diffClusters.length > 0) {
941
+ hotspotCoverage = this.calculateHotspotCoverage(result.diffClusters, hotspotAnalysis);
942
+
943
+ // Consider it filtered if:
944
+ // 1. High confidence hotspot data (score >= 70)
945
+ // 2. 80%+ of the diff is within hotspot regions
946
+ let isHighConfidence = hotspotAnalysis.confidence === 'high' || hotspotAnalysis.confidence_score && hotspotAnalysis.confidence_score >= 70;
947
+ if (isHighConfidence && hotspotCoverage.coverage >= 0.8) {
948
+ isHotspotFiltered = true;
949
+ }
950
+ }
788
951
  let diffInfo = ` (${result.diffPercentage.toFixed(2)}% different, ${result.diffPixels} pixels)`;
789
952
 
790
953
  // Add cluster info to log if available
791
954
  if (result.diffClusters && result.diffClusters.length > 0) {
792
955
  diffInfo += `, ${result.diffClusters.length} region${result.diffClusters.length > 1 ? 's' : ''}`;
793
956
  }
957
+
958
+ // Add hotspot info if applicable
959
+ if (hotspotCoverage && hotspotCoverage.coverage > 0) {
960
+ diffInfo += `, ${Math.round(hotspotCoverage.coverage * 100)}% in hotspots`;
961
+ }
794
962
  const comparison = {
795
963
  id: generateComparisonId(signature),
796
964
  name: sanitizedName,
797
- status: 'failed',
965
+ status: isHotspotFiltered ? 'passed' : 'failed',
798
966
  baseline: baselineImagePath,
799
967
  current: currentImagePath,
800
968
  diff: diffImagePath,
@@ -803,7 +971,7 @@ export class TddService {
803
971
  threshold: this.threshold,
804
972
  diffPercentage: result.diffPercentage,
805
973
  diffCount: result.diffPixels,
806
- reason: 'pixel-diff',
974
+ reason: isHotspotFiltered ? 'hotspot-filtered' : 'pixel-diff',
807
975
  // Honeydiff metrics
808
976
  totalPixels: result.totalPixels,
809
977
  aaPixelsIgnored: result.aaPixelsIgnored,
@@ -811,10 +979,25 @@ export class TddService {
811
979
  boundingBox: result.boundingBox,
812
980
  heightDiff: result.heightDiff,
813
981
  intensityStats: result.intensityStats,
814
- diffClusters: result.diffClusters
982
+ diffClusters: result.diffClusters,
983
+ // Hotspot analysis data
984
+ hotspotAnalysis: hotspotCoverage ? {
985
+ coverage: hotspotCoverage.coverage,
986
+ linesInHotspots: hotspotCoverage.linesInHotspots,
987
+ totalLines: hotspotCoverage.totalLines,
988
+ confidence: hotspotAnalysis?.confidence,
989
+ confidenceScore: hotspotAnalysis?.confidence_score,
990
+ regionCount: hotspotAnalysis?.regions?.length || 0,
991
+ isFiltered: isHotspotFiltered
992
+ } : null
815
993
  };
816
- output.warn(`❌ ${colors.red('FAILED')} ${sanitizedName} - differences detected${diffInfo}`);
817
- output.info(` Diff saved to: ${diffImagePath}`);
994
+ if (isHotspotFiltered) {
995
+ output.info(`✅ ${colors.green('PASSED')} ${sanitizedName} - differences in known hotspots${diffInfo}`);
996
+ output.debug('tdd', `Hotspot filtered: ${Math.round(hotspotCoverage.coverage * 100)}% coverage, confidence: ${hotspotAnalysis.confidence}`);
997
+ } else {
998
+ output.warn(`❌ ${colors.red('FAILED')} ${sanitizedName} - differences detected${diffInfo}`);
999
+ output.info(` Diff saved to: ${diffImagePath}`);
1000
+ }
818
1001
  this.comparisons.push(comparison);
819
1002
  return comparison;
820
1003
  }
@@ -97,4 +97,25 @@ export class ApiService {
97
97
  * @returns {Promise<Object>} Finalization result
98
98
  */
99
99
  finalizeParallelBuild(parallelId: string): Promise<any>;
100
+ /**
101
+ * Get hotspot analysis for a single screenshot
102
+ * @param {string} screenshotName - Screenshot name to get hotspots for
103
+ * @param {Object} options - Optional settings
104
+ * @param {number} [options.windowSize=20] - Number of historical builds to analyze
105
+ * @returns {Promise<Object>} Hotspot analysis data
106
+ */
107
+ getScreenshotHotspots(screenshotName: string, options?: {
108
+ windowSize?: number;
109
+ }): Promise<any>;
110
+ /**
111
+ * Batch get hotspot analysis for multiple screenshots
112
+ * More efficient than calling getScreenshotHotspots for each screenshot
113
+ * @param {string[]} screenshotNames - Array of screenshot names
114
+ * @param {Object} options - Optional settings
115
+ * @param {number} [options.windowSize=20] - Number of historical builds to analyze
116
+ * @returns {Promise<Object>} Hotspots keyed by screenshot name
117
+ */
118
+ getBatchHotspots(screenshotNames: string[], options?: {
119
+ windowSize?: number;
120
+ }): Promise<any>;
100
121
  }
@@ -16,6 +16,33 @@ export class TddService {
16
16
  comparisons: any[];
17
17
  threshold: any;
18
18
  downloadBaselines(environment?: string, branch?: any, buildId?: any, comparisonId?: any): Promise<any>;
19
+ /**
20
+ * Download hotspot data for screenshots from the cloud
21
+ * Hotspots identify regions that frequently change (timestamps, IDs, etc.)
22
+ * Used to filter out known dynamic content during comparisons
23
+ * @param {Array} screenshots - Array of screenshot objects with name property
24
+ */
25
+ downloadHotspots(screenshots: any[]): Promise<void>;
26
+ hotspotData: any;
27
+ /**
28
+ * Load hotspot data from disk
29
+ * @returns {Object|null} Hotspot data keyed by screenshot name, or null if not available
30
+ */
31
+ loadHotspots(): any | null;
32
+ /**
33
+ * Get hotspot analysis for a specific screenshot
34
+ * @param {string} screenshotName - Name of the screenshot
35
+ * @returns {Object|null} Hotspot analysis or null if not available
36
+ */
37
+ getHotspotForScreenshot(screenshotName: string): any | null;
38
+ /**
39
+ * Calculate what percentage of diff falls within hotspot regions
40
+ * Uses 1D Y-coordinate matching (same algorithm as cloud)
41
+ * @param {Array} diffClusters - Array of diff clusters from honeydiff
42
+ * @param {Object} hotspotAnalysis - Hotspot data with regions array
43
+ * @returns {Object} Coverage info { coverage, linesInHotspots, totalLines }
44
+ */
45
+ calculateHotspotCoverage(diffClusters: any[], hotspotAnalysis: any): any;
19
46
  /**
20
47
  * Download baselines using OAuth authentication
21
48
  * Used when user is logged in via device flow but no API token is configured
package/docs/tdd-mode.md CHANGED
@@ -311,6 +311,40 @@ npx vizzly tdd run "npm test"
311
311
  npx vizzly run "npm test" --build-name "Fix: Header alignment issue"
312
312
  ```
313
313
 
314
+ ## Hotspot Filtering
315
+
316
+ When connected to Vizzly cloud, TDD mode automatically filters out "noise" from known hotspot areas - regions that frequently change across builds (like timestamps, animations, or dynamic content).
317
+
318
+ ### How It Works
319
+
320
+ 1. **Download baselines** - Use the TDD dashboard's Builds page to download baselines from the cloud (hotspot data is included automatically)
321
+ 2. **Automatic filtering** - During comparisons, if a diff falls within a known hotspot region, it's automatically marked as passed
322
+ 3. **Visual feedback** - You'll see output like:
323
+ ```
324
+ ✅ PASSED Dashboard - differences in known hotspots (0.15% different, 42 pixels, 1 region, 95% in hotspots)
325
+ ```
326
+
327
+ ### Requirements
328
+
329
+ Hotspot filtering activates automatically when:
330
+ - You have an API token configured (`vizzly login` or `VIZZLY_TOKEN`)
331
+ - You've downloaded baselines from the cloud (via the TDD dashboard's Builds page)
332
+ - The cloud has enough historical build data to calculate hotspot regions
333
+
334
+ ### Filtering Criteria
335
+
336
+ A diff is filtered (auto-passed) when:
337
+ - **80%+ of the diff** falls within known hotspot regions
338
+ - **High confidence** hotspot data (confidence score ≥ 70)
339
+
340
+ If the diff falls outside hotspots or confidence is low, the comparison fails normally so you can review it.
341
+
342
+ ### Benefits
343
+
344
+ - **Reduced noise** - Stop seeing the same timestamp/animation diffs over and over
345
+ - **Faster reviews** - Focus on real visual changes, not known dynamic areas
346
+ - **Smart detection** - Hotspots are calculated from your actual build history, not manual configuration
347
+
314
348
  ## Comparison Settings
315
349
 
316
350
  TDD Mode uses the same comparison settings as production:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizzly-testing/cli",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
4
4
  "description": "Visual review platform for UI developers and designers",
5
5
  "keywords": [
6
6
  "visual-testing",