@vizzly-testing/cli
Version:
Visual review platform for UI developers and designers
235 lines (218 loc) • 8.25 kB
JavaScript
import { loadConfig } from '../utils/config-loader.js';
import { ConsoleUI } from '../utils/console-ui.js';
import { createServiceContainer } from '../container/index.js';
import { detectBranch, detectCommit, detectCommitMessage, detectPullRequestNumber, generateBuildNameWithGit } from '../utils/git.js';
import { ApiService } from '../services/api-service.js';
/**
* Construct proper build URL with org/project context
* @param {string} buildId - Build ID
* @param {string} apiUrl - API base URL
* @param {string} apiToken - API token
* @returns {Promise<string>} Proper build URL
*/
async function constructBuildUrl(buildId, apiUrl, apiToken) {
try {
const apiService = new ApiService({
baseUrl: apiUrl,
token: apiToken,
command: 'upload'
});
const tokenContext = await apiService.getTokenContext();
const baseUrl = apiUrl.replace(/\/api.*$/, '');
if (tokenContext.organization?.slug && tokenContext.project?.slug) {
return `${baseUrl}/${tokenContext.organization.slug}/${tokenContext.project.slug}/builds/${buildId}`;
}
} catch (error) {
// Fall back to simple URL if context fetch fails
console.debug('Failed to fetch token context, using fallback URL:', error.message);
}
// Fallback URL construction
const baseUrl = apiUrl.replace(/\/api.*$/, '');
return `${baseUrl}/builds/${buildId}`;
}
/**
* Upload command implementation
* @param {string} screenshotsPath - Path to screenshots
* @param {Object} options - Command options
* @param {Object} globalOptions - Global CLI options
*/
export async function uploadCommand(screenshotsPath, options = {}, globalOptions = {}) {
// Create UI handler
const ui = new ConsoleUI({
json: globalOptions.json,
verbose: globalOptions.verbose,
color: !globalOptions.noColor
});
// Note: ConsoleUI handles cleanup via global process listeners
let buildId = null;
let config = null;
const uploadStartTime = Date.now();
try {
ui.info('Starting upload process...');
// Load configuration with CLI overrides
const allOptions = {
...globalOptions,
...options
};
config = await loadConfig(globalOptions.config, allOptions);
// Validate API token
if (!config.apiKey) {
ui.error('API token required. Use --token or set VIZZLY_TOKEN environment variable');
return; // Won't reach here due to process.exit in error()
}
// Collect git metadata if not provided
const branch = await detectBranch(options.branch);
const commit = await detectCommit(options.commit);
const message = options.message || (await detectCommitMessage());
const buildName = await generateBuildNameWithGit(options.buildName);
const pullRequestNumber = detectPullRequestNumber();
ui.info(`Uploading screenshots from: ${screenshotsPath}`);
if (globalOptions.verbose) {
ui.info('Configuration loaded', {
branch,
commit: commit?.substring(0, 7),
environment: config.build.environment,
buildName: config.build.name
});
}
// Get uploader service
ui.startSpinner('Initializing uploader...');
const container = await createServiceContainer(config, 'upload');
const uploader = await container.get('uploader');
// Prepare upload options with progress callback
const uploadOptions = {
screenshotsDir: screenshotsPath,
buildName,
branch,
commit,
message,
environment: config.build.environment,
threshold: config.comparison.threshold,
uploadAll: options.uploadAll || false,
metadata: options.metadata ? JSON.parse(options.metadata) : {},
pullRequestNumber,
parallelId: config.parallelId,
onProgress: progressData => {
const {
message: progressMessage,
current,
total,
phase,
buildId: progressBuildId
} = progressData;
// Track buildId when it becomes available
if (progressBuildId) {
buildId = progressBuildId;
}
let displayMessage = progressMessage;
if (!displayMessage && phase) {
if (current !== undefined && total !== undefined) {
displayMessage = `${phase}: ${current}/${total}`;
} else {
displayMessage = phase;
}
}
ui.progress(displayMessage || 'Processing...', current, total);
}
};
// Start upload
ui.progress('Starting upload...');
const result = await uploader.upload(uploadOptions);
buildId = result.buildId; // Ensure we have the buildId
// Mark build as completed
if (result.buildId) {
ui.progress('Finalizing build...');
try {
const apiService = new ApiService({
baseUrl: config.apiUrl,
token: config.apiKey,
command: 'upload'
});
const executionTime = Date.now() - uploadStartTime;
await apiService.finalizeBuild(result.buildId, true, executionTime);
} catch (error) {
ui.warning(`Failed to finalize build: ${error.message}`);
}
}
ui.success('Upload completed successfully');
// Show Vizzly summary
if (result.buildId) {
ui.info(`🐻 Vizzly: Uploaded ${result.stats.uploaded} of ${result.stats.total} screenshots to build ${result.buildId}`);
// Use API-provided URL or construct proper URL with org/project context
const buildUrl = result.url || (await constructBuildUrl(result.buildId, config.apiUrl, config.apiKey));
ui.info(`🔗 Vizzly: View results at ${buildUrl}`);
}
// Wait for build completion if requested
if (options.wait && result.buildId) {
ui.info('Waiting for build completion...');
ui.startSpinner('Processing comparisons...');
const buildResult = await uploader.waitForBuild(result.buildId);
ui.success('Build processing completed');
// Show build processing results
if (buildResult.failedComparisons > 0) {
ui.warning(`${buildResult.failedComparisons} visual comparisons failed`);
} else {
ui.success(`All ${buildResult.passedComparisons} visual comparisons passed`);
}
// Use API-provided URL or construct proper URL with org/project context
const buildUrl = buildResult.url || (await constructBuildUrl(result.buildId, config.apiUrl, config.apiKey));
ui.info(`🔗 Vizzly: View results at ${buildUrl}`);
}
ui.cleanup();
} catch (error) {
// Mark build as failed if we have a buildId and config
if (buildId && config) {
try {
const apiService = new ApiService({
baseUrl: config.apiUrl,
token: config.apiKey,
command: 'upload'
});
const executionTime = Date.now() - uploadStartTime;
await apiService.finalizeBuild(buildId, false, executionTime);
} catch {
// Silent fail on cleanup
}
}
// Use user-friendly error message if available
const errorMessage = error?.getUserMessage ? error.getUserMessage() : error.message;
ui.error(errorMessage || 'Upload failed', error);
}
}
/**
* Validate upload options
* @param {string} screenshotsPath - Path to screenshots
* @param {Object} options - Command options
*/
export function validateUploadOptions(screenshotsPath, options) {
const errors = [];
if (!screenshotsPath) {
errors.push('Screenshots path is required');
}
if (options.metadata) {
try {
JSON.parse(options.metadata);
} catch {
errors.push('Invalid JSON in --metadata option');
}
}
if (options.threshold !== undefined) {
const threshold = parseFloat(options.threshold);
if (isNaN(threshold) || threshold < 0 || threshold > 1) {
errors.push('Threshold must be a number between 0 and 1');
}
}
if (options.batchSize !== undefined) {
const n = parseInt(options.batchSize, 10);
if (!Number.isFinite(n) || n <= 0) {
errors.push('Batch size must be a positive integer');
}
}
if (options.uploadTimeout !== undefined) {
const n = parseInt(options.uploadTimeout, 10);
if (!Number.isFinite(n) || n <= 0) {
errors.push('Upload timeout must be a positive integer (milliseconds)');
}
}
return errors;
}