@vizzly-testing/cli 0.21.0 → 0.21.2

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.
@@ -12,11 +12,13 @@ import { sendError, sendServiceUnavailable, sendSuccess } from '../middleware/re
12
12
  * @param {Object} context - Router context
13
13
  * @param {Object} context.screenshotHandler - Screenshot handler
14
14
  * @param {Object} context.tddService - TDD service for baseline downloads
15
+ * @param {Object} context.authService - Auth service for OAuth requests
15
16
  * @returns {Function} Route handler
16
17
  */
17
18
  export function createBaselineRouter({
18
19
  screenshotHandler,
19
- tddService
20
+ tddService,
21
+ authService
20
22
  }) {
21
23
  return async function handleBaselineRoute(req, res, pathname) {
22
24
  // Accept a single screenshot as baseline
@@ -154,12 +156,51 @@ export function createBaselineRouter({
154
156
  try {
155
157
  let body = await parseJsonBody(req);
156
158
  let {
157
- buildId
159
+ buildId,
160
+ organizationSlug,
161
+ projectSlug
158
162
  } = body;
159
163
  if (!buildId) {
160
164
  sendError(res, 400, 'buildId is required');
161
165
  return true;
162
166
  }
167
+
168
+ // Try OAuth authentication first (allows access to any project user has membership to)
169
+ // This is the preferred method when user is logged in via the dashboard
170
+ if (authService && organizationSlug && projectSlug) {
171
+ try {
172
+ output.info(`Downloading baselines from build ${buildId}...`);
173
+ output.debug('baseline', `Using OAuth for ${organizationSlug}/${projectSlug}`);
174
+
175
+ // Use the CLI endpoint which accepts OAuth and checks project membership
176
+ let apiResponse = await authService.authenticatedRequest(`/api/cli/${projectSlug}/builds/${buildId}/tdd-baselines`, {
177
+ method: 'GET',
178
+ headers: {
179
+ 'X-Organization': organizationSlug
180
+ }
181
+ });
182
+ if (!apiResponse) {
183
+ throw new Error(`Build ${buildId} not found or API returned null`);
184
+ }
185
+
186
+ // Process the baselines through tddService
187
+ let result = await tddService.processDownloadedBaselines(apiResponse, buildId);
188
+ sendSuccess(res, {
189
+ success: true,
190
+ message: `Baselines downloaded from build ${buildId}`,
191
+ ...result
192
+ });
193
+ return true;
194
+ } catch (oauthError) {
195
+ // If OAuth fails with auth error, fall through to other methods
196
+ if (!oauthError.message?.includes('Not authenticated') && !oauthError.message?.includes('401')) {
197
+ throw oauthError;
198
+ }
199
+ output.debug('baseline', `OAuth failed, trying other auth methods: ${oauthError.message}`);
200
+ }
201
+ }
202
+
203
+ // Fall back to using tddService directly (requires VIZZLY_TOKEN env var)
163
204
  output.info(`Downloading baselines from build ${buildId}...`);
164
205
  let result = await tddService.downloadBaselines('test',
165
206
  // environment
@@ -20,7 +20,8 @@ import * as output from '../utils/output.js';
20
20
  */
21
21
  let DEFAULT_CONFIG = {
22
22
  comparison: {
23
- threshold: 2.0
23
+ threshold: 2.0,
24
+ minClusterSize: 2
24
25
  },
25
26
  server: {
26
27
  port: 47392,
@@ -102,6 +103,10 @@ export function createConfigService({
102
103
  config.comparison.threshold = parseFloat(process.env.VIZZLY_THRESHOLD);
103
104
  sources.comparison = 'env';
104
105
  }
106
+ if (process.env.VIZZLY_MIN_CLUSTER_SIZE) {
107
+ config.comparison.minClusterSize = parseInt(process.env.VIZZLY_MIN_CLUSTER_SIZE, 10);
108
+ sources.comparison = 'env';
109
+ }
105
110
  if (process.env.VIZZLY_PORT) {
106
111
  config.server.port = parseInt(process.env.VIZZLY_PORT, 10);
107
112
  sources.server = 'env';
@@ -243,6 +248,16 @@ export default defineConfig(${JSON.stringify(newConfig, null, 2)});
243
248
  }
244
249
  }
245
250
 
251
+ // Validate minClusterSize
252
+ if (config.comparison?.minClusterSize !== undefined) {
253
+ let minClusterSize = config.comparison.minClusterSize;
254
+ if (!Number.isInteger(minClusterSize) || minClusterSize < 1) {
255
+ errors.push('comparison.minClusterSize must be a positive integer (1 or greater)');
256
+ } else if (minClusterSize > 100) {
257
+ warnings.push('comparison.minClusterSize above 100 may filter out most differences');
258
+ }
259
+ }
260
+
246
261
  // Validate port
247
262
  if (config.server?.port !== undefined) {
248
263
  let port = config.server.port;
@@ -186,6 +186,25 @@ export class TddService {
186
186
  * Download baselines from cloud
187
187
  */
188
188
  async downloadBaselines(environment = 'test', branch = null, buildId = null, comparisonId = null) {
189
+ // Destructure dependencies
190
+ let {
191
+ output,
192
+ colors,
193
+ getDefaultBranch,
194
+ getTddBaselines,
195
+ getBuilds,
196
+ getComparison,
197
+ clearBaselineData,
198
+ generateScreenshotSignature,
199
+ generateBaselineFilename,
200
+ sanitizeScreenshotName,
201
+ safePath,
202
+ existsSync,
203
+ fetchWithTimeout,
204
+ writeFileSync,
205
+ saveBaselineMetadata
206
+ } = this._deps;
207
+
189
208
  // If no branch specified, detect default branch
190
209
  if (!branch) {
191
210
  branch = await getDefaultBranch();
@@ -485,10 +504,242 @@ export class TddService {
485
504
  }
486
505
  }
487
506
 
507
+ /**
508
+ * Process already-fetched baseline data (for use when caller handles auth)
509
+ * This allows the baseline router to fetch with a project token and pass the response here
510
+ * @param {Object} apiResponse - Response from getTddBaselines API call
511
+ * @param {string} buildId - Build ID for reference
512
+ * @returns {Promise<Object>} Baseline data
513
+ */
514
+ async processDownloadedBaselines(apiResponse, buildId) {
515
+ // Destructure dependencies
516
+ let {
517
+ output,
518
+ colors,
519
+ clearBaselineData,
520
+ sanitizeScreenshotName,
521
+ safePath,
522
+ existsSync,
523
+ fetchWithTimeout,
524
+ writeFileSync,
525
+ saveBaselineMetadata
526
+ } = this._deps;
527
+
528
+ // Clear local state before downloading
529
+ output.info('Clearing local state before downloading baselines...');
530
+ clearBaselineData({
531
+ baselinePath: this.baselinePath,
532
+ currentPath: this.currentPath,
533
+ diffPath: this.diffPath
534
+ });
535
+
536
+ // Extract signature properties
537
+ if (apiResponse.signatureProperties && Array.isArray(apiResponse.signatureProperties)) {
538
+ this.signatureProperties = apiResponse.signatureProperties;
539
+ if (this.signatureProperties.length > 0) {
540
+ output.info(`Using signature properties: ${this.signatureProperties.join(', ')}`);
541
+ }
542
+ }
543
+ let baselineBuild = apiResponse.build;
544
+ if (baselineBuild.status === 'failed') {
545
+ output.warn(`Build ${buildId} is marked as FAILED - falling back to local baselines`);
546
+ return await this.handleLocalBaselines();
547
+ } else if (baselineBuild.status !== 'completed') {
548
+ output.warn(`Build ${buildId} has status: ${baselineBuild.status} (expected: completed)`);
549
+ }
550
+ baselineBuild.screenshots = apiResponse.screenshots;
551
+ let buildDetails = baselineBuild;
552
+ if (!buildDetails.screenshots || buildDetails.screenshots.length === 0) {
553
+ output.warn('No screenshots found in baseline build');
554
+ return null;
555
+ }
556
+ output.info(`Using baseline from build: ${colors.cyan(baselineBuild.name || 'Unknown')} (${baselineBuild.id || 'Unknown ID'})`);
557
+ output.info(`Checking ${colors.cyan(buildDetails.screenshots.length)} baseline screenshots...`);
558
+
559
+ // Check existing baseline metadata for SHA comparison
560
+ let existingBaseline = await this.loadBaseline();
561
+ let existingShaMap = new Map();
562
+ if (existingBaseline) {
563
+ existingBaseline.screenshots.forEach(s => {
564
+ if (s.sha256 && s.filename) {
565
+ existingShaMap.set(s.filename, s.sha256);
566
+ }
567
+ });
568
+ }
569
+
570
+ // Download screenshots
571
+ let downloadedCount = 0;
572
+ let skippedCount = 0;
573
+ let errorCount = 0;
574
+ let batchSize = 5;
575
+ let screenshotsToProcess = [];
576
+ for (let screenshot of buildDetails.screenshots) {
577
+ let sanitizedName;
578
+ try {
579
+ sanitizedName = sanitizeScreenshotName(screenshot.name);
580
+ } catch (error) {
581
+ output.warn(`Skipping screenshot with invalid name '${screenshot.name}': ${error.message}`);
582
+ errorCount++;
583
+ continue;
584
+ }
585
+ let filename = screenshot.filename;
586
+ if (!filename) {
587
+ output.warn(`Screenshot ${sanitizedName} has no filename from API - skipping`);
588
+ errorCount++;
589
+ continue;
590
+ }
591
+ let imagePath = safePath(this.baselinePath, filename);
592
+
593
+ // Check SHA
594
+ if (existsSync(imagePath) && screenshot.sha256) {
595
+ let storedSha = existingShaMap.get(filename);
596
+ if (storedSha === screenshot.sha256) {
597
+ downloadedCount++;
598
+ skippedCount++;
599
+ continue;
600
+ }
601
+ }
602
+ let downloadUrl = screenshot.original_url || screenshot.url;
603
+ if (!downloadUrl) {
604
+ output.warn(`Screenshot ${sanitizedName} has no download URL - skipping`);
605
+ errorCount++;
606
+ continue;
607
+ }
608
+ screenshotsToProcess.push({
609
+ screenshot,
610
+ sanitizedName,
611
+ imagePath,
612
+ downloadUrl,
613
+ filename
614
+ });
615
+ }
616
+
617
+ // Process downloads in batches
618
+ if (screenshotsToProcess.length > 0) {
619
+ output.info(`📥 Downloading ${screenshotsToProcess.length} new/updated screenshots...`);
620
+ for (let i = 0; i < screenshotsToProcess.length; i += batchSize) {
621
+ let batch = screenshotsToProcess.slice(i, i + batchSize);
622
+ let batchNum = Math.floor(i / batchSize) + 1;
623
+ let totalBatches = Math.ceil(screenshotsToProcess.length / batchSize);
624
+ output.info(`📦 Processing batch ${batchNum}/${totalBatches}`);
625
+ let downloadPromises = batch.map(async ({
626
+ sanitizedName,
627
+ imagePath,
628
+ downloadUrl
629
+ }) => {
630
+ try {
631
+ let response = await fetchWithTimeout(downloadUrl);
632
+ if (!response.ok) {
633
+ throw new Error(`Failed to download ${sanitizedName}: ${response.statusText}`);
634
+ }
635
+ let arrayBuffer = await response.arrayBuffer();
636
+ let imageBuffer = Buffer.from(arrayBuffer);
637
+ writeFileSync(imagePath, imageBuffer);
638
+ return {
639
+ success: true,
640
+ name: sanitizedName
641
+ };
642
+ } catch (error) {
643
+ output.warn(`Failed to download ${sanitizedName}: ${error.message}`);
644
+ return {
645
+ success: false,
646
+ name: sanitizedName,
647
+ error: error.message
648
+ };
649
+ }
650
+ });
651
+ let batchResults = await Promise.all(downloadPromises);
652
+ let batchSuccesses = batchResults.filter(r => r.success).length;
653
+ let batchFailures = batchResults.filter(r => !r.success).length;
654
+ downloadedCount += batchSuccesses;
655
+ errorCount += batchFailures;
656
+ }
657
+ }
658
+ if (downloadedCount === 0 && skippedCount === 0) {
659
+ output.error('No screenshots were successfully downloaded');
660
+ return null;
661
+ }
662
+
663
+ // Store baseline metadata
664
+ this.baselineData = {
665
+ buildId: baselineBuild.id,
666
+ buildName: baselineBuild.name,
667
+ environment: 'test',
668
+ branch: null,
669
+ threshold: this.threshold,
670
+ signatureProperties: this.signatureProperties,
671
+ createdAt: new Date().toISOString(),
672
+ buildInfo: {
673
+ commitSha: baselineBuild.commit_sha,
674
+ commitMessage: baselineBuild.commit_message,
675
+ approvalStatus: baselineBuild.approval_status,
676
+ completedAt: baselineBuild.completed_at
677
+ },
678
+ screenshots: buildDetails.screenshots.filter(s => s.filename).map(s => ({
679
+ name: sanitizeScreenshotName(s.name),
680
+ originalName: s.name,
681
+ sha256: s.sha256,
682
+ id: s.id,
683
+ filename: s.filename,
684
+ path: safePath(this.baselinePath, s.filename),
685
+ browser: s.browser,
686
+ viewport_width: s.viewport_width,
687
+ originalUrl: s.original_url,
688
+ fileSize: s.file_size_bytes,
689
+ dimensions: {
690
+ width: s.width,
691
+ height: s.height
692
+ }
693
+ }))
694
+ };
695
+ saveBaselineMetadata(this.baselinePath, this.baselineData);
696
+
697
+ // Download hotspots if API key is available (requires SDK auth)
698
+ // OAuth-only users won't get hotspots since the hotspot endpoint requires project token
699
+ if (this.config.apiKey && buildDetails.screenshots?.length > 0) {
700
+ await this.downloadHotspots(buildDetails.screenshots);
701
+ }
702
+
703
+ // Save baseline build metadata for MCP plugin
704
+ let baselineMetadataPath = safePath(this.workingDir, '.vizzly', 'baseline-metadata.json');
705
+ writeFileSync(baselineMetadataPath, JSON.stringify({
706
+ buildId: baselineBuild.id,
707
+ buildName: baselineBuild.name,
708
+ branch: null,
709
+ environment: 'test',
710
+ commitSha: baselineBuild.commit_sha,
711
+ commitMessage: baselineBuild.commit_message,
712
+ approvalStatus: baselineBuild.approval_status,
713
+ completedAt: baselineBuild.completed_at,
714
+ downloadedAt: new Date().toISOString()
715
+ }, null, 2));
716
+
717
+ // Summary
718
+ let actualDownloads = downloadedCount - skippedCount;
719
+ if (skippedCount > 0) {
720
+ if (actualDownloads === 0) {
721
+ output.info(`All ${skippedCount} baselines up-to-date`);
722
+ } else {
723
+ output.info(`Downloaded ${actualDownloads} new screenshots, ${skippedCount} already up-to-date`);
724
+ }
725
+ } else {
726
+ output.info(`Downloaded ${downloadedCount}/${buildDetails.screenshots.length} screenshots successfully`);
727
+ }
728
+ if (errorCount > 0) {
729
+ output.warn(`${errorCount} screenshots failed to download`);
730
+ }
731
+ return this.baselineData;
732
+ }
733
+
488
734
  /**
489
735
  * Download hotspot data for screenshots
490
736
  */
491
737
  async downloadHotspots(screenshots) {
738
+ let {
739
+ output,
740
+ getBatchHotspots,
741
+ saveHotspotMetadata
742
+ } = this._deps;
492
743
  if (!this.config.apiKey) {
493
744
  output.debug('tdd', 'Skipping hotspot download - no API token configured');
494
745
  return;
@@ -612,6 +863,20 @@ export class TddService {
612
863
  return metadata;
613
864
  }
614
865
 
866
+ /**
867
+ * Upsert a comparison result - replaces existing if same ID, otherwise appends.
868
+ * This prevents stale results from accumulating in daemon mode.
869
+ * @private
870
+ */
871
+ _upsertComparison(result) {
872
+ let existingIndex = this.comparisons.findIndex(c => c.id === result.id);
873
+ if (existingIndex >= 0) {
874
+ this.comparisons[existingIndex] = result;
875
+ } else {
876
+ this.comparisons.push(result);
877
+ }
878
+ }
879
+
615
880
  /**
616
881
  * Compare a screenshot against baseline
617
882
  */
@@ -708,7 +973,7 @@ export class TddService {
708
973
  currentPath: currentImagePath,
709
974
  properties: validatedProperties
710
975
  });
711
- this.comparisons.push(result);
976
+ this._upsertComparison(result);
712
977
  return result;
713
978
  }
714
979
 
@@ -731,7 +996,7 @@ export class TddService {
731
996
  minClusterSize: effectiveMinClusterSize,
732
997
  honeydiffResult
733
998
  });
734
- this.comparisons.push(result);
999
+ this._upsertComparison(result);
735
1000
  return result;
736
1001
  } else {
737
1002
  let hotspotAnalysis = this.getHotspotForScreenshot(name);
@@ -756,7 +1021,7 @@ export class TddService {
756
1021
  output.debug('comparison', `${sanitizedName}: ${result.status}`, {
757
1022
  diff: diffInfo
758
1023
  });
759
- this.comparisons.push(result);
1024
+ this._upsertComparison(result);
760
1025
  return result;
761
1026
  }
762
1027
  } catch (error) {
@@ -784,7 +1049,7 @@ export class TddService {
784
1049
  currentPath: currentImagePath,
785
1050
  properties: validatedProperties
786
1051
  });
787
- this.comparisons.push(result);
1052
+ this._upsertComparison(result);
788
1053
  return result;
789
1054
  }
790
1055
  output.debug('comparison', `${sanitizedName}: error - ${error.message}`);
@@ -796,7 +1061,7 @@ export class TddService {
796
1061
  properties: validatedProperties,
797
1062
  errorMessage: error.message
798
1063
  });
799
- this.comparisons.push(result);
1064
+ this._upsertComparison(result);
800
1065
  return result;
801
1066
  }
802
1067
  }
@@ -1138,7 +1403,7 @@ export class TddService {
1138
1403
  properties,
1139
1404
  signature
1140
1405
  };
1141
- this.comparisons.push(result);
1406
+ this._upsertComparison(result);
1142
1407
  output.info(`Baseline created for ${name}`);
1143
1408
  return result;
1144
1409
  }