@vizzly-testing/cli
Version:
Visual review platform for UI developers and designers
211 lines (201 loc) • 6.93 kB
JavaScript
import { Buffer } from 'node:buffer';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { uploadScreenshot as defaultUploadScreenshot } from '../../api/index.js';
import { detectImageInputType } from '../../utils/image-input-detector.js';
import * as output from '../../utils/output.js';
/**
* API Handler - Non-blocking screenshot upload
*
* Flow:
* ┌─────────────────────────────────────────────────────────────┐
* │ Test Suite │
* │ ↓ vizzlyScreenshot() │
* │ ↓ HTTP POST to localhost │
* │ ↓ │
* │ Screenshot Server │
* │ ↓ handleScreenshot() │
* │ ├─→ Convert base64 to Buffer │
* │ ├─→ Fire upload promise (NO AWAIT) ─────┐ │
* │ └─→ Return 200 immediately │ │
* │ │ │
* │ Test continues (NO BLOCKING) ✓ │ │
* │ ↓ │
* │ Background Upload │
* │ (to Vizzly API) │
* │ ↓ │
* │ Promise resolves/rejects │
* │ │
* │ Build Finalization │
* │ ↓ flush() │
* │ └─→ await Promise.allSettled(uploadPromises) │
* │ ↓ │
* │ All uploads complete ✓ │
* └─────────────────────────────────────────────────────────────┘
*/
/**
* Create an API handler for screenshot uploads.
* @param {Object} client - API client with request method
* @param {Object} options - Optional dependencies for testing
* @param {Function} options.uploadScreenshot - Upload function (defaults to API uploadScreenshot)
*/
export const createApiHandler = (client, {
uploadScreenshot = defaultUploadScreenshot
} = {}) => {
let vizzlyDisabled = false;
let screenshotCount = 0;
let uploadPromises = [];
const handleScreenshot = async (buildId, name, image, properties = {}) => {
if (vizzlyDisabled) {
output.debug('upload', `${name} (disabled)`);
return {
statusCode: 200,
body: {
success: true,
disabled: true,
count: ++screenshotCount,
message: `Vizzly disabled - ${screenshotCount} screenshots captured but not uploaded`
}
};
}
// buildId is optional - API will handle it appropriately
if (!client) {
return {
statusCode: 500,
body: {
error: 'API client not available'
}
};
}
// Support both base64 encoded images and file paths
let imageBuffer;
const inputType = detectImageInputType(image);
if (inputType === 'file-path') {
// It's a file path - resolve and read the file
const filePath = resolve(image.replace('file://', ''));
if (!existsSync(filePath)) {
return {
statusCode: 400,
body: {
error: `Screenshot file not found: ${filePath}`,
originalPath: image
}
};
}
try {
imageBuffer = readFileSync(filePath);
} catch (error) {
return {
statusCode: 500,
body: {
error: `Failed to read screenshot file: ${error.message}`,
filePath
}
};
}
} else if (inputType === 'base64') {
// It's base64 encoded
try {
imageBuffer = Buffer.from(image, 'base64');
} catch (error) {
return {
statusCode: 400,
body: {
error: `Invalid base64 image data: ${error.message}`
}
};
}
} else {
// Unknown input type
return {
statusCode: 400,
body: {
error: 'Invalid image input: must be a file path or base64 encoded image data',
receivedType: typeof image
}
};
}
screenshotCount++;
// Fire upload in background - DON'T AWAIT!
let uploadPromise = uploadScreenshot(client, buildId, name, imageBuffer, properties ?? {}).then(result => {
if (!result.skipped) {
output.debug('upload', name);
}
return {
success: true,
name,
result
};
}).catch(uploadError => {
output.debug('upload', `${name} failed`, {
error: uploadError.message
});
vizzlyDisabled = true;
output.warn('Vizzly disabled due to upload error - continuing tests without visual testing');
return {
success: false,
name,
error: uploadError
};
});
// Collect promise for later flushing
uploadPromises.push(uploadPromise);
// Return immediately - test continues without waiting!
return {
statusCode: 200,
body: {
success: true,
name,
count: screenshotCount
}
};
};
const getScreenshotCount = () => screenshotCount;
/**
* Wait for all background uploads to complete
* Call this before build finalization to ensure all uploads finish
*/
const flush = async () => {
if (uploadPromises.length === 0) {
return {
uploaded: 0,
failed: 0,
total: 0
};
}
output.debug('upload', `flushing ${uploadPromises.length} uploads`);
const results = await Promise.allSettled(uploadPromises);
let uploaded = 0;
let failed = 0;
results.forEach(result => {
if (result.status === 'fulfilled' && result.value.success) {
uploaded++;
} else {
failed++;
}
});
output.debug('upload', 'flush complete', {
uploaded,
failed
});
// Clear promises array
uploadPromises = [];
return {
uploaded,
failed,
total: results.length
};
};
const cleanup = () => {
vizzlyDisabled = false;
screenshotCount = 0;
uploadPromises = [];
// Silent cleanup
};
return {
handleScreenshot,
getScreenshotCount,
flush,
cleanup
};
};