@vizzly-testing/cli 0.17.0 → 0.19.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 (66) hide show
  1. package/dist/cli.js +87 -59
  2. package/dist/client/index.js +6 -6
  3. package/dist/commands/doctor.js +15 -15
  4. package/dist/commands/finalize.js +7 -7
  5. package/dist/commands/init.js +28 -28
  6. package/dist/commands/login.js +23 -23
  7. package/dist/commands/logout.js +4 -4
  8. package/dist/commands/project.js +36 -36
  9. package/dist/commands/run.js +33 -33
  10. package/dist/commands/status.js +14 -14
  11. package/dist/commands/tdd-daemon.js +43 -43
  12. package/dist/commands/tdd.js +26 -26
  13. package/dist/commands/upload.js +32 -32
  14. package/dist/commands/whoami.js +12 -12
  15. package/dist/index.js +9 -14
  16. package/dist/plugin-api.js +43 -0
  17. package/dist/plugin-loader.js +28 -28
  18. package/dist/reporter/reporter-bundle.css +1 -1
  19. package/dist/reporter/reporter-bundle.iife.js +19 -19
  20. package/dist/sdk/index.js +33 -35
  21. package/dist/server/handlers/api-handler.js +4 -4
  22. package/dist/server/handlers/tdd-handler.js +22 -21
  23. package/dist/server/http-server.js +21 -22
  24. package/dist/server/middleware/json-parser.js +1 -1
  25. package/dist/server/routers/assets.js +14 -14
  26. package/dist/server/routers/auth.js +14 -14
  27. package/dist/server/routers/baseline.js +8 -8
  28. package/dist/server/routers/cloud-proxy.js +15 -15
  29. package/dist/server/routers/config.js +11 -11
  30. package/dist/server/routers/dashboard.js +11 -11
  31. package/dist/server/routers/health.js +4 -4
  32. package/dist/server/routers/projects.js +19 -19
  33. package/dist/server/routers/screenshot.js +9 -9
  34. package/dist/services/api-service.js +16 -16
  35. package/dist/services/auth-service.js +17 -17
  36. package/dist/services/build-manager.js +3 -3
  37. package/dist/services/config-service.js +32 -32
  38. package/dist/services/html-report-generator.js +8 -8
  39. package/dist/services/index.js +11 -11
  40. package/dist/services/project-service.js +19 -19
  41. package/dist/services/report-generator/report.css +3 -3
  42. package/dist/services/report-generator/viewer.js +25 -23
  43. package/dist/services/screenshot-server.js +1 -1
  44. package/dist/services/server-manager.js +5 -5
  45. package/dist/services/static-report-generator.js +14 -14
  46. package/dist/services/tdd-service.js +152 -110
  47. package/dist/services/test-runner.js +3 -3
  48. package/dist/services/uploader.js +10 -8
  49. package/dist/types/config.d.ts +2 -1
  50. package/dist/types/index.d.ts +95 -1
  51. package/dist/types/sdk.d.ts +1 -1
  52. package/dist/utils/browser.js +3 -3
  53. package/dist/utils/build-history.js +12 -12
  54. package/dist/utils/config-loader.js +17 -17
  55. package/dist/utils/config-schema.js +6 -6
  56. package/dist/utils/environment-config.js +11 -0
  57. package/dist/utils/fetch-utils.js +2 -2
  58. package/dist/utils/file-helpers.js +2 -2
  59. package/dist/utils/git.js +3 -6
  60. package/dist/utils/global-config.js +28 -25
  61. package/dist/utils/output.js +136 -28
  62. package/dist/utils/package-info.js +3 -3
  63. package/dist/utils/security.js +12 -12
  64. package/docs/api-reference.md +52 -23
  65. package/docs/plugins.md +60 -25
  66. 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,25 +1,44 @@
