@vizzly-testing/cli 0.15.0 → 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.
package/dist/cli.js CHANGED
@@ -50,7 +50,9 @@ try {
50
50
  let registerPromise = plugin.register(program, {
51
51
  config,
52
52
  services,
53
- output
53
+ output,
54
+ // Backwards compatibility alias for plugins using old API
55
+ logger: output
54
56
  });
55
57
  let timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Plugin registration timeout (5s)')), 5000));
56
58
  await Promise.race([registerPromise, timeoutPromise]);
@@ -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/plugins.md CHANGED
@@ -356,7 +356,7 @@ register(program, { output }) {
356
356
  export default {
357
357
  name: 'hello',
358
358
  version: '1.0.0',
359
- register(program, { logger }) {
359
+ register(program, { output }) {
360
360
  program
361
361
  .command('hello <name>')
362
362
  .description('Say hello')
@@ -366,7 +366,7 @@ export default {
366
366
  if (options.loud) {
367
367
  greeting = greeting.toUpperCase();
368
368
  }
369
- logger.info(greeting);
369
+ output.info(greeting);
370
370
  });
371
371
  }
372
372
  };
@@ -378,13 +378,13 @@ export default {
378
378
  export default {
379
379
  name: 'storybook',
380
380
  version: '1.0.0',
381
- register(program, { config, logger, services }) {
381
+ register(program, { config, output, services }) {
382
382
  program
383
383
  .command('storybook <path>')
384
384
  .description('Capture screenshots from Storybook build')
385
385
  .option('--viewports <list>', 'Comma-separated viewports', '1280x720')
386
386
  .action(async (path, options) => {
387
- logger.info(`Crawling Storybook at ${path}`);
387
+ output.info(`Crawling Storybook at ${path}`);
388
388
 
389
389
  // Import dependencies lazily
390
390
  let { crawlStorybook } = await import('./crawler.js');
@@ -392,16 +392,15 @@ export default {
392
392
  // Capture screenshots
393
393
  let screenshots = await crawlStorybook(path, {
394
394
  viewports: options.viewports.split(','),
395
- logger,
396
395
  });
397
396
 
398
- logger.info(`Captured ${screenshots.length} screenshots`);
397
+ output.info(`Captured ${screenshots.length} screenshots`);
399
398
 
400
399
  // Upload using Vizzly's uploader service
401
400
  let uploader = await services.get('uploader');
402
401
  await uploader.uploadScreenshots(screenshots);
403
402
 
404
- logger.info('Upload complete!');
403
+ output.success('Upload complete!');
405
404
  });
406
405
  }
407
406
  };
@@ -413,7 +412,7 @@ export default {
413
412
  export default {
414
413
  name: 'reports',
415
414
  version: '1.0.0',
416
- register(program, { logger }) {
415
+ register(program, { output }) {
417
416
  let reports = program
418
417
  .command('reports')
419
418
  .description('Report generation commands');
@@ -422,14 +421,14 @@ export default {
422
421
  .command('generate')
423
422
  .description('Generate a new report')
424
423
  .action(() => {
425
- logger.info('Generating report...');
424
+ output.info('Generating report...');
426
425
  });
427
426
 
428
427
  reports
429
428
  .command('list')
430
429
  .description('List all reports')
431
430
  .action(() => {
432
- logger.info('Listing reports...');
431
+ output.info('Listing reports...');
433
432
  });
434
433
  }
435
434
  };
@@ -474,10 +473,10 @@ If you're using TypeScript or want better IDE support, you can add JSDoc types:
474
473
  * @param {import('commander').Command} program
475
474
  * @param {Object} context
476
475
  * @param {Object} context.config
477
- * @param {Object} context.logger
476
+ * @param {Object} context.output
478
477
  * @param {Object} context.services
479
478
  */
480
- function register(program, { config, logger, services }) {
479
+ function register(program, { config, output, services }) {
481
480
  // Your plugin code with full autocomplete!
482
481
  }
483
482
 
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.0",
3
+ "version": "0.16.0",
4
4
  "description": "Visual review platform for UI developers and designers",
5
5
  "keywords": [
6
6
  "visual-testing",