UNPKG

@vizzly-testing/cli

Version:

Visual review platform for UI developers and designers

279 lines (264 loc) 9.65 kB
import { createApiClient as defaultCreateApiClient, finalizeBuild as defaultFinalizeBuild, getTokenContext as defaultGetTokenContext } from '../api/index.js'; import { createUploader as defaultCreateUploader } from '../services/uploader.js'; import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js'; import { detectBranch as defaultDetectBranch, detectCommit as defaultDetectCommit, detectCommitMessage as defaultDetectCommitMessage, detectPullRequestNumber as defaultDetectPullRequestNumber, generateBuildNameWithGit as defaultGenerateBuildNameWithGit } from '../utils/git.js'; import * as defaultOutput from '../utils/output.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 * @param {Object} deps - Dependencies * @returns {Promise<string>} Proper build URL */ export async function constructBuildUrl(buildId, apiUrl, apiToken, deps = {}) { let { createApiClient = defaultCreateApiClient, getTokenContext = defaultGetTokenContext, output = defaultOutput } = deps; try { let client = createApiClient({ baseUrl: apiUrl, token: apiToken, command: 'upload' }); let tokenContext = await getTokenContext(client); let 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 output.debug('Failed to fetch token context, using fallback URL:', { error: error.message }); } // Fallback URL construction let 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 * @param {Object} deps - Dependencies for testing */ export async function uploadCommand(screenshotsPath, options = {}, globalOptions = {}, deps = {}) { let { loadConfig = defaultLoadConfig, createApiClient = defaultCreateApiClient, finalizeBuild = defaultFinalizeBuild, createUploader = defaultCreateUploader, detectBranch = defaultDetectBranch, detectCommit = defaultDetectCommit, detectCommitMessage = defaultDetectCommitMessage, detectPullRequestNumber = defaultDetectPullRequestNumber, generateBuildNameWithGit = defaultGenerateBuildNameWithGit, output = defaultOutput, exit = code => process.exit(code), buildUrlConstructor = constructBuildUrl } = deps; output.configure({ json: globalOptions.json, verbose: globalOptions.verbose, color: !globalOptions.noColor }); let buildId = null; let config = null; const uploadStartTime = Date.now(); try { output.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) { output.error('API token required. Use --token or set VIZZLY_TOKEN environment variable'); exit(1); return { success: false, reason: 'no-api-key' }; } // 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(); output.info(`Uploading screenshots from: ${screenshotsPath}`); if (globalOptions.verbose) { output.info('Configuration loaded'); output.debug('Config details', { branch, commit: commit?.substring(0, 7), environment: config.build.environment, buildName: config.build.name }); } // Create uploader output.startSpinner('Initializing uploader...'); let uploader = createUploader({ ...config, command: 'upload' }); // 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; } } output.progress(displayMessage || 'Processing...', current, total); } }; // Start upload output.progress('Starting upload...'); const result = await uploader.upload(uploadOptions); buildId = result.buildId; // Ensure we have the buildId // Mark build as completed if (result.buildId) { output.progress('Finalizing build...'); try { let client = createApiClient({ baseUrl: config.apiUrl, token: config.apiKey, command: 'upload' }); let executionTime = Date.now() - uploadStartTime; await finalizeBuild(client, result.buildId, true, executionTime); } catch (error) { output.warn(`Failed to finalize build: ${error.message}`); } } output.complete('Upload completed'); // Show Vizzly summary if (result.buildId) { output.blank(); output.keyValue({ Uploaded: `${result.stats.uploaded} of ${result.stats.total}`, Build: result.buildId }); // Use API-provided URL or construct proper URL with org/project context let buildUrl = result.url || (await buildUrlConstructor(result.buildId, config.apiUrl, config.apiKey, deps)); output.blank(); output.labelValue('View', output.link('Results', buildUrl)); } // Wait for build completion if requested if (options.wait && result.buildId) { output.startSpinner('Processing comparisons...'); let buildResult = await uploader.waitForBuild(result.buildId); output.stopSpinner(); output.complete('Build processing completed'); // Show build processing results let colors = output.getColors(); if (buildResult.failedComparisons > 0) { output.blank(); output.print(` ${colors.brand.danger(buildResult.failedComparisons)} visual comparisons failed`); } else { output.blank(); output.print(` ${colors.brand.success(buildResult.passedComparisons)} visual comparisons passed`); } // Use API-provided URL or construct proper URL with org/project context let waitBuildUrl = buildResult.url || (await buildUrlConstructor(result.buildId, config.apiUrl, config.apiKey, deps)); output.blank(); output.labelValue('View', output.link('Results', waitBuildUrl)); } output.cleanup(); return { success: true, result }; } catch (error) { // Mark build as failed if we have a buildId and config if (buildId && config) { try { let client = createApiClient({ baseUrl: config.apiUrl, token: config.apiKey, command: 'upload' }); let executionTime = Date.now() - uploadStartTime; await finalizeBuild(client, buildId, false, executionTime); } catch { // Silent fail on cleanup } } // Use user-friendly error message if available let errorMessage = error?.getUserMessage ? error.getUserMessage() : error.message; output.error(errorMessage || 'Upload failed', error); exit(1); return { success: false, 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 (Number.isNaN(threshold) || threshold < 0) { errors.push('Threshold must be a non-negative number (CIEDE2000 Delta E)'); } } 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; }