@vizzly-testing/cli
Version:
Visual review platform for UI developers and designers
396 lines (362 loc) • 10.6 kB
JavaScript
/**
* Uploader Core - Pure functions for upload logic
*
* No I/O, no side effects - just data transformations.
*/
import crypto from 'node:crypto';
import { basename } from 'node:path';
// ============================================================================
// Constants
// ============================================================================
export const DEFAULT_BATCH_SIZE = 50;
export const DEFAULT_SHA_CHECK_BATCH_SIZE = 100;
export const DEFAULT_TIMEOUT = 30000; // 30 seconds
// ============================================================================
// Validation
// ============================================================================
/**
* Validate API key is present
* @param {string|undefined} apiKey - API key to validate
* @returns {{ valid: boolean, error: string|null }}
*/
export function validateApiKey(apiKey) {
if (!apiKey) {
return {
valid: false,
error: 'API key is required'
};
}
return {
valid: true,
error: null
};
}
/**
* Validate screenshots directory path
* @param {string|undefined} screenshotsDir - Directory path
* @returns {{ valid: boolean, error: string|null }}
*/
export function validateScreenshotsDir(screenshotsDir) {
if (!screenshotsDir) {
return {
valid: false,
error: 'Screenshots directory is required'
};
}
return {
valid: true,
error: null
};
}
/**
* Validate directory stats
* @param {Object} stats - fs.stat result
* @param {string} path - Directory path for error message
* @returns {{ valid: boolean, error: string|null }}
*/
export function validateDirectoryStats(stats, path) {
if (!stats.isDirectory()) {
return {
valid: false,
error: `${path} is not a directory`
};
}
return {
valid: true,
error: null
};
}
/**
* Validate files were found
* @param {Array} files - Array of file paths
* @param {string} directory - Directory that was searched
* @returns {{ valid: boolean, error: string|null, context?: Object }}
*/
export function validateFilesFound(files, directory) {
if (!files || files.length === 0) {
return {
valid: false,
error: 'No screenshot files found',
context: {
directory,
pattern: '**/*.png'
}
};
}
return {
valid: true,
error: null
};
}
// ============================================================================
// Browser Extraction
// ============================================================================
const KNOWN_BROWSERS = ['chrome', 'firefox', 'safari', 'edge', 'webkit'];
/**
* Extract browser name from filename
* @param {string} filename - The screenshot filename
* @returns {string|null} Browser name or null if not found
*/
export function extractBrowserFromFilename(filename) {
let lowerFilename = filename.toLowerCase();
for (let browser of KNOWN_BROWSERS) {
if (lowerFilename.includes(browser)) {
return browser;
}
}
return null;
}
// ============================================================================
// Build Info Construction
// ============================================================================
/**
* Build API build info payload
* @param {Object} options - Upload options
* @param {string} [defaultBranch] - Default branch to use
* @returns {Object} Build info payload
*/
export function buildBuildInfo(options, defaultBranch = 'main') {
return {
name: options.buildName || `Upload ${new Date().toISOString()}`,
branch: options.branch || defaultBranch || 'main',
commit_sha: options.commit,
commit_message: options.message,
environment: options.environment || 'production',
threshold: options.threshold,
github_pull_request_number: options.pullRequestNumber,
parallel_id: options.parallelId
};
}
// ============================================================================
// File Metadata Processing
// ============================================================================
/**
* Compute SHA256 hash of a buffer
* @param {Buffer} buffer - File buffer
* @returns {string} Hex-encoded SHA256 hash
*/
export function computeSha256(buffer) {
return crypto.createHash('sha256').update(buffer).digest('hex');
}
/**
* Build file metadata object
* @param {string} filePath - Path to file
* @param {Buffer} buffer - File contents
* @returns {Object} File metadata
*/
export function buildFileMetadata(filePath, buffer) {
return {
path: filePath,
filename: basename(filePath),
buffer,
sha256: computeSha256(buffer)
};
}
/**
* Convert file metadata to screenshot check format
* @param {Object} file - File metadata
* @returns {Object} Screenshot format for SHA check
*/
export function fileToScreenshotFormat(file) {
return {
sha256: file.sha256,
name: file.filename.replace(/\.png$/, ''),
browser: extractBrowserFromFilename(file.filename) || 'chrome',
viewport_width: 1920,
viewport_height: 1080
};
}
/**
* Partition files into those that need upload and those that exist
* @param {Array} fileMetadata - All file metadata
* @param {Set} existingShas - Set of SHAs that already exist
* @returns {{ toUpload: Array, existing: Array }}
*/
export function partitionFilesByExistence(fileMetadata, existingShas) {
return {
toUpload: fileMetadata.filter(f => !existingShas.has(f.sha256)),
existing: fileMetadata.filter(f => existingShas.has(f.sha256))
};
}
// ============================================================================
// Progress Reporting
// ============================================================================
/**
* Build scanning phase progress
* @param {number} total - Total files found
* @returns {Object} Progress object
*/
export function buildScanningProgress(total) {
return {
phase: 'scanning',
message: `Found ${total} screenshots`,
total
};
}
/**
* Build processing phase progress
* @param {number} current - Current file number
* @param {number} total - Total files
* @returns {Object} Progress object
*/
export function buildProcessingProgress(current, total) {
return {
phase: 'processing',
message: 'Processing files',
current,
total
};
}
/**
* Build deduplication phase progress
* @param {number} toUpload - Files to upload
* @param {number} existing - Existing files
* @param {number} total - Total files
* @returns {Object} Progress object
*/
export function buildDeduplicationProgress(toUpload, existing, total) {
return {
phase: 'deduplication',
message: `Checking for duplicates (${toUpload} to upload, ${existing} existing)`,
toUpload,
existing,
total
};
}
/**
* Build uploading phase progress
* @param {number} current - Current upload number
* @param {number} total - Total to upload
* @returns {Object} Progress object
*/
export function buildUploadingProgress(current, total) {
return {
phase: 'uploading',
message: 'Uploading screenshots',
current,
total
};
}
/**
* Build completed phase progress
* @param {string} buildId - Build ID
* @param {string|null} url - Build URL
* @returns {Object} Progress object
*/
export function buildCompletedProgress(buildId, url) {
return {
phase: 'completed',
message: 'Upload completed',
buildId,
url
};
}
// ============================================================================
// Result Building
// ============================================================================
/**
* Build successful upload result
* @param {Object} options - Options
* @param {string} options.buildId - Build ID
* @param {string|null} options.url - Build URL
* @param {number} options.total - Total files
* @param {number} options.uploaded - Files uploaded
* @param {number} options.skipped - Files skipped
* @returns {Object} Upload result
*/
export function buildUploadResult({
buildId,
url,
total,
uploaded,
skipped
}) {
return {
success: true,
buildId,
url,
stats: {
total,
uploaded,
skipped
}
};
}
/**
* Build wait result from build response
* @param {Object} build - Build object from API
* @returns {Object} Wait result
*/
export function buildWaitResult(build) {
let result = {
status: 'completed',
build
};
if (typeof build.comparisonsTotal === 'number') {
result.comparisons = build.comparisonsTotal;
result.passedComparisons = build.comparisonsPassed || 0;
result.failedComparisons = build.comparisonsFailed || 0;
} else {
result.passedComparisons = 0;
result.failedComparisons = 0;
}
if (build.url) {
result.url = build.url;
}
return result;
}
// ============================================================================
// Configuration
// ============================================================================
/**
* Resolve batch size from options and config
* @param {Object} options - Runtime options
* @param {Object} uploadConfig - Upload configuration
* @returns {number} Resolved batch size
*/
export function resolveBatchSize(options, uploadConfig) {
return Number(options?.batchSize ?? uploadConfig?.batchSize ?? DEFAULT_BATCH_SIZE);
}
/**
* Resolve timeout from options and config
* @param {Object} options - Runtime options
* @param {Object} uploadConfig - Upload configuration
* @returns {number} Resolved timeout in ms
*/
export function resolveTimeout(options, uploadConfig) {
return Number(options?.timeout ?? uploadConfig?.timeout ?? DEFAULT_TIMEOUT);
}
/**
* Check if timeout has been exceeded
* @param {number} startTime - Start timestamp
* @param {number} timeout - Timeout in ms
* @returns {boolean} True if timed out
*/
export function isTimedOut(startTime, timeout) {
return Date.now() - startTime >= timeout;
}
/**
* Get elapsed time since start
* @param {number} startTime - Start timestamp
* @returns {number} Elapsed time in ms
*/
export function getElapsedTime(startTime) {
return Date.now() - startTime;
}
/**
* Build glob pattern for screenshots
* @param {string} directory - Base directory
* @returns {string} Glob pattern
*/
export function buildScreenshotPattern(directory) {
return `${directory}/**/*.png`;
}
/**
* Extract status code from error message
* @param {string} errorMessage - Error message
* @returns {string} Status code or 'unknown'
*/
export function extractStatusCodeFromError(errorMessage) {
let match = String(errorMessage || '').match(/API request failed: (\d+)/);
return match ? match[1] : 'unknown';
}