@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;
|
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;
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
1406
|
+
this._upsertComparison(result);
|
|
1142
1407
|
output.info(`Baseline created for ${name}`);
|
|
1143
1408
|
return result;
|
|
1144
1409
|
}
|