@vizzly-testing/cli
Version:
Visual review platform for UI developers and designers
366 lines (341 loc) • 10.7 kB
JavaScript
/**
* Vizzly SDK - Full API for custom integrations
*
* This is the comprehensive SDK for building custom Vizzly integrations.
* For simple test runner usage, use @vizzly-testing/cli/client instead.
*/
/**
* @module @vizzly-testing/cli/sdk
* @description Full SDK for custom integrations and advanced usage
*/
import { EventEmitter } from 'node:events';
import { VizzlyError } from '../errors/vizzly-error.js';
import { ScreenshotServer } from '../services/screenshot-server.js';
import { createUploader } from '../services/uploader.js';
import { createTDDService } from '../tdd/tdd-service.js';
import { loadConfig } from '../utils/config-loader.js';
import { resolveImageBuffer } from '../utils/file-helpers.js';
import * as output from '../utils/output.js';
/**
* Create a new Vizzly instance with custom configuration
*
* @param {Object} [config] - Configuration options
* @returns {Promise<VizzlySDK>} Configured Vizzly SDK instance
*
* @example
* // Create with custom config
* import { createVizzly } from '@vizzly-testing/cli/sdk';
*
* const vizzly = await createVizzly({
* apiKey: process.env.VIZZLY_TOKEN,
* apiUrl: 'https://app.vizzly.dev',
* server: {
* port: 3003,
* enabled: true
* }
* });
*
* // Start the server
* await vizzly.start();
*
* // Take screenshots
* const screenshot = await getScreenshotSomehow();
* await vizzly.screenshot('my-test', screenshot);
*
* // Upload results
* const result = await vizzly.upload();
* console.log(`Build URL: ${result.url}`);
*
* // Cleanup
* await vizzly.stop();
*/
export function createVizzly(config = {}, options = {}) {
// Configure output based on options
output.configure({
verbose: options.verbose || false
});
// Merge with loaded config
const resolvedConfig = {
...config
};
/**
* Initialize SDK with config loading
*/
const init = async () => {
const fileConfig = await loadConfig();
Object.assign(resolvedConfig, fileConfig, config); // CLI config takes precedence
return resolvedConfig;
};
/**
* Create uploader service
*/
const createUploaderService = (uploaderOptions = {}) => {
return createUploader({
apiKey: resolvedConfig.apiKey,
apiUrl: resolvedConfig.apiUrl
}, {
...options,
...uploaderOptions
});
};
/**
* Create TDD service
*/
const createTDDServiceInstance = (tddOptions = {}) => {
return createTDDService(resolvedConfig, {
...options,
...tddOptions
});
};
/**
* Upload screenshots (convenience method)
*/
const upload = async uploadOptions => {
const uploader = createUploaderService();
return uploader.upload(uploadOptions);
};
/**
* Start TDD mode (convenience method)
*/
const startTDD = async (tddOptions = {}) => {
const tddService = createTDDServiceInstance();
return tddService.start(tddOptions);
};
return {
// Core methods
init,
upload,
startTDD,
// Service factories
createUploader: createUploaderService,
createTDDService: createTDDServiceInstance,
// Utilities
loadConfig: () => loadConfig(),
// Config access
getConfig: () => ({
...resolvedConfig
}),
updateConfig: newConfig => Object.assign(resolvedConfig, newConfig)
};
}
/**
* @typedef {Object} VizzlySDK
* @property {Function} start - Start the Vizzly server
* @property {Function} stop - Stop the Vizzly server
* @property {Function} screenshot - Capture a screenshot
* @property {Function} upload - Upload screenshots to Vizzly
* @property {Function} compare - Run local comparison (TDD mode)
* @property {Function} getConfig - Get current configuration
* @property {Function} on - Subscribe to events
* @property {Function} off - Unsubscribe from events
*/
/**
* VizzlySDK class implementation
* @class
* @extends {EventEmitter}
*/
export class VizzlySDK extends EventEmitter {
/**
* @param {Object} config - Configuration
* @param {Object} services - Service instances
*/
constructor(config, services) {
super();
this.config = config;
this.services = services;
this.server = null;
this.currentBuildId = null;
}
/**
* Stop the Vizzly server
* @returns {Promise<void>}
*/
async stop() {
if (this.server) {
await this.server.stop();
this.server = null;
this.emit('server:stopped');
output.debug('Vizzly server stopped');
}
}
/**
* Get current configuration
* @returns {Object} Current config
*/
getConfig() {
return {
...this.config
};
}
/**
* Start the Vizzly server
* @returns {Promise<{port: number, url: string}>} Server information
*/
async start() {
if (this.server) {
output.warn('Server already running');
return {
port: this.config.server?.port || 3000,
url: `http://localhost:${this.config.server?.port || 3000}`
};
}
// Create a simple build manager for screenshot collection
const buildManager = {
screenshots: new Map(),
currentBuildId: null,
async addScreenshot(buildId, screenshot) {
if (!this.screenshots.has(buildId)) {
this.screenshots.set(buildId, []);
}
this.screenshots.get(buildId).push(screenshot);
},
getScreenshots(buildId) {
return this.screenshots.get(buildId) || [];
}
};
this.server = new ScreenshotServer(this.config, buildManager);
await this.server.start();
const port = this.config.server?.port || 3000;
const serverInfo = {
port,
url: `http://localhost:${port}`
};
this.emit('server:started', serverInfo);
return serverInfo;
}
/**
* Capture a screenshot
* @param {string} name - Screenshot name
* @param {Buffer|string} imageBuffer - Image data as a Buffer, or a file path to an image
* @param {Object} [options] - Options
* @returns {Promise<void>}
* @throws {VizzlyError} When server is not running
* @throws {VizzlyError} When file path is provided but file doesn't exist
* @throws {VizzlyError} When file cannot be read due to permissions or I/O errors
*/
async screenshot(name, imageBuffer, options = {}) {
if (!this.server || !this.server.isRunning()) {
throw new VizzlyError('Server not running. Call start() first.', 'SERVER_NOT_RUNNING');
}
// Resolve Buffer or file path using shared utility
const buffer = resolveImageBuffer(imageBuffer, 'screenshot');
// Generate or use provided build ID
const buildId = options.buildId || this.currentBuildId || 'default';
this.currentBuildId = buildId;
// Convert Buffer to base64 for JSON transport
const imageBase64 = buffer.toString('base64');
const screenshotData = {
buildId,
name,
image: imageBase64,
properties: options.properties || {}
};
// POST to the local screenshot server
const serverUrl = `http://localhost:${this.config.server?.port || 3000}`;
try {
const response = await fetch(`${serverUrl}/screenshot`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(screenshotData)
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({
error: 'Unknown error'
}));
throw new VizzlyError(`Screenshot capture failed: ${errorData.error}`, 'SCREENSHOT_FAILED', {
name,
buildId,
status: response.status
});
}
this.emit('screenshot:captured', {
name,
buildId,
options
});
output.debug(`Screenshot captured: ${name}`);
} catch (error) {
if (error instanceof VizzlyError) throw error;
throw new VizzlyError(`Failed to send screenshot to server: ${error.message}`, 'SCREENSHOT_TRANSPORT_ERROR', {
name,
buildId,
originalError: error.message
});
}
}
/**
* Upload all captured screenshots
* @param {Object} [options] - Upload options
* @returns {Promise<Object>} Upload result
*/
async upload(options = {}) {
if (!this.services?.uploader) {
this.services = this.services || {};
this.services.uploader = createUploader({
apiKey: this.config.apiKey,
apiUrl: this.config.apiUrl,
upload: this.config.upload
});
}
// Get the screenshots directory from config or default
const screenshotsDir = options.screenshotsDir || this.config?.upload?.screenshotsDir || './screenshots';
const uploadOptions = {
screenshotsDir,
buildName: options.buildName || this.config.buildName,
branch: options.branch || this.config.branch,
commit: options.commit || this.config.commit,
message: options.message || this.config.message,
environment: options.environment || this.config.environment || 'production',
threshold: options.threshold || this.config.threshold,
onProgress: progress => {
this.emit('upload:progress', progress);
if (options.onProgress) {
options.onProgress(progress);
}
}
};
try {
const result = await this.services.uploader.upload(uploadOptions);
this.emit('upload:completed', result);
return result;
} catch (error) {
this.emit('upload:failed', error);
throw error;
}
}
/**
* Run local comparison in TDD mode
* @param {string} name - Screenshot name
* @param {Buffer|string} imageBuffer - Current image as a Buffer, or a file path to an image
* @returns {Promise<Object>} Comparison result
* @throws {VizzlyError} When file path is provided but file doesn't exist
* @throws {VizzlyError} When file cannot be read due to permissions or I/O errors
*/
async compare(name, imageBuffer) {
if (!this.services?.tddService) {
this.services = this.services || {};
this.services.tddService = createTDDService(this.config);
}
// Resolve Buffer or file path using shared utility
const buffer = resolveImageBuffer(imageBuffer, 'compare');
try {
const result = await this.services.tddService.compareScreenshot(name, buffer);
this.emit('comparison:completed', result);
return result;
} catch (error) {
this.emit('comparison:failed', {
name,
error
});
throw error;
}
}
}
// Export service creators for advanced usage
export { createUploader } from '../services/uploader.js';
export { createTDDService } from '../tdd/tdd-service.js';
// Re-export key utilities and errors
export { loadConfig } from '../utils/config-loader.js';
export * as output from '../utils/output.js';