@vizzly-testing/cli 0.16.4 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +4 -4
  2. package/claude-plugin/skills/debug-visual-regression/SKILL.md +2 -2
  3. package/dist/cli.js +84 -58
  4. package/dist/client/index.js +6 -6
  5. package/dist/commands/doctor.js +18 -17
  6. package/dist/commands/finalize.js +7 -7
  7. package/dist/commands/init.js +30 -30
  8. package/dist/commands/login.js +23 -23
  9. package/dist/commands/logout.js +4 -4
  10. package/dist/commands/project.js +36 -36
  11. package/dist/commands/run.js +33 -33
  12. package/dist/commands/status.js +14 -14
  13. package/dist/commands/tdd-daemon.js +43 -43
  14. package/dist/commands/tdd.js +27 -27
  15. package/dist/commands/upload.js +33 -33
  16. package/dist/commands/whoami.js +12 -12
  17. package/dist/index.js +9 -14
  18. package/dist/plugin-loader.js +28 -28
  19. package/dist/reporter/reporter-bundle.css +1 -1
  20. package/dist/reporter/reporter-bundle.iife.js +19 -19
  21. package/dist/sdk/index.js +33 -35
  22. package/dist/server/handlers/api-handler.js +4 -4
  23. package/dist/server/handlers/tdd-handler.js +12 -12
  24. package/dist/server/http-server.js +21 -22
  25. package/dist/server/middleware/json-parser.js +1 -1
  26. package/dist/server/routers/assets.js +14 -14
  27. package/dist/server/routers/auth.js +14 -14
  28. package/dist/server/routers/baseline.js +8 -8
  29. package/dist/server/routers/cloud-proxy.js +15 -15
  30. package/dist/server/routers/config.js +11 -11
  31. package/dist/server/routers/dashboard.js +11 -11
  32. package/dist/server/routers/health.js +4 -4
  33. package/dist/server/routers/projects.js +19 -19
  34. package/dist/server/routers/screenshot.js +9 -9
  35. package/dist/services/api-service.js +16 -16
  36. package/dist/services/auth-service.js +17 -17
  37. package/dist/services/build-manager.js +3 -3
  38. package/dist/services/config-service.js +33 -33
  39. package/dist/services/html-report-generator.js +8 -8
  40. package/dist/services/index.js +11 -11
  41. package/dist/services/project-service.js +19 -19
  42. package/dist/services/report-generator/report.css +3 -3
  43. package/dist/services/report-generator/viewer.js +25 -23
  44. package/dist/services/screenshot-server.js +1 -1
  45. package/dist/services/server-manager.js +5 -5
  46. package/dist/services/static-report-generator.js +14 -14
  47. package/dist/services/tdd-service.js +101 -95
  48. package/dist/services/test-runner.js +14 -4
  49. package/dist/services/uploader.js +10 -8
  50. package/dist/types/config.d.ts +2 -1
  51. package/dist/types/index.d.ts +11 -1
  52. package/dist/types/sdk.d.ts +1 -1
  53. package/dist/utils/browser.js +3 -3
  54. package/dist/utils/build-history.js +12 -12
  55. package/dist/utils/config-loader.js +19 -19
  56. package/dist/utils/config-schema.js +10 -9
  57. package/dist/utils/environment-config.js +11 -0
  58. package/dist/utils/fetch-utils.js +2 -2
  59. package/dist/utils/file-helpers.js +2 -2
  60. package/dist/utils/git.js +3 -6
  61. package/dist/utils/global-config.js +28 -25
  62. package/dist/utils/output.js +136 -28
  63. package/dist/utils/package-info.js +3 -3
  64. package/dist/utils/security.js +12 -12
  65. package/docs/api-reference.md +56 -27
  66. package/docs/doctor-command.md +1 -1
  67. package/docs/tdd-mode.md +3 -3
  68. package/package.json +9 -13
@@ -3,14 +3,14 @@
3
3
  * Generates a self-contained HTML file with the React dashboard and embedded data
4
4
  */
5
5
 
6
- import { writeFile, mkdir, copyFile } from 'fs/promises';
7
- import { existsSync } from 'fs';
8
- import { join, dirname } from 'path';
9
- import { fileURLToPath } from 'url';
6
+ import { existsSync } from 'node:fs';
7
+ import { copyFile, mkdir, writeFile } from 'node:fs/promises';
8
+ import { dirname, join } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
10
  import * as output from '../utils/output.js';