1
- import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
2
- import { join } from 'path';
1
+ /**
2
+ * TDD Service - Local Visual Testing
3
+ *
4
+ * āš ļø CRITICAL: Signature/filename generation MUST stay in sync with the cloud!
5
+ *
6
+ * Cloud counterpart: vizzly/src/utils/screenshot-identity.js
7
+ * - generateScreenshotSignature()
8
+ * - generateBaselineFilename()
9
+ *
10
+ * Contract tests: Both repos have golden tests that must produce identical values:
11
+ * - Cloud: tests/contracts/signature-parity.test.js
12
+ * - CLI: tests/contracts/signature-parity.spec.js
13
+ *
14
+ * If you modify signature or filename generation here, you MUST:
15
+ * 1. Make the same change in the cloud repo
16
+ * 2. Update golden test values in BOTH repos
17
+ * 3. Run contract tests in both repos to verify parity
18
+ *
19
+ * The signature format is: name|viewport_width|browser|custom1|custom2|...
20
+ * The filename format is: {sanitized-name}_{12-char-sha256-hash}.png
21
+ */
22
+
23
+ import crypto from 'node:crypto';
24
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
25
+ import { join } from 'node:path';
3
26
  import { compare } from '@vizzly-testing/honeydiff';
4
- import crypto from 'crypto';
27
+ import { NetworkError } from '../errors/vizzly-error.js';
5
28
  import { ApiService } from '../services/api-service.js';
6
- import * as output from '../utils/output.js';
7
29
  import { colors } from '../utils/colors.js';
8
- import { getDefaultBranch } from '../utils/git.js';
9
30
  import { fetchWithTimeout } from '../utils/fetch-utils.js';
10
- import { NetworkError } from '../errors/vizzly-error.js';
31
+ import { getDefaultBranch } from '../utils/git.js';
32
+ import * as output from '../utils/output.js';
33
+ import { safePath, sanitizeScreenshotName, validatePathSecurity, validateScreenshotProperties } from '../utils/security.js';
11
34
  import { HtmlReportGenerator } from './html-report-generator.js';
12
- import { sanitizeScreenshotName, validatePathSecurity, safePath, validateScreenshotProperties } from '../utils/security.js';
13
35
 
14
36
  /**
15
37
  * Generate a screenshot signature for baseline matching
16
- * Uses same logic as screenshot-identity.js: name + viewport_width + browser + custom properties
17
38
  *
18
- * Matches backend signature generation which uses:
19
- * - screenshot.name
20
- * - screenshot.viewport_width (top-level property)
21
- * - screenshot.browser (top-level property)
22
- * - custom properties from project's baseline_signature_properties setting
39
+ * āš ļø SYNC WITH: vizzly/src/utils/screenshot-identity.js - generateScreenshotSignature()
40
+ *
41
+ * Uses same logic as cloud: name + viewport_width + browser + custom properties
23
42
  *
24
43
  * @param {string} name - Screenshot name
25
44
  * @param {Object} properties - Screenshot properties (viewport, browser, metadata, etc.)
@@ -27,49 +46,58 @@ import { sanitizeScreenshotName, validatePathSecurity, safePath, validateScreens
27
46
  * @returns {string} Signature like "Login|1920|chrome|iPhone 15 Pro"
28
47
  */
29
48
  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
- }
49
+ // Match cloud screenshot-identity.js behavior exactly:
50
+ // Always include all default properties (name, viewport_width, browser)
51
+ // even if null/undefined, using empty string as placeholder
52
+ const defaultProperties = ['name', 'viewport_width', 'browser'];
53
+ const allProperties = [...defaultProperties, ...customProperties];
54
+ const parts = allProperties.map(propName => {
55
+ let value;
56
+ if (propName === 'name') {
57
+ value = name;
58
+ } else if (propName === 'viewport_width') {
59
+ // Check for viewport_width as top-level property first (backend format)
60
+ value = properties.viewport_width;
61
+ // Fallback to nested viewport.width (SDK format)
62
+ if (value === null || value === undefined) {
63
+ value = properties.viewport?.width;
64
+ }
65
+ } else if (propName === 'browser') {
66
+ value = properties.browser;
67
+ } else {
68
+ // Custom property - check multiple locations
69
+ value = properties[propName] ?? properties.metadata?.[propName] ?? properties.metadata?.properties?.[propName];
70
+ }
49
71
 
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] ?? '';
72
+ // Handle null/undefined values consistently (match cloud behavior)
73
+ if (value === null || value === undefined) {
74
+ return '';
75
+ }
57
76
 
58
- // Normalize: convert to string, trim whitespace
59
- parts.push(String(value).trim());
60
- }
77
+ // Convert to string and normalize
78
+ return String(value).trim();
79
+ });
61
80
  return parts.join('|');
62
81
  }
63
82
 
