@vizzly-testing/cli 0.19.0 → 0.19.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.
- package/dist/server/routers/baseline.js +5 -40
- package/dist/services/api-service.js +10 -0
- package/dist/services/tdd-service.js +105 -259
- package/dist/utils/security.js +59 -2
- package/package.json +1 -1
|
@@ -12,13 +12,11 @@ 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
|
|
16
15
|
* @returns {Function} Route handler
|
|
17
16
|
*/
|
|
18
17
|
export function createBaselineRouter({
|
|
19
18
|
screenshotHandler,
|
|
20
|
-
tddService
|
|
21
|
-
authService
|
|
19
|
+
tddService
|
|
22
20
|
}) {
|
|
23
21
|
return async function handleBaselineRoute(req, res, pathname) {
|
|
24
22
|
// Accept a single screenshot as baseline
|
|
@@ -96,49 +94,16 @@ export function createBaselineRouter({
|
|
|
96
94
|
return true;
|
|
97
95
|
}
|
|
98
96
|
try {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
buildId
|
|
102
|
-
organizationSlug,
|
|
103
|
-
projectSlug
|
|
97
|
+
let body = await parseJsonBody(req);
|
|
98
|
+
let {
|
|
99
|
+
buildId
|
|
104
100
|
} = body;
|
|
105
101
|
if (!buildId) {
|
|
106
102
|
sendError(res, 400, 'buildId is required');
|
|
107
103
|
return true;
|
|
108
104
|
}
|
|
109
105
|
output.info(`Downloading baselines from build ${buildId}...`);
|
|
110
|
-
|
|
111
|
-
// If organizationSlug and projectSlug are provided, use OAuth-based download
|
|
112
|
-
if (organizationSlug && projectSlug && authService) {
|
|
113
|
-
try {
|
|
114
|
-
const result = await tddService.downloadBaselinesWithAuth(buildId, organizationSlug, projectSlug, authService);
|
|
115
|
-
sendSuccess(res, {
|
|
116
|
-
success: true,
|
|
117
|
-
message: `Baselines downloaded from build ${buildId}`,
|
|
118
|
-
...result
|
|
119
|
-
});
|
|
120
|
-
return true;
|
|
121
|
-
} catch (authError) {
|
|
122
|
-
// Log the OAuth error with details
|
|
123
|
-
output.warn(`OAuth download failed (org=${organizationSlug}, project=${projectSlug}): ${authError.message}`);
|
|
124
|
-
|
|
125
|
-
// If the error is a 404, it's likely the build doesn't belong to the project
|
|
126
|
-
// or the project/org is incorrect - provide a helpful error
|
|
127
|
-
if (authError.message?.includes('404')) {
|
|
128
|
-
sendError(res, 404, `Build not found or does not belong to project "${projectSlug}" in organization "${organizationSlug}". ` + `Please verify the build exists and you have access to it.`);
|
|
129
|
-
return true;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// For auth errors, try API token fallback
|
|
133
|
-
if (!authError.message?.includes('401')) {
|
|
134
|
-
// For other errors, don't fall through - report them directly
|
|
135
|
-
throw authError;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// Fall back to API token-based download (when no OAuth info or OAuth auth failed)
|
|
141
|
-
const result = await tddService.downloadBaselines('test',
|
|
106
|
+
let result = await tddService.downloadBaselines('test',
|
|
142
107
|
// environment
|
|
143
108
|
null,
|
|
144
109
|
// branch (not needed when buildId is specified)
|
|
@@ -122,6 +122,16 @@ export class ApiService {
|
|
|
122
122
|
return this.request(endpoint);
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Get TDD baselines for a build
|
|
127
|
+
* Returns screenshots with pre-computed filenames for baseline download
|
|
128
|
+
* @param {string} buildId - Build ID
|
|
129
|
+
* @returns {Promise<Object>} { build, screenshots, signatureProperties }
|
|
130
|
+
*/
|
|
131
|
+
async getTddBaselines(buildId) {
|
|
132
|
+
return this.request(`/api/sdk/builds/${buildId}/tdd-baselines`);
|
|
133
|
+
}
|
|
134
|
+
|
|
125
135
|
/**
|
|
126
136
|
* Get comparison information
|
|
127
137
|
* @param {string} comparisonId - Comparison ID
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import crypto from 'node:crypto';
|
|
24
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
25
25
|
import { join } from 'node:path';
|
|
26
26
|
import { compare } from '@vizzly-testing/honeydiff';
|
|
27
27
|
import { NetworkError } from '../errors/vizzly-error.js';
|
|
@@ -178,41 +178,71 @@ export class TddService {
|
|
|
178
178
|
try {
|
|
179
179
|
let baselineBuild;
|
|
180
180
|
if (buildId) {
|
|
181
|
-
// Use
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
// API response available in verbose mode
|
|
185
|
-
output.debug('tdd', 'fetched baseline build', {
|
|
186
|
-
id: apiResponse?.build?.id || apiResponse?.id
|
|
187
|
-
});
|
|
181
|
+
// Use the tdd-baselines endpoint which returns pre-computed filenames
|
|
182
|
+
let apiResponse = await this.api.getTddBaselines(buildId);
|
|
188
183
|
if (!apiResponse) {
|
|
189
184
|
throw new Error(`Build ${buildId} not found or API returned null`);
|
|
190
185
|
}
|
|
191
186
|
|
|
187
|
+
// When downloading baselines, always start with a clean slate
|
|
188
|
+
// This handles signature property changes, build switches, and any stale state
|
|
189
|
+
output.info('Clearing local state before downloading baselines...');
|
|
190
|
+
try {
|
|
191
|
+
// Clear everything - baselines, current screenshots, diffs, and metadata
|
|
192
|
+
// This ensures we start fresh with the new baseline build
|
|
193
|
+
rmSync(this.baselinePath, {
|
|
194
|
+
recursive: true,
|
|
195
|
+
force: true
|
|
196
|
+
});
|
|
197
|
+
rmSync(this.currentPath, {
|
|
198
|
+
recursive: true,
|
|
199
|
+
force: true
|
|
200
|
+
});
|
|
201
|
+
rmSync(this.diffPath, {
|
|
202
|
+
recursive: true,
|
|
203
|
+
force: true
|
|
204
|
+
});
|
|
205
|
+
mkdirSync(this.baselinePath, {
|
|
206
|
+
recursive: true
|
|
207
|
+
});
|
|
208
|
+
mkdirSync(this.currentPath, {
|
|
209
|
+
recursive: true
|
|
210
|
+
});
|
|
211
|
+
mkdirSync(this.diffPath, {
|
|
212
|
+
recursive: true
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Clear baseline metadata file (will be regenerated with new baseline)
|
|
216
|
+
const baselineMetadataPath = safePath(this.workingDir, '.vizzly', 'baseline-metadata.json');
|
|
217
|
+
if (existsSync(baselineMetadataPath)) {
|
|
218
|
+
rmSync(baselineMetadataPath, {
|
|
219
|
+
force: true
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
output.error(`Failed to clear local state: ${error.message}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
192
226
|
// Extract signature properties from API response (for variant support)
|
|
193
227
|
if (apiResponse.signatureProperties && Array.isArray(apiResponse.signatureProperties)) {
|
|
194
228
|
this.signatureProperties = apiResponse.signatureProperties;
|
|
195
229
|
if (this.signatureProperties.length > 0) {
|
|
196
|
-
output.info(`Using
|
|
230
|
+
output.info(`Using signature properties: ${this.signatureProperties.join(', ')}`);
|
|
197
231
|
}
|
|
198
232
|
}
|
|
199
|
-
|
|
200
|
-
// Handle wrapped response format
|
|
201
|
-
baselineBuild = apiResponse.build || apiResponse;
|
|
202
|
-
if (!baselineBuild.id) {
|
|
203
|
-
output.warn(`⚠️ Build response structure: ${JSON.stringify(Object.keys(apiResponse))}`);
|
|
204
|
-
output.warn(`⚠️ Extracted build keys: ${JSON.stringify(Object.keys(baselineBuild))}`);
|
|
205
|
-
}
|
|
233
|
+
baselineBuild = apiResponse.build;
|
|
206
234
|
|
|
207
235
|
// Check build status and warn if it's not successful
|
|
208
236
|
if (baselineBuild.status === 'failed') {
|
|
209
237
|
output.warn(`⚠️ Build ${buildId} is marked as FAILED - falling back to local baselines`);
|
|
210
238
|
output.info(`💡 To use remote baselines, specify a successful build ID instead`);
|
|
211
|
-
// Fall back to local baseline logic
|
|
212
239
|
return await this.handleLocalBaselines();
|
|
213
240
|
} else if (baselineBuild.status !== 'completed') {
|
|
214
241
|
output.warn(`⚠️ Build ${buildId} has status: ${baselineBuild.status} (expected: completed)`);
|
|
215
242
|
}
|
|
243
|
+
|
|
244
|
+
// Attach screenshots to build for unified processing below
|
|
245
|
+
baselineBuild.screenshots = apiResponse.screenshots;
|
|
216
246
|
} else if (comparisonId) {
|
|
217
247
|
// Use specific comparison ID - download only this comparison's baseline screenshot
|
|
218
248
|
output.info(`Using comparison: ${comparisonId}`);
|
|
@@ -262,6 +292,11 @@ export class TddService {
|
|
|
262
292
|
}
|
|
263
293
|
output.info(`📊 Extracted properties for signature: ${JSON.stringify(screenshotProperties)}`);
|
|
264
294
|
|
|
295
|
+
// Generate filename locally for comparison path (we don't have API-provided filename)
|
|
296
|
+
const screenshotName = comparison.baseline_name || comparison.current_name;
|
|
297
|
+
const signature = generateScreenshotSignature(screenshotName, screenshotProperties, this.signatureProperties);
|
|
298
|
+
const filename = generateBaselineFilename(screenshotName, signature);
|
|
299
|
+
|
|
265
300
|
// For a specific comparison, we only download that one baseline screenshot
|
|
266
301
|
// Create a mock build structure with just this one screenshot
|
|
267
302
|
baselineBuild = {
|
|
@@ -269,10 +304,11 @@ export class TddService {
|
|
|
269
304
|
name: `Comparison ${comparisonId.substring(0, 8)}`,
|
|
270
305
|
screenshots: [{
|
|
271
306
|
id: comparison.baseline_screenshot.id,
|
|
272
|
-
name:
|
|
307
|
+
name: screenshotName,
|
|
273
308
|
original_url: baselineUrl,
|
|
274
309
|
metadata: screenshotProperties,
|
|
275
|
-
properties: screenshotProperties
|
|
310
|
+
properties: screenshotProperties,
|
|
311
|
+
filename: filename // Generated locally for comparison path
|
|
276
312
|
}]
|
|
277
313
|
};
|
|
278
314
|
} else {
|
|
@@ -288,18 +324,27 @@ export class TddService {
|
|
|
288
324
|
output.info('💡 Run a build in normal mode first to create baselines');
|
|
289
325
|
return null;
|
|
290
326
|
}
|
|
291
|
-
|
|
327
|
+
|
|
328
|
+
// Use getTddBaselines to get screenshots with pre-computed filenames
|
|
329
|
+
const apiResponse = await this.api.getTddBaselines(builds.data[0].id);
|
|
330
|
+
if (!apiResponse) {
|
|
331
|
+
throw new Error(`Build ${builds.data[0].id} not found or API returned null`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Extract signature properties from API response (for variant support)
|
|
335
|
+
if (apiResponse.signatureProperties && Array.isArray(apiResponse.signatureProperties)) {
|
|
336
|
+
this.signatureProperties = apiResponse.signatureProperties;
|
|
337
|
+
if (this.signatureProperties.length > 0) {
|
|
338
|
+
output.info(`Using custom signature properties: ${this.signatureProperties.join(', ')}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
baselineBuild = apiResponse.build;
|
|
342
|
+
baselineBuild.screenshots = apiResponse.screenshots;
|
|
292
343
|
}
|
|
293
344
|
|
|
294
|
-
// For
|
|
345
|
+
// For both buildId and getBuilds paths, we now have screenshots with filenames
|
|
295
346
|
// For comparisonId, we created a mock build with just the one screenshot
|
|
296
|
-
// Otherwise, get build details with screenshots
|
|
297
347
|
let buildDetails = baselineBuild;
|
|
298
|
-
if (!buildId && !comparisonId) {
|
|
299
|
-
// Get build details with screenshots for non-buildId/non-comparisonId cases
|
|
300
|
-
const actualBuildId = baselineBuild.id;
|
|
301
|
-
buildDetails = await this.api.getBuild(actualBuildId, 'screenshots');
|
|
302
|
-
}
|
|
303
348
|
if (!buildDetails.screenshots || buildDetails.screenshots.length === 0) {
|
|
304
349
|
output.warn('⚠️ No screenshots found in baseline build');
|
|
305
350
|
return null;
|
|
@@ -308,12 +353,12 @@ export class TddService {
|
|
|
308
353
|
output.info(`Checking ${colors.cyan(buildDetails.screenshots.length)} baseline screenshots...`);
|
|
309
354
|
|
|
310
355
|
// Check existing baseline metadata for efficient SHA comparison
|
|
311
|
-
|
|
312
|
-
|
|
356
|
+
let existingBaseline = await this.loadBaseline();
|
|
357
|
+
let existingShaMap = new Map();
|
|
313
358
|
if (existingBaseline) {
|
|
314
359
|
existingBaseline.screenshots.forEach(s => {
|
|
315
|
-
if (s.sha256 && s.
|
|
316
|
-
existingShaMap.set(s.
|
|
360
|
+
if (s.sha256 && s.filename) {
|
|
361
|
+
existingShaMap.set(s.filename, s.sha256);
|
|
317
362
|
}
|
|
318
363
|
});
|
|
319
364
|
}
|
|
@@ -338,26 +383,21 @@ export class TddService {
|
|
|
338
383
|
continue;
|
|
339
384
|
}
|
|
340
385
|
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
// Use API-provided filename if available, otherwise generate hash-based filename
|
|
352
|
-
// Both return the full filename with .png extension
|
|
353
|
-
const filename = screenshot.filename || generateBaselineFilename(sanitizedName, signature);
|
|
354
|
-
const imagePath = safePath(this.baselinePath, filename);
|
|
386
|
+
// Use API-provided filename (required from tdd-baselines endpoint)
|
|
387
|
+
// This ensures filenames match between cloud and local TDD
|
|
388
|
+
let filename = screenshot.filename;
|
|
389
|
+
if (!filename) {
|
|
390
|
+
output.warn(`⚠️ Screenshot ${sanitizedName} has no filename from API - skipping`);
|
|
391
|
+
errorCount++;
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
let imagePath = safePath(this.baselinePath, filename);
|
|
355
395
|
|
|
356
|
-
// Check if we already have this file with the same SHA
|
|
396
|
+
// Check if we already have this file with the same SHA
|
|
357
397
|
if (existsSync(imagePath) && screenshot.sha256) {
|
|
358
|
-
|
|
398
|
+
let storedSha = existingShaMap.get(filename);
|
|
359
399
|
if (storedSha === screenshot.sha256) {
|
|
360
|
-
downloadedCount++;
|
|
400
|
+
downloadedCount++;
|
|
361
401
|
skippedCount++;
|
|
362
402
|
continue;
|
|
363
403
|
}
|
|
@@ -375,9 +415,7 @@ export class TddService {
|
|
|
375
415
|
sanitizedName,
|
|
376
416
|
imagePath,
|
|
377
417
|
downloadUrl,
|
|
378
|
-
|
|
379
|
-
filename,
|
|
380
|
-
properties
|
|
418
|
+
filename
|
|
381
419
|
});
|
|
382
420
|
}
|
|
383
421
|
|
|
@@ -458,41 +496,23 @@ export class TddService {
|
|
|
458
496
|
approvalStatus: baselineBuild.approval_status,
|
|
459
497
|
completedAt: baselineBuild.completed_at
|
|
460
498
|
},
|
|
461
|
-
screenshots: buildDetails.screenshots.
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
499
|
+
screenshots: buildDetails.screenshots.filter(s => s.filename) // Only include screenshots with filenames
|
|
500
|
+
.map(s => ({
|
|
501
|
+
name: sanitizeScreenshotName(s.name),
|
|
502
|
+
originalName: s.name,
|
|
503
|
+
sha256: s.sha256,
|
|
504
|
+
id: s.id,
|
|
505
|
+
filename: s.filename,
|
|
506
|
+
path: safePath(this.baselinePath, s.filename),
|
|
507
|
+
browser: s.browser,
|
|
508
|
+
viewport_width: s.viewport_width,
|
|
509
|
+
originalUrl: s.original_url,
|
|
510
|
+
fileSize: s.file_size_bytes,
|
|
511
|
+
dimensions: {
|
|
512
|
+
width: s.width,
|
|
513
|
+
height: s.height
|
|
468
514
|
}
|
|
469
|
-
|
|
470
|
-
// Build properties object with top-level viewport_width and browser
|
|
471
|
-
// These are returned as top-level fields from the API, not inside metadata
|
|
472
|
-
const properties = validateScreenshotProperties({
|
|
473
|
-
viewport_width: s.viewport_width,
|
|
474
|
-
browser: s.browser,
|
|
475
|
-
...(s.metadata || s.properties || {})
|
|
476
|
-
});
|
|
477
|
-
const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
|
|
478
|
-
const filename = generateBaselineFilename(sanitizedName, signature);
|
|
479
|
-
return {
|
|
480
|
-
name: sanitizedName,
|
|
481
|
-
originalName: s.name,
|
|
482
|
-
sha256: s.sha256,
|
|
483
|
-
// Store remote SHA for quick comparison
|
|
484
|
-
id: s.id,
|
|
485
|
-
properties: properties,
|
|
486
|
-
path: safePath(this.baselinePath, filename),
|
|
487
|
-
signature: signature,
|
|
488
|
-
originalUrl: s.original_url,
|
|
489
|
-
fileSize: s.file_size_bytes,
|
|
490
|
-
dimensions: {
|
|
491
|
-
width: s.width,
|
|
492
|
-
height: s.height
|
|
493
|
-
}
|
|
494
|
-
};
|
|
495
|
-
}).filter(Boolean) // Remove null entries from invalid screenshots
|
|
515
|
+
}))
|
|
496
516
|
};
|
|
497
517
|
const metadataPath = join(this.baselinePath, 'metadata.json');
|
|
498
518
|
writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
|
|
@@ -685,180 +705,6 @@ export class TddService {
|
|
|
685
705
|
};
|
|
686
706
|
}
|
|
687
707
|
|
|
688
|
-
/**
|
|
689
|
-
* Download baselines using OAuth authentication
|
|
690
|
-
* Used when user is logged in via device flow but no API token is configured
|
|
691
|
-
* @param {string} buildId - Build ID to download from
|
|
692
|
-
* @param {string} organizationSlug - Organization slug
|
|
693
|
-
* @param {string} projectSlug - Project slug
|
|
694
|
-
* @param {Object} authService - Auth service for OAuth requests
|
|
695
|
-
* @returns {Promise<Object>} Download result
|
|
696
|
-
*/
|
|
697
|
-
async downloadBaselinesWithAuth(buildId, organizationSlug, projectSlug, authService) {
|
|
698
|
-
output.info(`Downloading baselines using OAuth from build ${buildId}...`);
|
|
699
|
-
try {
|
|
700
|
-
// Fetch build with screenshots via OAuth endpoint
|
|
701
|
-
const endpoint = `/api/build/${projectSlug}/${buildId}/tdd-baselines`;
|
|
702
|
-
const response = await authService.authenticatedRequest(endpoint, {
|
|
703
|
-
method: 'GET',
|
|
704
|
-
headers: {
|
|
705
|
-
'X-Organization': organizationSlug
|
|
706
|
-
}
|
|
707
|
-
});
|
|
708
|
-
const {
|
|
709
|
-
build,
|
|
710
|
-
screenshots,
|
|
711
|
-
signatureProperties
|
|
712
|
-
} = response;
|
|
713
|
-
|
|
714
|
-
// Extract signature properties from API response (for variant support)
|
|
715
|
-
if (signatureProperties && Array.isArray(signatureProperties)) {
|
|
716
|
-
this.signatureProperties = signatureProperties;
|
|
717
|
-
if (this.signatureProperties.length > 0) {
|
|
718
|
-
output.info(`Using custom signature properties: ${this.signatureProperties.join(', ')}`);
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
if (!screenshots || screenshots.length === 0) {
|
|
722
|
-
output.warn('⚠️ No screenshots found in build');
|
|
723
|
-
return {
|
|
724
|
-
downloadedCount: 0,
|
|
725
|
-
skippedCount: 0,
|
|
726
|
-
errorCount: 0
|
|
727
|
-
};
|
|
728
|
-
}
|
|
729
|
-
output.info(`Using baseline from build: ${colors.cyan(build.name || 'Unknown')} (${build.id})`);
|
|
730
|
-
output.info(`Checking ${colors.cyan(screenshots.length)} baseline screenshots...`);
|
|
731
|
-
|
|
732
|
-
// Load existing baseline metadata for SHA comparison
|
|
733
|
-
const existingBaseline = await this.loadBaseline();
|
|
734
|
-
const existingShaMap = new Map();
|
|
735
|
-
if (existingBaseline) {
|
|
736
|
-
existingBaseline.screenshots.forEach(s => {
|
|
737
|
-
if (s.sha256 && s.signature) {
|
|
738
|
-
existingShaMap.set(s.signature, s.sha256);
|
|
739
|
-
}
|
|
740
|
-
});
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
// Process and download screenshots
|
|
744
|
-
let downloadedCount = 0;
|
|
745
|
-
let skippedCount = 0;
|
|
746
|
-
let errorCount = 0;
|
|
747
|
-
const downloadedScreenshots = [];
|
|
748
|
-
for (const screenshot of screenshots) {
|
|
749
|
-
let sanitizedName;
|
|
750
|
-
try {
|
|
751
|
-
sanitizedName = sanitizeScreenshotName(screenshot.name);
|
|
752
|
-
} catch (error) {
|
|
753
|
-
output.warn(`Screenshot name sanitization failed for '${screenshot.name}': ${error.message}`);
|
|
754
|
-
errorCount++;
|
|
755
|
-
continue;
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
// Build properties object with top-level viewport_width and browser
|
|
759
|
-
// These are returned as top-level fields from the API, not inside metadata
|
|
760
|
-
const properties = validateScreenshotProperties({
|
|
761
|
-
viewport_width: screenshot.viewport_width,
|
|
762
|
-
browser: screenshot.browser,
|
|
763
|
-
...screenshot.metadata
|
|
764
|
-
});
|
|
765
|
-
const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
|
|
766
|
-
// Use API-provided filename if available, otherwise generate hash-based filename
|
|
767
|
-
const filename = screenshot.filename || generateBaselineFilename(sanitizedName, signature);
|
|
768
|
-
const filePath = safePath(this.baselinePath, filename);
|
|
769
|
-
|
|
770
|
-
// Check if we can skip via SHA comparison
|
|
771
|
-
if (screenshot.sha256 && existingShaMap.get(signature) === screenshot.sha256) {
|
|
772
|
-
skippedCount++;
|
|
773
|
-
downloadedScreenshots.push({
|
|
774
|
-
name: sanitizedName,
|
|
775
|
-
sha256: screenshot.sha256,
|
|
776
|
-
signature,
|
|
777
|
-
path: filePath,
|
|
778
|
-
properties
|
|
779
|
-
});
|
|
780
|
-
continue;
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
// Download the screenshot
|
|
784
|
-
const downloadUrl = screenshot.original_url;
|
|
785
|
-
if (!downloadUrl) {
|
|
786
|
-
output.warn(`⚠️ No download URL for screenshot: ${sanitizedName}`);
|
|
787
|
-
errorCount++;
|
|
788
|
-
continue;
|
|
789
|
-
}
|
|
790
|
-
try {
|
|
791
|
-
const imageResponse = await fetchWithTimeout(downloadUrl, {}, 30000);
|
|
792
|
-
if (!imageResponse.ok) {
|
|
793
|
-
throw new Error(`HTTP ${imageResponse.status}`);
|
|
794
|
-
}
|
|
795
|
-
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
|
|
796
|
-
|
|
797
|
-
// Calculate SHA256 of downloaded content
|
|
798
|
-
const sha256 = crypto.createHash('sha256').update(imageBuffer).digest('hex');
|
|
799
|
-
writeFileSync(filePath, imageBuffer);
|
|
800
|
-
downloadedCount++;
|
|
801
|
-
downloadedScreenshots.push({
|
|
802
|
-
name: sanitizedName,
|
|
803
|
-
sha256,
|
|
804
|
-
signature,
|
|
805
|
-
path: filePath,
|
|
806
|
-
properties,
|
|
807
|
-
originalUrl: downloadUrl
|
|
808
|
-
});
|
|
809
|
-
} catch (error) {
|
|
810
|
-
output.warn(`⚠️ Failed to download ${sanitizedName}: ${error.message}`);
|
|
811
|
-
errorCount++;
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
// Store baseline metadata
|
|
816
|
-
this.baselineData = {
|
|
817
|
-
buildId: build.id,
|
|
818
|
-
buildName: build.name,
|
|
819
|
-
branch: build.branch,
|
|
820
|
-
threshold: this.threshold,
|
|
821
|
-
signatureProperties: this.signatureProperties,
|
|
822
|
-
// Store for TDD comparison
|
|
823
|
-
screenshots: downloadedScreenshots
|
|
824
|
-
};
|
|
825
|
-
const metadataPath = join(this.baselinePath, 'metadata.json');
|
|
826
|
-
writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
|
|
827
|
-
|
|
828
|
-
// Save baseline build metadata
|
|
829
|
-
const baselineMetadataPath = safePath(this.workingDir, '.vizzly', 'baseline-metadata.json');
|
|
830
|
-
writeFileSync(baselineMetadataPath, JSON.stringify({
|
|
831
|
-
buildId: build.id,
|
|
832
|
-
buildName: build.name,
|
|
833
|
-
branch: build.branch,
|
|
834
|
-
commitSha: build.commit_sha,
|
|
835
|
-
downloadedAt: new Date().toISOString()
|
|
836
|
-
}, null, 2));
|
|
837
|
-
|
|
838
|
-
// Summary
|
|
839
|
-
if (skippedCount > 0 && downloadedCount === 0) {
|
|
840
|
-
output.info(`✅ All ${skippedCount} baselines up-to-date (matching local SHA)`);
|
|
841
|
-
} else if (skippedCount > 0) {
|
|
842
|
-
output.info(`✅ Downloaded ${downloadedCount} new screenshots, ${skippedCount} already up-to-date`);
|
|
843
|
-
} else {
|
|
844
|
-
output.info(`✅ Downloaded ${downloadedCount}/${screenshots.length} screenshots successfully`);
|
|
845
|
-
}
|
|
846
|
-
if (errorCount > 0) {
|
|
847
|
-
output.warn(`⚠️ ${errorCount} screenshots failed to download`);
|
|
848
|
-
}
|
|
849
|
-
return {
|
|
850
|
-
downloadedCount,
|
|
851
|
-
skippedCount,
|
|
852
|
-
errorCount,
|
|
853
|
-
buildId: build.id,
|
|
854
|
-
buildName: build.name
|
|
855
|
-
};
|
|
856
|
-
} catch (error) {
|
|
857
|
-
output.error(`❌ OAuth download failed: ${error.message} (org=${organizationSlug}, project=${projectSlug}, build=${buildId})`);
|
|
858
|
-
throw error;
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
|
|
862
708
|
/**
|
|
863
709
|
* Handle local baseline logic (either load existing or prepare for new baselines)
|
|
864
710
|
* @returns {Promise<Object|null>} Baseline data or null if no local baselines exist
|
package/dist/utils/security.js
CHANGED
|
@@ -13,6 +13,62 @@ import * as output from './output.js';
|
|
|
13
13
|
* @param {boolean} allowSlashes - Whether to allow forward slashes (for browser version strings)
|
|
14
14
|
* @returns {string} Sanitized screenshot name
|
|
15
15
|
*/
|
|
16
|
+
/**
|
|
17
|
+
* Validate screenshot name for security (no transformations, just validation)
|
|
18
|
+
* Throws if name contains path traversal or other dangerous patterns
|
|
19
|
+
*
|
|
20
|
+
* @param {string} name - Screenshot name to validate
|
|
21
|
+
* @param {number} maxLength - Maximum allowed length
|
|
22
|
+
* @returns {string} The original name (unchanged) if valid
|
|
23
|
+
* @throws {Error} If name contains dangerous patterns
|
|
24
|
+
*/
|
|
25
|
+
export function validateScreenshotName(name, maxLength = 255) {
|
|
26
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
27
|
+
throw new Error('Screenshot name must be a non-empty string');
|
|
28
|
+
}
|
|
29
|
+
if (name.length > maxLength) {
|
|
30
|
+
throw new Error(`Screenshot name exceeds maximum length of ${maxLength} characters`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Block directory traversal patterns
|
|
34
|
+
if (name.includes('..') || name.includes('\\')) {
|
|
35
|
+
throw new Error('Screenshot name contains invalid path characters');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Block forward slashes (path separators)
|
|
39
|
+
if (name.includes('/')) {
|
|
40
|
+
throw new Error('Screenshot name cannot contain forward slashes');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Block absolute paths
|
|
44
|
+
if (isAbsolute(name)) {
|
|
45
|
+
throw new Error('Screenshot name cannot be an absolute path');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Return the original name unchanged - validation only!
|
|
49
|
+
return name;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Validate screenshot name for security (allows spaces, preserves original name)
|
|
54
|
+
*
|
|
55
|
+
* This function only validates for security - it does NOT transform spaces.
|
|
56
|
+
* Spaces are preserved so that:
|
|
57
|
+
* 1. generateScreenshotSignature() uses the original name with spaces (matches cloud)
|
|
58
|
+
* 2. generateBaselineFilename() handles space→hyphen conversion (matches cloud)
|
|
59
|
+
*
|
|
60
|
+
* Flow: "VBtn dark" → sanitize → "VBtn dark" → signature: "VBtn dark|1265||" → filename: "VBtn-dark_hash.png"
|
|
61
|
+
*
|
|
62
|
+
* @param {string} name - Screenshot name to validate
|
|
63
|
+
* @param {number} maxLength - Maximum allowed length (default: 255)
|
|
64
|
+
* @param {boolean} allowSlashes - Whether to allow forward slashes (for browser version strings)
|
|
65
|
+
* @returns {string} The validated name (unchanged if valid, spaces preserved)
|
|
66
|
+
* @throws {Error} If name contains dangerous patterns
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* sanitizeScreenshotName("VBtn dark") // Returns "VBtn dark" (spaces preserved)
|
|
70
|
+
* sanitizeScreenshotName("My/Component") // Throws error (contains /)
|
|
71
|
+
*/
|
|
16
72
|
export function sanitizeScreenshotName(name, maxLength = 255, allowSlashes = false) {
|
|
17
73
|
if (typeof name !== 'string' || name.length === 0) {
|
|
18
74
|
throw new Error('Screenshot name must be a non-empty string');
|
|
@@ -36,9 +92,10 @@ export function sanitizeScreenshotName(name, maxLength = 255, allowSlashes = fal
|
|
|
36
92
|
throw new Error('Screenshot name cannot be an absolute path');
|
|
37
93
|
}
|
|
38
94
|
|
|
39
|
-
// Allow only safe characters: alphanumeric, hyphens, underscores, dots, and optionally slashes
|
|
95
|
+
// Allow only safe characters: alphanumeric, hyphens, underscores, dots, spaces, and optionally slashes
|
|
96
|
+
// Spaces are allowed here and will be converted to hyphens in generateBaselineFilename() to match cloud behavior
|
|
40
97
|
// Replace other characters with underscores
|
|
41
|
-
const allowedChars = allowSlashes ? /[^a-zA-Z0-9._/-]/g : /[^a-zA-Z0-9._-]/g;
|
|
98
|
+
const allowedChars = allowSlashes ? /[^a-zA-Z0-9._ /-]/g : /[^a-zA-Z0-9._ -]/g;
|
|
42
99
|
let sanitized = name.replace(allowedChars, '_');
|
|
43
100
|
|
|
44
101
|
// Prevent names that start with dots (hidden files)
|