@vizzly-testing/cli 0.20.1 → 0.21.1

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.
@@ -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;