@vizzly-testing/cli
Version:
Visual review platform for UI developers and designers
360 lines (335 loc) • 12.2 kB
JavaScript
/**
* API Service for Vizzly
* Handles HTTP requests to the Vizzly API
*/
import { URLSearchParams } from 'url';
import { VizzlyError, AuthError } from '../errors/vizzly-error.js';
import crypto from 'crypto';
import { getPackageVersion } from '../utils/package-info.js';
import { getApiUrl, getApiToken, getUserAgent } from '../utils/environment-config.js';
import { getAuthTokens, saveAuthTokens } from '../utils/global-config.js';
/**
* ApiService class for direct API communication
*/
export class ApiService {
constructor(options = {}) {
// Accept config as-is, no fallbacks to environment
// Config-loader handles all env/file resolution
this.baseUrl = options.apiUrl || options.baseUrl || getApiUrl();
this.token = options.apiKey || options.token || getApiToken(); // Accept both apiKey and token
this.uploadAll = options.uploadAll || false;
// Build User-Agent string
const command = options.command || 'run';
const baseUserAgent = `vizzly-cli/${getPackageVersion()} (${command})`;
const sdkUserAgent = options.userAgent || getUserAgent();
this.userAgent = sdkUserAgent ? `${baseUserAgent} ${sdkUserAgent}` : baseUserAgent;
if (!this.token && !options.allowNoToken) {
throw new VizzlyError('No API token provided. Set VIZZLY_TOKEN environment variable or run "vizzly login".');
}
}
/**
* Make an API request
* @param {string} endpoint - API endpoint
* @param {Object} options - Fetch options
* @param {boolean} isRetry - Internal flag to prevent infinite retry loops
* @returns {Promise<Object>} Response data
*/
async request(endpoint, options = {}, isRetry = false) {
const url = `${this.baseUrl}${endpoint}`;
const headers = {
'User-Agent': this.userAgent,
...options.headers
};
if (this.token) {
headers.Authorization = `Bearer ${this.token}`;
}
const response = await fetch(url, {
...options,
headers
});
if (!response.ok) {
let errorText = '';
try {
if (typeof response.text === 'function') {
errorText = await response.text();
} else {
errorText = response.statusText || '';
}
} catch {
// ignore
}
// Handle authentication errors with automatic token refresh
if (response.status === 401 && !isRetry) {
// Attempt to refresh token if we have refresh token in global config
let auth = await getAuthTokens();
if (auth && auth.refreshToken) {
try {
// Attempt token refresh
let refreshResponse = await fetch(`${this.baseUrl}/api/auth/cli/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': this.userAgent
},
body: JSON.stringify({
refreshToken: auth.refreshToken
})
});
if (refreshResponse.ok) {
let refreshData = await refreshResponse.json();
// Save new tokens to global config
await saveAuthTokens({
accessToken: refreshData.accessToken,
refreshToken: refreshData.refreshToken,
expiresAt: refreshData.expiresAt,
user: auth.user // Keep existing user data
});
// Update token for this service instance
this.token = refreshData.accessToken;
// Retry the original request with new token
return this.request(endpoint, options, true);
}
} catch {
// Token refresh failed, fall through to auth error
}
}
throw new AuthError('Invalid or expired API token. Please run "vizzly login" to authenticate.');
}
if (response.status === 401) {
throw new AuthError('Invalid or expired API token. Please run "vizzly login" to authenticate.');
}
throw new VizzlyError(`API request failed: ${response.status}${errorText ? ` - ${errorText}` : ''} (URL: ${url})`);
}
return response.json();
}
/**
* Get build information
* @param {string} buildId - Build ID
* @param {string} include - Optional include parameter (e.g., 'screenshots')
* @returns {Promise<Object>} Build data
*/
async getBuild(buildId, include = null) {
const endpoint = include ? `/api/sdk/builds/${buildId}?include=${include}` : `/api/sdk/builds/${buildId}`;
return this.request(endpoint);
}
/**
* Get comparison information
* @param {string} comparisonId - Comparison ID
* @returns {Promise<Object>} Comparison data
*/
async getComparison(comparisonId) {
let response = await this.request(`/api/sdk/comparisons/${comparisonId}`);
return response.comparison;
}
/**
* Search for comparisons by name across builds
* @param {string} name - Screenshot name to search for
* @param {Object} filters - Optional filters (branch, limit, offset)
* @param {string} [filters.branch] - Filter by branch name
* @param {number} [filters.limit=50] - Maximum number of results (default: 50)
* @param {number} [filters.offset=0] - Pagination offset (default: 0)
* @returns {Promise<Object>} Search results with comparisons and pagination
*/
async searchComparisons(name, filters = {}) {
if (!name || typeof name !== 'string') {
throw new VizzlyError('name is required and must be a non-empty string');
}
let {
branch,
limit = 50,
offset = 0
} = filters;
const queryParams = new URLSearchParams({
name,
limit: String(limit),
offset: String(offset)
});
// Only add branch if provided
if (branch) queryParams.append('branch', branch);
return this.request(`/api/sdk/comparisons/search?${queryParams}`);
}
/**
* Get builds for a project
* @param {Object} filters - Filter options
* @returns {Promise<Array>} List of builds
*/
async getBuilds(filters = {}) {
const queryParams = new URLSearchParams(filters).toString();
const endpoint = `/api/sdk/builds${queryParams ? `?${queryParams}` : ''}`;
return this.request(endpoint);
}
/**
* Create a new build
* @param {Object} metadata - Build metadata
* @returns {Promise<Object>} Created build data
*/
async createBuild(metadata) {
return this.request('/api/sdk/builds', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
build: metadata
})
});
}
/**
* Check if SHAs already exist on the server
* @param {string[]|Object[]} shas - Array of SHA256 hashes to check, or array of screenshot objects with metadata
* @param {string} buildId - Build ID for screenshot record creation
* @returns {Promise<Object>} Response with existing SHAs and screenshot data
*/
async checkShas(shas, buildId) {
try {
let requestBody;
// Check if we're using the new signature-based format (array of objects) or legacy format (array of strings)
if (Array.isArray(shas) && shas.length > 0 && typeof shas[0] === 'object' && shas[0].sha256) {
// New signature-based format
requestBody = {
buildId,
screenshots: shas
};
} else {
// Legacy SHA-only format
requestBody = {
shas,
buildId
};
}
const response = await this.request('/api/sdk/check-shas', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
});
return response;
} catch (error) {
// Continue without deduplication on error
console.debug('SHA check failed, continuing without deduplication:', error.message);
// Extract SHAs for fallback response regardless of format
const shaList = Array.isArray(shas) && shas.length > 0 && typeof shas[0] === 'object' ? shas.map(s => s.sha256) : shas;
return {
existing: [],
missing: shaList,
screenshots: []
};
}
}
/**
* Upload a screenshot with SHA checking
* @param {string} buildId - Build ID
* @param {string} name - Screenshot name
* @param {Buffer} buffer - Screenshot data
* @param {Object} metadata - Additional metadata
* @returns {Promise<Object>} Upload result
*/
async uploadScreenshot(buildId, name, buffer, metadata = {}) {
// Skip SHA deduplication entirely if uploadAll flag is set
if (this.uploadAll) {
// Upload directly without SHA calculation or checking
return this.request(`/api/sdk/builds/${buildId}/screenshots`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name,
image_data: buffer.toString('base64'),
properties: metadata ?? {}
// No SHA included when bypassing deduplication
})
});
}
// Normal flow with SHA deduplication using signature-based format
const sha256 = crypto.createHash('sha256').update(buffer).digest('hex');
// Create screenshot object with signature data for checking
const screenshotCheck = [{
sha256,
name,
browser: metadata?.browser || 'chrome',
viewport_width: metadata?.viewport?.width || 1920,
viewport_height: metadata?.viewport?.height || 1080
}];
// Check if this SHA with signature already exists
const checkResult = await this.checkShas(screenshotCheck, buildId);
if (checkResult.existing && checkResult.existing.includes(sha256)) {
// File already exists with same signature, screenshot record was automatically created
const screenshot = checkResult.screenshots?.find(s => s.sha256 === sha256);
return {
message: 'Screenshot already exists, skipped upload',
sha256,
skipped: true,
screenshot,
fromExisting: true
};
}
// File doesn't exist or has different signature, proceed with upload
return this.request(`/api/sdk/builds/${buildId}/screenshots`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name,
image_data: buffer.toString('base64'),
properties: metadata ?? {},
sha256 // Include SHA for server-side deduplication
})
});
}
/**
* Update build status
* @param {string} buildId - Build ID
* @param {string} status - Build status (pending|running|completed|failed)
* @param {number} executionTimeMs - Execution time in milliseconds
* @returns {Promise<Object>} Updated build data
*/
async updateBuildStatus(buildId, status, executionTimeMs = null) {
const body = {
status
};
if (executionTimeMs !== null) {
body.executionTimeMs = executionTimeMs;
}
return this.request(`/api/sdk/builds/${buildId}/status`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
}
/**
* Finalize a build (convenience method)
* @param {string} buildId - Build ID
* @param {boolean} success - Whether the build succeeded
* @param {number} executionTimeMs - Execution time in milliseconds
* @returns {Promise<Object>} Finalized build data
*/
async finalizeBuild(buildId, success = true, executionTimeMs = null) {
const status = success ? 'completed' : 'failed';
return this.updateBuildStatus(buildId, status, executionTimeMs);
}
/**
* Get token context (organization and project info)
* @returns {Promise<Object>} Token context data
*/
async getTokenContext() {
return this.request('/api/sdk/token/context');
}
/**
* Finalize a parallel build
* @param {string} parallelId - Parallel ID to finalize
* @returns {Promise<Object>} Finalization result
*/
async finalizeParallelBuild(parallelId) {
return this.request(`/api/sdk/parallel/${parallelId}/finalize`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
}
}