11
- let __filename = fileURLToPath(import.meta.url);
12
- let __dirname = dirname(__filename);
13
- let PROJECT_ROOT = join(__dirname, '..', '..');
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = dirname(__filename);
13
+ const PROJECT_ROOT = join(__dirname, '..', '..');
14
14
  export class StaticReportGenerator {
15
15
  constructor(workingDir, config) {
16
16
  this.workingDir = workingDir;
@@ -35,8 +35,8 @@ export class StaticReportGenerator {
35
35
  });
36
36
 
37
37
  // Copy React bundles to report directory
38
- let bundlePath = join(PROJECT_ROOT, 'dist', 'reporter', 'reporter-bundle.iife.js');
39
- let cssPath = join(PROJECT_ROOT, 'dist', 'reporter', 'reporter-bundle.css');
38
+ const bundlePath = join(PROJECT_ROOT, 'dist', 'reporter', 'reporter-bundle.iife.js');
39
+ const cssPath = join(PROJECT_ROOT, 'dist', 'reporter', 'reporter-bundle.css');
40
40
  if (!existsSync(bundlePath) || !existsSync(cssPath)) {
41
41
  throw new Error('Reporter bundles not found. Run "npm run build:reporter" first.');
42
42
  }
@@ -46,7 +46,7 @@ export class StaticReportGenerator {
46
46
  await copyFile(cssPath, join(this.reportDir, 'reporter-bundle.css'));
47
47
 
48
48
  // Generate HTML with embedded data
49
- let htmlContent = this.generateHtmlTemplate(reportData);
49
+ const htmlContent = this.generateHtmlTemplate(reportData);
50
50
  await writeFile(this.reportPath, htmlContent, 'utf8');
51
51
  output.debug('report', 'generated static report');
52
52
  return this.reportPath;
@@ -63,7 +63,7 @@ export class StaticReportGenerator {
63
63
  */
64
64
  generateHtmlTemplate(reportData) {
65
65
  // Serialize report data safely
66
- let serializedData = JSON.stringify(reportData).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
66
+ const serializedData = JSON.stringify(reportData).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
67
67
  return `<!DOCTYPE html>
68
68
  <html lang="en">
69
69
  <head>
@@ -127,9 +127,9 @@ export class StaticReportGenerator {
127
127
  * @returns {string} Minimal HTML content
128
128
  */
129
129
  generateFallbackHtml(reportData) {
130
- let summary = reportData.summary || {};
131
- let comparisons = reportData.comparisons || [];
132
- let failed = comparisons.filter(c => c.status === 'failed');
130
+ const summary = reportData.summary || {};
131
+ const comparisons = reportData.comparisons || [];
132
+ const failed = comparisons.filter(c => c.status === 'failed');
133
133
  return `<!DOCTYPE html>
134
134
  <html lang="en">
135
135
  <head>
@@ -1,15 +1,15 @@
1
- import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
2
- import { join } from 'path';
1
+ import crypto from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
3
4
  import { compare } from '@vizzly-testing/honeydiff';
4
- import crypto from 'crypto';
5
+ import { NetworkError } from '../errors/vizzly-error.js';
5
6
  import { ApiService } from '../services/api-service.js';
6
- import * as output from '../utils/output.js';
7
7
  import { colors } from '../utils/colors.js';
8
- import { getDefaultBranch } from '../utils/git.js';
9
8
  import { fetchWithTimeout } from '../utils/fetch-utils.js';
10
- import { NetworkError } from '../errors/vizzly-error.js';
9
+ import { getDefaultBranch } from '../utils/git.js';
10
+ import * as output from '../utils/output.js';
11
+ import { safePath, sanitizeScreenshotName, validatePathSecurity, validateScreenshotProperties } from '../utils/security.js';
11
12
  import { HtmlReportGenerator } from './html-report-generator.js';
12
- import { sanitizeScreenshotName, validatePathSecurity, safePath, validateScreenshotProperties } from '../utils/security.js';
13
13
 
14
14
  /**
15
15
  * Generate a screenshot signature for baseline matching
@@ -27,49 +27,52 @@ import { sanitizeScreenshotName, validatePathSecurity, safePath, validateScreens
27
27
  * @returns {string} Signature like "Login|1920|chrome|iPhone 15 Pro"
28
28
  */
29
29
  function generateScreenshotSignature(name, properties = {}, customProperties = []) {
30
- let parts = [name];
31
-
32
- // Check for viewport_width as top-level property first (backend format)
33
- let viewportWidth = properties.viewport_width;
34
-
35
- // Fallback to nested viewport.width (SDK format)
36
- if (!viewportWidth && properties.viewport?.width) {
37
- viewportWidth = properties.viewport.width;
38
- }
39
-
40
- // Add viewport width if present
41
- if (viewportWidth) {
42
- parts.push(viewportWidth.toString());
43
- }
44
-
45
- // Add browser if present
46
- if (properties.browser) {
47
- parts.push(properties.browser);
48
- }
30
+ // Match cloud screenshot-identity.js behavior exactly:
31
+ // Always include all default properties (name, viewport_width, browser)
32
+ // even if null/undefined, using empty string as placeholder
33
+ const defaultProperties = ['name', 'viewport_width', 'browser'];
34
+ const allProperties = [...defaultProperties, ...customProperties];
35
+ const parts = allProperties.map(propName => {
36
+ let value;
37
+ if (propName === 'name') {
38
+ value = name;
39
+ } else if (propName === 'viewport_width') {
40
+ // Check for viewport_width as top-level property first (backend format)
41
+ value = properties.viewport_width;
42
+ // Fallback to nested viewport.width (SDK format)
43
+ if (value === null || value === undefined) {
44
+ value = properties.viewport?.width;
45
+ }
46
+ } else if (propName === 'browser') {
47
+ value = properties.browser;
48
+ } else {
49
+ // Custom property - check multiple locations
50
+ value = properties[propName] ?? properties.metadata?.[propName] ?? properties.metadata?.properties?.[propName];
51
+ }
49
52
 
50
- // Add custom properties in order (matches cloud screenshot-identity.js behavior)
51
- for (let propName of customProperties) {
52
- // Check multiple locations where property might exist:
53
- // 1. Top-level property (e.g., properties.device)
54
- // 2. In metadata object (e.g., properties.metadata.device)
55
- // 3. In nested metadata.properties (e.g., properties.metadata.properties.device)
56
- let value = properties[propName] ?? properties.metadata?.[propName] ?? properties.metadata?.properties?.[propName] ?? '';
53
+ // Handle null/undefined values consistently (match cloud behavior)
54
+ if (value === null || value === undefined) {
55
+ return '';
56
+ }
57
57
 
58
- // Normalize: convert to string, trim whitespace
59
- parts.push(String(value).trim());
60
- }
58
+ // Convert to string and normalize
59
+ return String(value).trim();
60
+ });
61
61
  return parts.join('|');
62
62
  }
63
63
 
64
64
  /**
65
65
  * Create a safe filename from signature
66
66
  * Handles custom property values that may contain spaces or special characters
67
+ *
68
+ * IMPORTANT: Does NOT collapse multiple underscores because empty signature
69
+ * positions (e.g., null browser) result in `||` which becomes `__` and must
70
+ * be preserved for cloud compatibility.
67
71
  */
68
72
  function signatureToFilename(signature) {
69
73
  return signature.replace(/\|/g, '_') // pipes to underscores
70
- .replace(/\s+/g, '_') // spaces to underscores
71
- .replace(/[/\\:*?"<>]/g, '') // remove unsafe filesystem chars
72
- .replace(/_+/g, '_'); // collapse multiple underscores
74
+ .replace(/\s+/g, '-') // spaces to hyphens (not underscores, to distinguish from position separators)
75
+ .replace(/[/\\:*?"<>]/g, ''); // remove unsafe filesystem chars
73
76
  }
74
77
 
75
78
  /**
@@ -112,7 +115,8 @@ export class TddService {
112
115
  this.diffPath = safePath(this.workingDir, '.vizzly', 'diffs');
113
116
  this.baselineData = null;
114
117
  this.comparisons = [];
115
- this.threshold = config.comparison?.threshold || 0.1;
118
+ this.threshold = config.comparison?.threshold || 2.0;
119
+ this.minClusterSize = config.comparison?.minClusterSize ?? 2; // Filter single-pixel noise by default
116
120
  this.signatureProperties = []; // Custom properties from project's baseline_signature_properties
117
121
 
118
122
  // Check if we're in baseline update mode
@@ -196,7 +200,7 @@ export class TddService {
196
200
  }
197
201
 
198
202
  // The original_url might be in baseline_screenshot.original_url or comparison.baseline_screenshot_url
199
- let baselineUrl = comparison.baseline_screenshot.original_url || comparison.baseline_screenshot_url;
203
+ const baselineUrl = comparison.baseline_screenshot.original_url || comparison.baseline_screenshot_url;
200
204
  if (!baselineUrl) {
201
205
  throw new Error(`Baseline screenshot for comparison ${comparisonId} has no download URL`);
202
206
  }
@@ -205,7 +209,7 @@ export class TddService {
205
209
  // The baseline should use the same properties (viewport/browser) as the current screenshot
206
210
  // so that generateScreenshotSignature produces the correct filename
207
211
  // Use current screenshot properties since we're downloading baseline to compare against current
208
- let screenshotProperties = {};
212
+ const screenshotProperties = {};
209
213
 
210
214
  // Build properties from comparison API fields (added in backend update)
211
215
  // Use current_* fields since we're matching against the current screenshot being tested
@@ -312,13 +316,13 @@ export class TddService {
312
316
  // Generate signature for baseline matching (same as compareScreenshot)
313
317
  // Build properties object with top-level viewport_width and browser
314
318
  // These are returned as top-level fields from the API, not inside metadata
315
- let properties = validateScreenshotProperties({
319
+ const properties = validateScreenshotProperties({
316
320
  viewport_width: screenshot.viewport_width,
317
321
  browser: screenshot.browser,
318
322
  ...(screenshot.metadata || screenshot.properties || {})
319
323
  });
320
- let signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
321
- let filename = signatureToFilename(signature);
324
+ const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
325
+ const filename = signatureToFilename(signature);
322
326
  const imagePath = safePath(this.baselinePath, `${filename}.png`);
323
327
 
324
328
  // Check if we already have this file with the same SHA (using metadata)
@@ -437,13 +441,13 @@ export class TddService {
437
441
 
438
442
  // Build properties object with top-level viewport_width and browser
439
443
  // These are returned as top-level fields from the API, not inside metadata
440
- let properties = validateScreenshotProperties({
444
+ const properties = validateScreenshotProperties({
441
445
  viewport_width: s.viewport_width,
442
446
  browser: s.browser,
443
447
  ...(s.metadata || s.properties || {})
444
448
  });
445
- let signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
446
- let filename = signatureToFilename(signature);
449
+ const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
450
+ const filename = signatureToFilename(signature);
447
451
  return {
448
452
  name: sanitizedName,
449
453
  originalName: s.name,
@@ -521,14 +525,14 @@ export class TddService {
521
525
  }
522
526
  try {
523
527
  // Get unique screenshot names
524
- let screenshotNames = [...new Set(screenshots.map(s => s.name))];
528
+ const screenshotNames = [...new Set(screenshots.map(s => s.name))];
525
529
  if (screenshotNames.length === 0) {
526
530
  return;
527
531
  }
528
532
  output.info(`🔥 Fetching hotspot data for ${screenshotNames.length} screenshots...`);
529
533
 
530
534
  // Use batch endpoint for efficiency
531
- let response = await this.api.getBatchHotspots(screenshotNames);
535
+ const response = await this.api.getBatchHotspots(screenshotNames);
532
536
  if (!response.hotspots || Object.keys(response.hotspots).length === 0) {
533
537
  output.debug('tdd', 'No hotspot data available from cloud');
534
538
  return;
@@ -536,14 +540,14 @@ export class TddService {
536
540
 
537
541
  // Store hotspots in a separate file for easy access during comparisons
538
542
  this.hotspotData = response.hotspots;
539
- let hotspotsPath = safePath(this.workingDir, '.vizzly', 'hotspots.json');
543
+ const hotspotsPath = safePath(this.workingDir, '.vizzly', 'hotspots.json');
540
544
  writeFileSync(hotspotsPath, JSON.stringify({
541
545
  downloadedAt: new Date().toISOString(),
542
546
  summary: response.summary,
543
547
  hotspots: response.hotspots
544
548
  }, null, 2));
545
- let hotspotCount = Object.keys(response.hotspots).length;
546
- let totalRegions = Object.values(response.hotspots).reduce((sum, h) => sum + (h.regions?.length || 0), 0);
549
+ const hotspotCount = Object.keys(response.hotspots).length;
550
+ const totalRegions = Object.values(response.hotspots).reduce((sum, h) => sum + (h.regions?.length || 0), 0);
547
551
  output.info(`✅ Downloaded hotspot data for ${hotspotCount} screenshots (${totalRegions} regions total)`);
548
552
  } catch (error) {
549
553
  // Don't fail baseline download if hotspot fetch fails
@@ -558,11 +562,11 @@ export class TddService {
558
562
  */
559
563
  loadHotspots() {
560
564
  try {
561
- let hotspotsPath = safePath(this.workingDir, '.vizzly', 'hotspots.json');
565
+ const hotspotsPath = safePath(this.workingDir, '.vizzly', 'hotspots.json');
562
566
  if (!existsSync(hotspotsPath)) {
563
567
  return null;
564
568
  }
565
- let data = JSON.parse(readFileSync(hotspotsPath, 'utf8'));
569
+ const data = JSON.parse(readFileSync(hotspotsPath, 'utf8'));
566
570
  return data.hotspots || null;
567
571
  } catch (error) {
568
572
  output.debug('tdd', `Failed to load hotspots: ${error.message}`);
@@ -577,7 +581,7 @@ export class TddService {
577
581
  */
578
582
  getHotspotForScreenshot(screenshotName) {
579
583
  // Check memory cache first
580
- if (this.hotspotData && this.hotspotData[screenshotName]) {
584
+ if (this.hotspotData?.[screenshotName]) {
581
585
  return this.hotspotData[screenshotName];
582
586
  }
583
587
 
@@ -614,9 +618,9 @@ export class TddService {
614
618
  // Extract Y-coordinates (diff lines) from clusters
615
619
  // Each cluster has a boundingBox with y and height
616
620
  let diffLines = [];
617
- for (let cluster of diffClusters) {
621
+ for (const cluster of diffClusters) {
618
622
  if (cluster.boundingBox) {
619
- let {
623
+ const {
620
624
  y,
621
625
  height
622
626
  } = cluster.boundingBox;
@@ -639,13 +643,13 @@ export class TddService {
639
643
 
640
644
  // Check how many diff lines fall within hotspot regions
641
645
  let linesInHotspots = 0;
642
- for (let line of diffLines) {
643
- let inHotspot = hotspotAnalysis.regions.some(region => line >= region.y1 && line <= region.y2);
646
+ for (const line of diffLines) {
647
+ const inHotspot = hotspotAnalysis.regions.some(region => line >= region.y1 && line <= region.y2);
644
648
  if (inHotspot) {
645
649
  linesInHotspots++;
646
650
  }
647
651
  }
648
- let coverage = linesInHotspots / diffLines.length;
652
+ const coverage = linesInHotspots / diffLines.length;
649
653
  return {
650
654
  coverage,
651
655
  linesInHotspots,
@@ -666,14 +670,14 @@ export class TddService {
666
670
  output.info(`Downloading baselines using OAuth from build ${buildId}...`);
667
671
  try {
668
672
  // Fetch build with screenshots via OAuth endpoint
669
- let endpoint = `/api/build/${projectSlug}/${buildId}/tdd-baselines`;
670
- let response = await authService.authenticatedRequest(endpoint, {
673
+ const endpoint = `/api/build/${projectSlug}/${buildId}/tdd-baselines`;
674
+ const response = await authService.authenticatedRequest(endpoint, {
671
675
  method: 'GET',
672
676
  headers: {
673
677
  'X-Organization': organizationSlug
674
678
  }
675
679
  });
676
- let {
680
+ const {
677
681
  build,
678
682
  screenshots,
679
683
  signatureProperties
@@ -698,8 +702,8 @@ export class TddService {
698
702
  output.info(`Checking ${colors.cyan(screenshots.length)} baseline screenshots...`);
699
703
 
700
704
  // Load existing baseline metadata for SHA comparison
701
- let existingBaseline = await this.loadBaseline();
702
- let existingShaMap = new Map();
705
+ const existingBaseline = await this.loadBaseline();
706
+ const existingShaMap = new Map();
703
707
  if (existingBaseline) {
704
708
  existingBaseline.screenshots.forEach(s => {
705
709
  if (s.sha256 && s.signature) {
@@ -712,8 +716,8 @@ export class TddService {
712
716
  let downloadedCount = 0;
713
717
  let skippedCount = 0;
714
718
  let errorCount = 0;
715
- let downloadedScreenshots = [];
716
- for (let screenshot of screenshots) {
719
+ const downloadedScreenshots = [];
720
+ for (const screenshot of screenshots) {
717
721
  let sanitizedName;
718
722
  try {
719
723
  sanitizedName = sanitizeScreenshotName(screenshot.name);
@@ -725,14 +729,14 @@ export class TddService {
725
729
 
726
730
  // Build properties object with top-level viewport_width and browser
727
731
  // These are returned as top-level fields from the API, not inside metadata
728
- let properties = validateScreenshotProperties({
732
+ const properties = validateScreenshotProperties({
729
733
  viewport_width: screenshot.viewport_width,
730
734
  browser: screenshot.browser,
731
735
  ...screenshot.metadata
732
736
  });
733
- let signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
734
- let filename = signatureToFilename(signature);
735
- let filePath = safePath(this.baselinePath, `${filename}.png`);
737
+ const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
738
+ const filename = signatureToFilename(signature);
739
+ const filePath = safePath(this.baselinePath, `${filename}.png`);
736
740
 
737
741
  // Check if we can skip via SHA comparison
738
742
  if (screenshot.sha256 && existingShaMap.get(signature) === screenshot.sha256) {
@@ -748,21 +752,21 @@ export class TddService {
748
752
  }
749
753
 
750
754
  // Download the screenshot
751
- let downloadUrl = screenshot.original_url;
755
+ const downloadUrl = screenshot.original_url;
752
756
  if (!downloadUrl) {
753
757
  output.warn(`⚠️ No download URL for screenshot: ${sanitizedName}`);
754
758
  errorCount++;
755
759
  continue;
756
760
  }
757
761
  try {
758
- let imageResponse = await fetchWithTimeout(downloadUrl, {}, 30000);
762
+ const imageResponse = await fetchWithTimeout(downloadUrl, {}, 30000);
759
763
  if (!imageResponse.ok) {
760
764
  throw new Error(`HTTP ${imageResponse.status}`);
761
765
  }
762
- let imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
766
+ const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
763
767
 
764
768
  // Calculate SHA256 of downloaded content
765
- let sha256 = crypto.createHash('sha256').update(imageBuffer).digest('hex');
769
+ const sha256 = crypto.createHash('sha256').update(imageBuffer).digest('hex');
766
770
  writeFileSync(filePath, imageBuffer);
767
771
  downloadedCount++;
768
772
  downloadedScreenshots.push({
@@ -789,11 +793,11 @@ export class TddService {
789
793
  // Store for TDD comparison
790
794
  screenshots: downloadedScreenshots
791
795
  };
792
- let metadataPath = join(this.baselinePath, 'metadata.json');
796
+ const metadataPath = join(this.baselinePath, 'metadata.json');
793
797
  writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
794
798
 
795
799
  // Save baseline build metadata
796
- let baselineMetadataPath = safePath(this.workingDir, '.vizzly', 'baseline-metadata.json');
800
+ const baselineMetadataPath = safePath(this.workingDir, '.vizzly', 'baseline-metadata.json');
797
801
  writeFileSync(baselineMetadataPath, JSON.stringify({
798
802
  buildId: build.id,
799
803
  buildName: build.name,
@@ -973,12 +977,14 @@ export class TddService {
973
977
  try {
974
978
  // Try to compare - honeydiff will throw if dimensions don't match
975
979
  const result = await compare(baselineImagePath, currentImagePath, {
976
- colorThreshold: this.threshold,
977
- // YIQ color threshold (0.0-1.0), default 0.1
980
+ threshold: this.threshold,
981
+ // CIEDE2000 Delta E (2.0 = recommended default)
978
982
  antialiasing: true,
979
983
  diffPath: diffImagePath,
980
984
  overwrite: true,
981
- includeClusters: true // Enable spatial clustering analysis
985
+ includeClusters: true,
986
+ // Enable spatial clustering analysis
987
+ minClusterSize: this.minClusterSize // Filter single-pixel noise (default: 2)
982
988
  });
983
989
  if (!result.isDifferent) {
984
990
  // Images match
@@ -1003,7 +1009,7 @@ export class TddService {
1003
1009
  return comparison;
1004
1010
  } else {
1005
1011
  // Images differ - check if differences are in known hotspot regions
1006
- let hotspotAnalysis = this.getHotspotForScreenshot(name);
1012
+ const hotspotAnalysis = this.getHotspotForScreenshot(name);
1007
1013
  let hotspotCoverage = null;
1008
1014
  let isHotspotFiltered = false;
1009
1015
  if (hotspotAnalysis && result.diffClusters && result.diffClusters.length > 0) {
@@ -1012,7 +1018,7 @@ export class TddService {
1012
1018
  // Consider it filtered if:
1013
1019
  // 1. High confidence hotspot data (score >= 70)
1014
1020
  // 2. 80%+ of the diff is within hotspot regions
1015
- let isHighConfidence = hotspotAnalysis.confidence === 'high' || hotspotAnalysis.confidence_score && hotspotAnalysis.confidence_score >= 70;
1021
+ const isHighConfidence = hotspotAnalysis.confidence === 'high' || hotspotAnalysis.confidence_score && hotspotAnalysis.confidence_score >= 70;
1016
1022
  if (isHighConfidence && hotspotCoverage.coverage >= 0.8) {
1017
1023
  isHotspotFiltered = true;
1018
1024
  }
@@ -1072,7 +1078,7 @@ export class TddService {
1072
1078
  }
1073
1079
  } catch (error) {
1074
1080
  // Check if error is due to dimension mismatch
1075
- const isDimensionMismatch = error.message && error.message.includes("Image dimensions don't match");
1081
+ const isDimensionMismatch = error.message?.includes("Image dimensions don't match");
1076
1082
  if (isDimensionMismatch) {
1077
1083
  // Different dimensions = different screenshot signature
1078
1084
  // This shouldn't happen if signatures are working correctly, but handle gracefully
@@ -1208,7 +1214,7 @@ export class TddService {
1208
1214
  });
1209
1215
 
1210
1216
  // Show report path (always clickable)
1211
- output.info(`\n🐻 View detailed report: ${colors.cyan('file://' + reportPath)}`);
1217
+ output.info(`\n🐻 View detailed report: ${colors.cyan(`file://${reportPath}`)}`);
1212
1218
 
1213
1219
  // Auto-open if configured
1214
1220
  if (this.config.tdd?.openReport) {
@@ -1228,10 +1234,10 @@ export class TddService {
1228
1234
  try {
1229
1235
  const {
1230
1236
  exec
1231
- } = await import('child_process');
1237
+ } = await import('node:child_process');
1232
1238
  const {
1233
1239
  promisify
1234
- } = await import('util');
1240
+ } = await import('node:util');
1235
1241
  const execAsync = promisify(exec);
1236
1242
  let command;
1237
1243
  switch (process.platform) {
@@ -1295,9 +1301,9 @@ export class TddService {
1295
1301
  output.warn(`Skipping baseline update for invalid name '${name}': ${error.message}`);
1296
1302
  continue;
1297
1303
  }
1298
- let validatedProperties = validateScreenshotProperties(comparison.properties || {});
1299
- let signature = generateScreenshotSignature(sanitizedName, validatedProperties, this.signatureProperties);
1300
- let filename = signatureToFilename(signature);
1304
+ const validatedProperties = validateScreenshotProperties(comparison.properties || {});
1305
+ const signature = generateScreenshotSignature(sanitizedName, validatedProperties, this.signatureProperties);
1306
+ const filename = signatureToFilename(signature);
1301
1307
  const baselineImagePath = safePath(this.baselinePath, `${filename}.png`);
1302
1308
  try {
1303
1309
  // Copy current screenshot to baseline
@@ -1360,7 +1366,7 @@ export class TddService {
1360
1366
  }
1361
1367
 
1362
1368
  // Generate signature for this screenshot
1363
- let signature = generateScreenshotSignature(name, properties || {}, this.signatureProperties);
1369
+ const signature = generateScreenshotSignature(name, properties || {}, this.signatureProperties);
1364
1370
 
1365
1371
  // Add screenshot to baseline metadata
1366
1372
  const screenshotEntry = {
@@ -1417,7 +1423,7 @@ export class TddService {
1417
1423
  }
1418
1424
 
1419
1425
  // Generate signature for this screenshot
1420
- let signature = generateScreenshotSignature(name, properties || {}, this.signatureProperties);
1426
+ const signature = generateScreenshotSignature(name, properties || {}, this.signatureProperties);
1421
1427
 
1422
1428
  // Add screenshot to baseline metadata
1423
1429
  const screenshotEntry = {
@@ -1471,9 +1477,9 @@ export class TddService {
1471
1477
  comparison = idOrComparison;
1472
1478
  }
1473
1479
  const sanitizedName = comparison.name;
1474
- let properties = comparison.properties || {};
1475
- let signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
1476
- let filename = signatureToFilename(signature);
1480
+ const properties = comparison.properties || {};
1481
+ const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
1482
+ const filename = signatureToFilename(signature);
1477
1483
 
1478
1484
  // Find the current screenshot file
1479
1485
  const currentImagePath = safePath(this.currentPath, `${filename}.png`);
@@ -3,9 +3,9 @@
3
3
  * Orchestrates the test execution flow
4
4
  */
5
5
 
6
- import { EventEmitter } from 'events';
6
+ import { spawn } from 'node:child_process';
7
+ import { EventEmitter } from 'node:events';
7
8
  import { VizzlyError } from '../errors/vizzly-error.js';
8
- import { spawn } from 'child_process';
9
9
  import * as output from '../utils/output.js';
10
10
  export class TestRunner extends EventEmitter {
11
11
  constructor(config, buildManager, serverManager, tddService) {
@@ -178,7 +178,7 @@ export class TestRunner extends EventEmitter {
178
178
  // API mode: create build via API
179
179
  const apiService = await this.createApiService();
180
180
  if (apiService) {
181
- const buildResult = await apiService.createBuild({
181
+ const buildPayload = {
182
182
  name: options.buildName || `Test Run ${new Date().toISOString()}`,
183
183
  branch: options.branch || 'main',
184
184
  environment: options.environment || 'test',
@@ -186,7 +186,17 @@ export class TestRunner extends EventEmitter {
186
186
  commit_message: options.message,
187
187
  github_pull_request_number: options.pullRequestNumber,
188
188
  parallel_id: options.parallelId
189
- });
189
+ };
190
+
191
+ // Only include metadata if we have meaningful config to send
192
+ if (this.config.comparison?.threshold != null) {
193
+ buildPayload.metadata = {
194
+ comparison: {
195
+ threshold: this.config.comparison.threshold
196
+ }
197
+ };
198
+ }
199
+ const buildResult = await apiService.createBuild(buildPayload);
190
200
  output.debug('build', `created ${buildResult.id}`);
191
201
 
192
202
  // Emit build created event
@@ -3,14 +3,14 @@
3
3
  * Handles screenshot uploads to the Vizzly platform
4
4
  */
5
5
 
6
+ import crypto from 'node:crypto';
7
+ import { readFile, stat } from 'node:fs/promises';
8
+ import { basename } from 'node:path';
6
9
  import { glob } from 'glob';
7
- import { readFile, stat } from 'fs/promises';
8
- import { basename } from 'path';
9
- import crypto from 'crypto';
10
+ import { TimeoutError, UploadError, ValidationError } from '../errors/vizzly-error.js';
11
+ import { getDefaultBranch } from '../utils/git.js';
10
12
  import * as output from '../utils/output.js';
11
13
  import { ApiService } from './api-service.js';
12
- import { getDefaultBranch } from '../utils/git.js';
13
- import { UploadError, TimeoutError, ValidationError } from '../errors/vizzly-error.js';
14
14
  const DEFAULT_BATCH_SIZE = 50;
15
15
  const DEFAULT_SHA_CHECK_BATCH_SIZE = 100;
16
16
  const DEFAULT_TIMEOUT = 30000; // 30 seconds
@@ -25,7 +25,7 @@ export function createUploader({
25
25
  command,
26
26
  upload: uploadConfig = {}
27
27
  } = {}, options = {}) {
28
- let signal = options.signal || new AbortController().signal;
28
+ const signal = options.signal || new AbortController().signal;
29
29
  const api = new ApiService({
30
30
  baseUrl: apiUrl,
31
31
  token: apiKey,
@@ -160,7 +160,7 @@ export function createUploader({
160
160
  });
161
161
 
162
162
  // Re-throw if already a VizzlyError
163
- if (error.name && error.name.includes('Error') && error.code) {
163
+ if (error.name?.includes('Error') && error.code) {
164
164
  throw error;
165
165
  }
166
166
 
@@ -304,7 +304,9 @@ async function checkExistingFiles(fileMetadata, api, signal, buildId) {
304
304
  existing = [],
305
305
  screenshots = []
306
306
  } = res || {};
307
- existing.forEach(sha => existingShas.add(sha));
307
+ for (let sha of existing) {
308
+ existingShas.add(sha);
309
+ }
308
310
  allScreenshots.push(...screenshots);
309
311
  } catch (error) {
310
312
  // Continue without deduplication on error
@@ -20,7 +20,8 @@ export { VizzlyConfig } from './index';
20
20
  * port: 47392
21
21
  * },
22
22
  * comparison: {
23
- * threshold: 0.1
23
+ * threshold: 2.0, // CIEDE2000 Delta E (0=exact, 1=JND, 2=recommended)
24
+ * minClusterSize: 2 // Filter single-pixel noise (1=exact, 2=default, 3+=permissive)
24
25
  * }
25
26
  * });
26
27
  */
@@ -3,7 +3,7 @@
3
3
  * @module @vizzly-testing/cli
4
4
  */
5
5
 
6
- import { EventEmitter } from 'events';
6
+ import { EventEmitter } from 'node:events';
7
7
 
8
8
  // ============================================================================
9
9
  // Configuration Types
@@ -29,7 +29,17 @@ export interface UploadConfig {
29
29
  }
30
30
 
31
31
  export interface ComparisonConfig {
32
+ /** CIEDE2000 Delta E threshold (0=exact, 1=JND, 2=recommended default) */
32
33
  threshold?: number;
34
+ /**
35
+ * Minimum cluster size to count as a real difference.
36
+ * Filters out scattered single-pixel noise from rendering variance.
37
+ * - 1 = Exact matching (any different pixel counts)
38
+ * - 2 = Default (filters single isolated pixels as noise)
39
+ * - 3+ = More permissive (only larger clusters detected)
40
+ * @default 2
41
+ */
42
+ minClusterSize?: number;
33
43
  }
34
44
 
35
45
  export interface TddConfig {
@@ -4,7 +4,7 @@
4
4
  * @module @vizzly-testing/cli/sdk
5
5
  */
6
6
 
7
- import { EventEmitter } from 'events';
7
+ import { EventEmitter } from 'node:events';
8
8
 
9
9
  // Re-export common types
10
10
  export {