@vizzly-testing/cli
Version:
Visual review platform for UI developers and designers
412 lines (391 loc) • 11.9 kB
JavaScript
/**
* Uploader Operations - I/O operations with dependency injection
*
* Each operation takes its dependencies as parameters for testability.
*/
import { buildBuildInfo, buildCompletedProgress, buildDeduplicationProgress, buildFileMetadata, buildProcessingProgress, buildScanningProgress, buildScreenshotPattern, buildUploadingProgress, buildUploadResult, buildWaitResult, DEFAULT_SHA_CHECK_BATCH_SIZE, extractStatusCodeFromError, fileToScreenshotFormat, getElapsedTime, isTimedOut, partitionFilesByExistence, validateApiKey, validateDirectoryStats, validateFilesFound, validateScreenshotsDir } from './core.js';
// ============================================================================
// File Discovery
// ============================================================================
/**
* Find all PNG screenshots in a directory
* @param {Object} options - Options
* @param {string} options.directory - Directory to search
* @param {Object} options.deps - Dependencies
* @param {Function} options.deps.glob - Glob function
* @returns {Promise<Array<string>>} Array of file paths
*/
export async function findScreenshots({
directory,
deps
}) {
let {
glob
} = deps;
let pattern = buildScreenshotPattern(directory);
return glob(pattern, {
absolute: true
});
}
// ============================================================================
// File Processing
// ============================================================================
/**
* Process files to extract metadata and compute hashes
* @param {Object} options - Options
* @param {Array<string>} options.files - File paths
* @param {AbortSignal} options.signal - Abort signal
* @param {Function} options.onProgress - Progress callback
* @param {Object} options.deps - Dependencies
* @param {Function} options.deps.readFile - File read function
* @param {Function} options.deps.createError - Error factory
* @returns {Promise<Array>} File metadata array
*/
export async function processFiles({
files,
signal,
onProgress,
deps
}) {
let {
readFile,
createError
} = deps;
let results = [];
let count = 0;
for (let filePath of files) {
if (signal.aborted) {
throw createError('Operation cancelled', 'UPLOAD_CANCELLED');
}
let buffer = await readFile(filePath);
let metadata = buildFileMetadata(filePath, buffer);
results.push(metadata);
count++;
if (count % 10 === 0 || count === files.length) {
onProgress(count);
}
}
return results;
}
// ============================================================================
// SHA Checking / Deduplication
// ============================================================================
/**
* Check which files already exist on the server
* @param {Object} options - Options
* @param {Array} options.fileMetadata - File metadata array
* @param {Object} options.client - API client
* @param {AbortSignal} options.signal - Abort signal
* @param {string} options.buildId - Build ID
* @param {Object} options.deps - Dependencies
* @param {Function} options.deps.checkShas - SHA check API function
* @param {Function} options.deps.createError - Error factory
* @param {Object} options.deps.output - Output utilities
* @returns {Promise<{ toUpload: Array, existing: Array, screenshots: Array }>}
*/
export async function checkExistingFiles({
fileMetadata,
client,
signal,
buildId,
deps
}) {
let {
checkShas,
createError,
output
} = deps;
let existingShas = new Set();
let allScreenshots = [];
for (let i = 0; i < fileMetadata.length; i += DEFAULT_SHA_CHECK_BATCH_SIZE) {
if (signal.aborted) {
throw createError('Operation cancelled', 'UPLOAD_CANCELLED');
}
let batch = fileMetadata.slice(i, i + DEFAULT_SHA_CHECK_BATCH_SIZE);
let screenshotBatch = batch.map(fileToScreenshotFormat);
try {
let res = await checkShas(client, screenshotBatch, buildId);
let {
existing = [],
screenshots = []
} = res || {};
for (let sha of existing) {
existingShas.add(sha);
}
allScreenshots.push(...screenshots);
} catch (error) {
output.debug('upload', 'SHA check failed, continuing without deduplication', {
error: error.message
});
}
}
let partitioned = partitionFilesByExistence(fileMetadata, existingShas);
return {
toUpload: partitioned.toUpload,
existing: partitioned.existing,
screenshots: allScreenshots
};
}
// ============================================================================
// File Upload
// ============================================================================
/**
* Upload files to Vizzly
* @param {Object} options - Options
* @param {Array} options.toUpload - Files to upload
* @param {string} options.buildId - Build ID
* @param {Object} options.client - API client
* @param {AbortSignal} options.signal - Abort signal
* @param {number} options.batchSize - Batch size
* @param {Function} options.onProgress - Progress callback
* @param {Object} options.deps - Dependencies
* @param {Function} options.deps.createError - Error factory
* @returns {Promise<{ buildId: string, url: string|null }>}
*/
export async function uploadFiles({
toUpload,
buildId,
client,
signal,
batchSize,
onProgress,
deps
}) {
let {
createError
} = deps;
let result = null;
if (toUpload.length === 0) {
return {
buildId,
url: null
};
}
for (let i = 0; i < toUpload.length; i += batchSize) {
if (signal.aborted) {
throw createError('Operation cancelled', 'UPLOAD_CANCELLED');
}
let batch = toUpload.slice(i, i + batchSize);
let form = new FormData();
form.append('build_id', buildId);
for (let file of batch) {
let blob = new Blob([file.buffer], {
type: 'image/png'
});
form.append('screenshots', blob, file.filename);
}
try {
result = await client.request('/api/sdk/upload', {
method: 'POST',
body: form,
signal,
headers: {}
});
} catch (err) {
throw createError(`Upload failed: ${err.message}`, 'UPLOAD_FAILED', {
batch: Math.floor(i / batchSize) + 1
});
}
onProgress(i + batch.length);
}
return {
buildId,
url: result?.build?.url || result?.url
};
}
// ============================================================================
// Build Waiting
// ============================================================================
/**
* Wait for a build to complete
* @param {Object} options - Options
* @param {string} options.buildId - Build ID
* @param {number} options.timeout - Timeout in ms
* @param {AbortSignal} options.signal - Abort signal
* @param {Object} options.client - API client
* @param {Object} options.deps - Dependencies
* @param {Function} options.deps.createError - Error factory
* @param {Function} options.deps.createTimeoutError - Timeout error factory
* @returns {Promise<Object>} Build result
*/
export async function waitForBuild({
buildId,
timeout,
signal,
client,
deps
}) {
let {
createError,
createTimeoutError
} = deps;
let startTime = Date.now();
while (!isTimedOut(startTime, timeout)) {
if (signal.aborted) {
throw createError('Operation cancelled', 'UPLOAD_CANCELLED', {
buildId
});
}
let resp;
try {
resp = await client.request(`/api/sdk/builds/${buildId}`, {
signal
});
} catch (err) {
let code = extractStatusCodeFromError(err?.message);
throw createError(`Failed to check build status: ${code}`, 'BUILD_STATUS_FAILED');
}
let build = resp?.build ?? resp;
if (build.status === 'completed') {
return buildWaitResult(build);
}
if (build.status === 'failed') {
throw createError(`Build failed: ${build.error || 'Unknown error'}`, 'BUILD_FAILED');
}
}
throw createTimeoutError(`Build timed out after ${timeout}ms`, {
buildId,
timeout,
elapsed: getElapsedTime(startTime)
});
}
// ============================================================================
// Main Upload Operation
// ============================================================================
/**
* Upload screenshots to Vizzly
* @param {Object} options - Options
* @param {Object} options.uploadOptions - Upload options (screenshotsDir, buildName, etc.)
* @param {Object} options.config - Configuration
* @param {AbortSignal} options.signal - Abort signal
* @param {number} options.batchSize - Batch size
* @param {Object} options.deps - Dependencies
* @returns {Promise<Object>} Upload result
*/
export async function upload({
uploadOptions,
config,
signal,
batchSize,
deps
}) {
let {
client,
createBuild,
getDefaultBranch,
glob,
readFile,
stat,
checkShas,
createError,
createValidationError,
createUploadError,
output
} = deps;
let {
screenshotsDir,
onProgress = () => {}
} = uploadOptions;
try {
// Validate API key
let apiKeyValidation = validateApiKey(config.apiKey);
if (!apiKeyValidation.valid) {
throw createValidationError(apiKeyValidation.error, {
config: {
apiKey: config.apiKey,
apiUrl: config.apiUrl
}
});
}
// Validate screenshots directory
let dirValidation = validateScreenshotsDir(screenshotsDir);
if (!dirValidation.valid) {
throw createValidationError(dirValidation.error);
}
let stats = await stat(screenshotsDir);
let statsValidation = validateDirectoryStats(stats, screenshotsDir);
if (!statsValidation.valid) {
throw createValidationError(statsValidation.error);
}
// Find screenshots
let files = await findScreenshots({
directory: screenshotsDir,
deps: {
glob
}
});
let filesValidation = validateFilesFound(files, screenshotsDir);
if (!filesValidation.valid) {
throw createUploadError(filesValidation.error, filesValidation.context);
}
onProgress(buildScanningProgress(files.length));
// Process files
let fileMetadata = await processFiles({
files,
signal,
onProgress: current => onProgress(buildProcessingProgress(current, files.length)),
deps: {
readFile,
createError
}
});
// Create build
let defaultBranch = await getDefaultBranch();
let buildInfo = buildBuildInfo(uploadOptions, defaultBranch);
let build = await createBuild(client, buildInfo);
let buildId = build.id;
// Check existing files
let {
toUpload,
existing,
screenshots
} = await checkExistingFiles({
fileMetadata,
client,
signal,
buildId,
deps: {
checkShas,
createError,
output
}
});
onProgress(buildDeduplicationProgress(toUpload.length, existing.length, files.length));
// Upload files
let result = await uploadFiles({
toUpload,
existing,
screenshots,
buildId,
buildInfo,
client,
signal,
batchSize,
onProgress: current => onProgress(buildUploadingProgress(current, toUpload.length)),
deps: {
createError
}
});
onProgress(buildCompletedProgress(result.buildId, result.url));
return buildUploadResult({
buildId: result.buildId,
url: result.url,
total: files.length,
uploaded: toUpload.length,
skipped: existing.length
});
} catch (error) {
output.debug('upload', 'failed', {
error: error.message
});
// Re-throw if already a VizzlyError
if (error.name?.includes('Error') && error.code) {
throw error;
}
// Wrap unknown errors
throw createUploadError(`Upload failed: ${error.message}`, {
originalError: error.message,
stack: error.stack
});
}
}