@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.
- package/dist/commands/run.js +2 -0
- package/dist/commands/tdd.js +10 -2
- package/dist/reporter/reporter-bundle.iife.js +50 -50
- package/dist/server/http-server.js +4 -1
- package/dist/server/routers/baseline.js +43 -2
- package/dist/server/routers/dashboard.js +21 -36
- package/dist/server/routers/events.js +134 -0
- package/dist/services/auth-service.js +117 -0
- package/dist/services/project-service.js +136 -0
- package/dist/tdd/tdd-service.js +251 -0
- package/docs/internal/SDK-API.md +1018 -0
- package/package.json +1 -1
package/dist/tdd/tdd-service.js
CHANGED
|
@@ -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;
|