64
83
  /**
65
- * Create a safe filename from signature
66
- * Handles custom property values that may contain spaces or special characters
84
+ * Generate a stable, filesystem-safe filename for a screenshot baseline
85
+ * Uses a hash of the signature to avoid character encoding issues
86
+ * Matches the cloud's generateBaselineFilename implementation exactly
87
+ *
88
+ * @param {string} name - Screenshot name
89
+ * @param {string} signature - Full signature string
90
+ * @returns {string} Filename like "homepage_a1b2c3d4e5f6.png"
67
91
  */
68
- function signatureToFilename(signature) {
69
- 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
92
+ function generateBaselineFilename(name, signature) {
93
+ const hash = crypto.createHash('sha256').update(signature).digest('hex').slice(0, 12);
94
+
95
+ // Sanitize the name for filesystem safety
96
+ const safeName = name.replace(/[/\\:*?"<>|]/g, '') // Remove unsafe chars
97
+ .replace(/\s+/g, '-') // Spaces to hyphens
98
+ .slice(0, 50); // Limit length
99
+
100
+ return `${safeName}_${hash}.png`;
73
101
  }
74
102
 
75
103
  /**
@@ -113,6 +141,7 @@ export class TddService {
113
141
  this.baselineData = null;
114
142
  this.comparisons = [];
115
143
  this.threshold = config.comparison?.threshold || 2.0;
144
+ this.minClusterSize = config.comparison?.minClusterSize ?? 2; // Filter single-pixel noise by default
116
145
  this.signatureProperties = []; // Custom properties from project's baseline_signature_properties
117
146
 
118
147
  // Check if we're in baseline update mode
@@ -196,7 +225,7 @@ export class TddService {
196
225
  }
197
226
 
198
227
  // 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;
228
+ const baselineUrl = comparison.baseline_screenshot.original_url || comparison.baseline_screenshot_url;
200
229
  if (!baselineUrl) {
201
230
  throw new Error(`Baseline screenshot for comparison ${comparisonId} has no download URL`);
202
231
  }
@@ -205,7 +234,7 @@ export class TddService {
205
234
  // The baseline should use the same properties (viewport/browser) as the current screenshot
206
235
  // so that generateScreenshotSignature produces the correct filename
207
236
  // Use current screenshot properties since we're downloading baseline to compare against current
208
- let screenshotProperties = {};
237
+ const screenshotProperties = {};
209
238
 
210
239
  // Build properties from comparison API fields (added in backend update)
211
240
  // Use current_* fields since we're matching against the current screenshot being tested
@@ -312,14 +341,17 @@ export class TddService {
312
341
  // Generate signature for baseline matching (same as compareScreenshot)
313
342
  // Build properties object with top-level viewport_width and browser
314
343
  // These are returned as top-level fields from the API, not inside metadata
315
- let properties = validateScreenshotProperties({
344
+ const properties = validateScreenshotProperties({
316
345
  viewport_width: screenshot.viewport_width,
317
346
  browser: screenshot.browser,
318
347
  ...(screenshot.metadata || screenshot.properties || {})
319
348
  });
320
- let signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
321
- let filename = signatureToFilename(signature);
322
- const imagePath = safePath(this.baselinePath, `${filename}.png`);
349
+ const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
350
+
351
+ // Use API-provided filename if available, otherwise generate hash-based filename
352
+ // Both return the full filename with .png extension
353
+ const filename = screenshot.filename || generateBaselineFilename(sanitizedName, signature);
354
+ const imagePath = safePath(this.baselinePath, filename);
323
355
 
324
356
  // Check if we already have this file with the same SHA (using metadata)
325
357
  if (existsSync(imagePath) && screenshot.sha256) {
@@ -437,13 +469,13 @@ export class TddService {
437
469
 
438
470
  // Build properties object with top-level viewport_width and browser
439
471
  // These are returned as top-level fields from the API, not inside metadata
440
- let properties = validateScreenshotProperties({
472
+ const properties = validateScreenshotProperties({
441
473
  viewport_width: s.viewport_width,
442
474
  browser: s.browser,
443
475
  ...(s.metadata || s.properties || {})
444
476
  });
445
- let signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
446
- let filename = signatureToFilename(signature);
477
+ const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
478
+ const filename = generateBaselineFilename(sanitizedName, signature);
447
479
  return {
448
480
  name: sanitizedName,
449
481
  originalName: s.name,
@@ -451,7 +483,7 @@ export class TddService {
451
483
  // Store remote SHA for quick comparison
452
484
  id: s.id,
453
485
  properties: properties,
454
- path: safePath(this.baselinePath, `${filename}.png`),
486
+ path: safePath(this.baselinePath, filename),
455
487
  signature: signature,
456
488
  originalUrl: s.original_url,
457
489
  fileSize: s.file_size_bytes,
@@ -521,14 +553,14 @@ export class TddService {
521
553
  }
522
554
  try {
523
555
  // Get unique screenshot names
524
- let screenshotNames = [...new Set(screenshots.map(s => s.name))];
556
+ const screenshotNames = [...new Set(screenshots.map(s => s.name))];
525
557
  if (screenshotNames.length === 0) {
526
558
  return;
527
559
  }
528
560
  output.info(`šŸ”„ Fetching hotspot data for ${screenshotNames.length} screenshots...`);
529
561
 
530
562
  // Use batch endpoint for efficiency
531
- let response = await this.api.getBatchHotspots(screenshotNames);
563
+ const response = await this.api.getBatchHotspots(screenshotNames);
532
564
  if (!response.hotspots || Object.keys(response.hotspots).length === 0) {
533
565
  output.debug('tdd', 'No hotspot data available from cloud');
534
566
  return;
@@ -536,14 +568,14 @@ export class TddService {
536
568
 
537
569
  // Store hotspots in a separate file for easy access during comparisons
538
570
  this.hotspotData = response.hotspots;
539
- let hotspotsPath = safePath(this.workingDir, '.vizzly', 'hotspots.json');
571
+ const hotspotsPath = safePath(this.workingDir, '.vizzly', 'hotspots.json');
540
572
  writeFileSync(hotspotsPath, JSON.stringify({
541
573
  downloadedAt: new Date().toISOString(),
542
574
  summary: response.summary,
543
575
  hotspots: response.hotspots
544
576
  }, 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);
577
+ const hotspotCount = Object.keys(response.hotspots).length;
578
+ const totalRegions = Object.values(response.hotspots).reduce((sum, h) => sum + (h.regions?.length || 0), 0);
547
579
  output.info(`āœ… Downloaded hotspot data for ${hotspotCount} screenshots (${totalRegions} regions total)`);
548
580
  } catch (error) {
549
581
  // Don't fail baseline download if hotspot fetch fails
@@ -558,11 +590,11 @@ export class TddService {
558
590
  */
559
591
  loadHotspots() {
560
592
  try {
561
- let hotspotsPath = safePath(this.workingDir, '.vizzly', 'hotspots.json');
593
+ const hotspotsPath = safePath(this.workingDir, '.vizzly', 'hotspots.json');
562
594
  if (!existsSync(hotspotsPath)) {
563
595
  return null;
564
596
  }
565
- let data = JSON.parse(readFileSync(hotspotsPath, 'utf8'));
597
+ const data = JSON.parse(readFileSync(hotspotsPath, 'utf8'));
566
598
  return data.hotspots || null;
567
599
  } catch (error) {
568
600
  output.debug('tdd', `Failed to load hotspots: ${error.message}`);
@@ -577,7 +609,7 @@ export class TddService {
577
609
  */
578
610
  getHotspotForScreenshot(screenshotName) {
579
611
  // Check memory cache first
580
- if (this.hotspotData && this.hotspotData[screenshotName]) {
612
+ if (this.hotspotData?.[screenshotName]) {
581
613
  return this.hotspotData[screenshotName];
582
614
  }
583
615
 
@@ -614,9 +646,9 @@ export class TddService {
614
646
  // Extract Y-coordinates (diff lines) from clusters
615
647
  // Each cluster has a boundingBox with y and height
616
648
  let diffLines = [];
617
- for (let cluster of diffClusters) {
649
+ for (const cluster of diffClusters) {
618
650
  if (cluster.boundingBox) {
619
- let {
651
+ const {
620
652
  y,
621
653
  height
622
654
  } = cluster.boundingBox;
@@ -639,13 +671,13 @@ export class TddService {
639
671
 
640
672
  // Check how many diff lines fall within hotspot regions
641
673
  let linesInHotspots = 0;
642
- for (let line of diffLines) {
643
- let inHotspot = hotspotAnalysis.regions.some(region => line >= region.y1 && line <= region.y2);
674
+ for (const line of diffLines) {
675
+ const inHotspot = hotspotAnalysis.regions.some(region => line >= region.y1 && line <= region.y2);
644
676
  if (inHotspot) {
645
677
  linesInHotspots++;
646
678
  }
647
679
  }
648
- let coverage = linesInHotspots / diffLines.length;
680
+ const coverage = linesInHotspots / diffLines.length;
649
681
  return {
650
682
  coverage,
651
683
  linesInHotspots,
@@ -666,14 +698,14 @@ export class TddService {
666
698
  output.info(`Downloading baselines using OAuth from build ${buildId}...`);
667
699
  try {
668
700
  // Fetch build with screenshots via OAuth endpoint
669
- let endpoint = `/api/build/${projectSlug}/${buildId}/tdd-baselines`;
670
- let response = await authService.authenticatedRequest(endpoint, {
701
+ const endpoint = `/api/build/${projectSlug}/${buildId}/tdd-baselines`;
702
+ const response = await authService.authenticatedRequest(endpoint, {
671
703
  method: 'GET',
672
704
  headers: {
673
705
  'X-Organization': organizationSlug
674
706
  }
675
707
  });
676
- let {
708
+ const {
677
709
  build,
678
710
  screenshots,
679
711
  signatureProperties
@@ -698,8 +730,8 @@ export class TddService {
698
730
  output.info(`Checking ${colors.cyan(screenshots.length)} baseline screenshots...`);
699
731
 
700
732
  // Load existing baseline metadata for SHA comparison
701
- let existingBaseline = await this.loadBaseline();
702
- let existingShaMap = new Map();
733
+ const existingBaseline = await this.loadBaseline();
734
+ const existingShaMap = new Map();
703
735
  if (existingBaseline) {
704
736
  existingBaseline.screenshots.forEach(s => {
705
737
  if (s.sha256 && s.signature) {
@@ -712,8 +744,8 @@ export class TddService {
712
744
  let downloadedCount = 0;
713
745
  let skippedCount = 0;
714
746
  let errorCount = 0;
715
- let downloadedScreenshots = [];
716
- for (let screenshot of screenshots) {
747
+ const downloadedScreenshots = [];
748
+ for (const screenshot of screenshots) {
717
749
  let sanitizedName;
718
750
  try {
719
751
  sanitizedName = sanitizeScreenshotName(screenshot.name);
@@ -725,14 +757,15 @@ export class TddService {
725
757
 
726
758
  // Build properties object with top-level viewport_width and browser
727
759
  // These are returned as top-level fields from the API, not inside metadata
728
- let properties = validateScreenshotProperties({
760
+ const properties = validateScreenshotProperties({
729
761
  viewport_width: screenshot.viewport_width,
730
762
  browser: screenshot.browser,
731
763
  ...screenshot.metadata
732
764
  });
733
- let signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
734
- let filename = signatureToFilename(signature);
735
- let filePath = safePath(this.baselinePath, `${filename}.png`);
765
+ const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
766
+ // Use API-provided filename if available, otherwise generate hash-based filename
767
+ const filename = screenshot.filename || generateBaselineFilename(sanitizedName, signature);
768
+ const filePath = safePath(this.baselinePath, filename);
736
769
 
737
770
  // Check if we can skip via SHA comparison
738
771
  if (screenshot.sha256 && existingShaMap.get(signature) === screenshot.sha256) {
@@ -748,21 +781,21 @@ export class TddService {
748
781
  }
749
782
 
750
783
  // Download the screenshot
751
- let downloadUrl = screenshot.original_url;
784
+ const downloadUrl = screenshot.original_url;
752
785
  if (!downloadUrl) {
753
786
  output.warn(`āš ļø No download URL for screenshot: ${sanitizedName}`);
754
787
  errorCount++;
755
788
  continue;
756
789
  }
757
790
  try {
758
- let imageResponse = await fetchWithTimeout(downloadUrl, {}, 30000);
791
+ const imageResponse = await fetchWithTimeout(downloadUrl, {}, 30000);
759
792
  if (!imageResponse.ok) {
760
793
  throw new Error(`HTTP ${imageResponse.status}`);
761
794
  }
762
- let imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
795
+ const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
763
796
 
764
797
  // Calculate SHA256 of downloaded content
765
- let sha256 = crypto.createHash('sha256').update(imageBuffer).digest('hex');
798
+ const sha256 = crypto.createHash('sha256').update(imageBuffer).digest('hex');
766
799
  writeFileSync(filePath, imageBuffer);
767
800
  downloadedCount++;
768
801
  downloadedScreenshots.push({
@@ -789,11 +822,11 @@ export class TddService {
789
822
  // Store for TDD comparison
790
823
  screenshots: downloadedScreenshots
791
824
  };
792
- let metadataPath = join(this.baselinePath, 'metadata.json');
825
+ const metadataPath = join(this.baselinePath, 'metadata.json');
793
826
  writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
794
827
 
795
828
  // Save baseline build metadata
796
- let baselineMetadataPath = safePath(this.workingDir, '.vizzly', 'baseline-metadata.json');
829
+ const baselineMetadataPath = safePath(this.workingDir, '.vizzly', 'baseline-metadata.json');
797
830
  writeFileSync(baselineMetadataPath, JSON.stringify({
798
831
  buildId: build.id,
799
832
  buildName: build.name,
@@ -896,6 +929,12 @@ export class TddService {
896
929
  validatedProperties = {};
897
930
  }
898
931
 
932
+ // Preserve metadata object through validation (validateScreenshotProperties strips non-primitives)
933
+ // This is needed because signature generation checks properties.metadata.* for custom properties
934
+ if (properties.metadata && typeof properties.metadata === 'object') {
935
+ validatedProperties.metadata = properties.metadata;
936
+ }
937
+
899
938
  // Normalize properties to match backend format (viewport_width at top level)
900
939
  // This ensures signature generation matches backend's screenshot-identity.js
901
940
  if (validatedProperties.viewport?.width && !validatedProperties.viewport_width) {
@@ -904,10 +943,11 @@ export class TddService {
904
943
 
905
944
  // Generate signature for baseline matching (name + viewport_width + browser + custom props)
906
945
  const signature = generateScreenshotSignature(sanitizedName, validatedProperties, this.signatureProperties);
907
- const filename = signatureToFilename(signature);
908
- const currentImagePath = safePath(this.currentPath, `${filename}.png`);
909
- const baselineImagePath = safePath(this.baselinePath, `${filename}.png`);
910
- const diffImagePath = safePath(this.diffPath, `${filename}.png`);
946
+ // Use hash-based filename for reliable matching (matches cloud format)
947
+ const filename = generateBaselineFilename(sanitizedName, signature);
948
+ const currentImagePath = safePath(this.currentPath, filename);
949
+ const baselineImagePath = safePath(this.baselinePath, filename);
950
+ const diffImagePath = safePath(this.diffPath, filename);
911
951
 
912
952
  // Save current screenshot
913
953
  writeFileSync(currentImagePath, imageBuffer);
@@ -978,7 +1018,9 @@ export class TddService {
978
1018
  antialiasing: true,
979
1019
  diffPath: diffImagePath,
980
1020
  overwrite: true,
981
- includeClusters: true // Enable spatial clustering analysis
1021
+ includeClusters: true,
1022
+ // Enable spatial clustering analysis
1023
+ minClusterSize: this.minClusterSize // Filter single-pixel noise (default: 2)
982
1024
  });
983
1025
  if (!result.isDifferent) {
984
1026
  // Images match
@@ -1003,7 +1045,7 @@ export class TddService {
1003
1045
  return comparison;
1004
1046
  } else {
1005
1047
  // Images differ - check if differences are in known hotspot regions
1006
- let hotspotAnalysis = this.getHotspotForScreenshot(name);
1048
+ const hotspotAnalysis = this.getHotspotForScreenshot(name);
1007
1049
  let hotspotCoverage = null;
1008
1050
  let isHotspotFiltered = false;
1009
1051
  if (hotspotAnalysis && result.diffClusters && result.diffClusters.length > 0) {
@@ -1012,7 +1054,7 @@ export class TddService {
1012
1054
  // Consider it filtered if:
1013
1055
  // 1. High confidence hotspot data (score >= 70)
1014
1056
  // 2. 80%+ of the diff is within hotspot regions
1015
- let isHighConfidence = hotspotAnalysis.confidence === 'high' || hotspotAnalysis.confidence_score && hotspotAnalysis.confidence_score >= 70;
1057
+ const isHighConfidence = hotspotAnalysis.confidence === 'high' || hotspotAnalysis.confidence_score && hotspotAnalysis.confidence_score >= 70;
1016
1058
  if (isHighConfidence && hotspotCoverage.coverage >= 0.8) {
1017
1059
  isHotspotFiltered = true;
1018
1060
  }
@@ -1072,7 +1114,7 @@ export class TddService {
1072
1114
  }
1073
1115
  } catch (error) {
1074
1116
  // Check if error is due to dimension mismatch
1075
- const isDimensionMismatch = error.message && error.message.includes("Image dimensions don't match");
1117
+ const isDimensionMismatch = error.message?.includes("Image dimensions don't match");
1076
1118
  if (isDimensionMismatch) {
1077
1119
  // Different dimensions = different screenshot signature
1078
1120
  // This shouldn't happen if signatures are working correctly, but handle gracefully
@@ -1208,7 +1250,7 @@ export class TddService {
1208
1250
  });
1209
1251
 
1210
1252
  // Show report path (always clickable)
1211
- output.info(`\n🐻 View detailed report: ${colors.cyan('file://' + reportPath)}`);
1253
+ output.info(`\n🐻 View detailed report: ${colors.cyan(`file://${reportPath}`)}`);
1212
1254
 
1213
1255
  // Auto-open if configured
1214
1256
  if (this.config.tdd?.openReport) {
@@ -1228,10 +1270,10 @@ export class TddService {
1228
1270
  try {
1229
1271
  const {
1230
1272
  exec
1231
- } = await import('child_process');
1273
+ } = await import('node:child_process');
1232
1274
  const {
1233
1275
  promisify
1234
- } = await import('util');
1276
+ } = await import('node:util');
1235
1277
  const execAsync = promisify(exec);
1236
1278
  let command;
1237
1279
  switch (process.platform) {
@@ -1295,10 +1337,10 @@ export class TddService {
1295
1337
  output.warn(`Skipping baseline update for invalid name '${name}': ${error.message}`);
1296
1338
  continue;
1297
1339
  }
1298
- let validatedProperties = validateScreenshotProperties(comparison.properties || {});
1299
- let signature = generateScreenshotSignature(sanitizedName, validatedProperties, this.signatureProperties);
1300
- let filename = signatureToFilename(signature);
1301
- const baselineImagePath = safePath(this.baselinePath, `${filename}.png`);
1340
+ const validatedProperties = validateScreenshotProperties(comparison.properties || {});
1341
+ const signature = generateScreenshotSignature(sanitizedName, validatedProperties, this.signatureProperties);
1342
+ const filename = generateBaselineFilename(sanitizedName, signature);
1343
+ const baselineImagePath = safePath(this.baselinePath, filename);
1302
1344
  try {
1303
1345
  // Copy current screenshot to baseline
1304
1346
  const currentBuffer = readFileSync(current);
@@ -1360,7 +1402,7 @@ export class TddService {
1360
1402
  }
1361
1403
 
1362
1404
  // Generate signature for this screenshot
1363
- let signature = generateScreenshotSignature(name, properties || {}, this.signatureProperties);
1405
+ const signature = generateScreenshotSignature(name, properties || {}, this.signatureProperties);
1364
1406
 
1365
1407
  // Add screenshot to baseline metadata
1366
1408
  const screenshotEntry = {
@@ -1417,7 +1459,7 @@ export class TddService {
1417
1459
  }
1418
1460
 
1419
1461
  // Generate signature for this screenshot
1420
- let signature = generateScreenshotSignature(name, properties || {}, this.signatureProperties);
1462
+ const signature = generateScreenshotSignature(name, properties || {}, this.signatureProperties);
1421
1463
 
1422
1464
  // Add screenshot to baseline metadata
1423
1465
  const screenshotEntry = {
@@ -1471,12 +1513,12 @@ export class TddService {
1471
1513
  comparison = idOrComparison;
1472
1514
  }
1473
1515
  const sanitizedName = comparison.name;
1474
- let properties = comparison.properties || {};
1475
- let signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
1476
- let filename = signatureToFilename(signature);
1516
+ const properties = comparison.properties || {};
1517
+ const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
1518
+ const filename = generateBaselineFilename(sanitizedName, signature);
1477
1519
 
1478
1520
  // Find the current screenshot file
1479
- const currentImagePath = safePath(this.currentPath, `${filename}.png`);
1521
+ const currentImagePath = safePath(this.currentPath, filename);
1480
1522
  if (!existsSync(currentImagePath)) {
1481
1523
  output.error(`Current screenshot not found at: ${currentImagePath}`);
1482
1524
  throw new Error(`Current screenshot not found: ${sanitizedName} (looked at ${currentImagePath})`);
@@ -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
- let buildPayload = {
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',