@vizzly-testing/cli 0.21.0 → 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/reporter/reporter-bundle.iife.js +16 -16
- package/dist/server/routers/baseline.js +43 -2
- package/dist/tdd/tdd-service.js +251 -0
- package/docs/internal/SDK-API.md +1018 -0
- package/package.json +1 -1
|
@@ -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
|
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;
|