@vizzly-testing/cli
Version:
Visual review platform for UI developers and designers
400 lines (340 loc) • 12.1 kB
JavaScript
/**
* Provider for Vizzly Cloud API integration
*/
export class CloudAPIProvider {
constructor() {
this.defaultApiUrl = process.env.VIZZLY_API_URL || 'https://app.vizzly.dev';
}
/**
* Make API request to Vizzly
*/
async makeRequest(path, apiToken, apiUrl = this.defaultApiUrl) {
if (!apiToken) {
throw new Error(
'API token required. Set VIZZLY_TOKEN environment variable or provide via apiToken parameter.'
);
}
let url = `${apiUrl}${path}`;
let response = await fetch(url, {
headers: {
Authorization: `Bearer ${apiToken}`,
'User-Agent': 'Vizzly-Claude-Plugin/0.1.0'
}
});
if (!response.ok) {
let error = await response.text();
throw new Error(`API request failed (${response.status}): ${error}`);
}
return response.json();
}
/**
* Get build status and details
*/
async getBuildStatus(buildId, apiToken, apiUrl) {
if (!buildId || typeof buildId !== 'string') {
throw new Error('buildId is required and must be a non-empty string');
}
let data = await this.makeRequest(
`/api/sdk/builds/${buildId}?include=comparisons`,
apiToken,
apiUrl
);
let { build } = data;
// Calculate comparison summary
// Note: API returns 'result' field (not 'status'), and doesn't have 'has_diff'
// result can be: 'identical', 'changed', 'new', 'removed', 'error', 'missing', 'returning'
let comparisons = build.comparisons || [];
// Calculate basic comparison summary
let changedComparisons = comparisons.filter((c) => c.result === 'changed' || (c.diff_percentage && c.diff_percentage > 0));
let summary = {
total: comparisons.length,
new: comparisons.filter((c) => c.result === 'new').length,
changed: changedComparisons.length,
identical: comparisons.filter((c) => c.result === 'identical' || (c.result !== 'new' && c.result !== 'changed' && (!c.diff_percentage || c.diff_percentage === 0))).length,
// Approval status breakdown
approval: {
pending: comparisons.filter((c) => c.approval_status === 'pending').length,
approved: comparisons.filter((c) => c.approval_status === 'approved').length,
rejected: comparisons.filter((c) => c.approval_status === 'rejected').length,
autoApproved: comparisons.filter((c) => c.approval_status === 'auto_approved').length
},
// Flaky screenshot count
flaky: comparisons.filter((c) => c.is_flaky).length
};
// Keep minimal for token efficiency - use read_comparison_details for full metadata
let failedComparisons = comparisons
.filter((c) => c.result === 'changed' || (c.diff_percentage && c.diff_percentage > 0))
.map((c) => ({
id: c.id,
name: c.current_name || c.name,
diffPercentage: c.diff_percentage,
approvalStatus: c.approval_status,
// Include hot spot coverage % for quick triage (single number)
hotSpotCoverage: c.analysis_metadata?.hot_spot_coverage || null
}));
let newComparisons = comparisons
.filter((c) => c.result === 'new')
.map((c) => ({
name: c.name,
currentUrl: c.current_screenshot?.original_url
}));
return {
build: {
id: build.id,
name: build.name,
branch: build.branch,
status: build.status,
url: build.url,
organizationSlug: build.organizationSlug,
projectSlug: build.projectSlug,
createdAt: build.created_at,
// Include commit details for debugging
commitSha: build.commit_sha,
commitMessage: build.commit_message,
commonAncestorSha: build.common_ancestor_sha
},
summary,
failedComparisons,
newComparisons
};
}
/**
* List recent builds
*/
async listRecentBuilds(apiToken, options = {}) {
let { limit = 10, branch, apiUrl } = options;
let queryParams = new URLSearchParams({
limit: limit.toString()
});
if (branch) {
queryParams.append('branch', branch);
}
let data = await this.makeRequest(`/api/sdk/builds?${queryParams}`, apiToken, apiUrl);
return {
builds: data.builds.map((b) => ({
id: b.id,
name: b.name,
branch: b.branch,
status: b.status,
environment: b.environment,
createdAt: b.created_at
})),
pagination: data.pagination
};
}
/**
* Get token context (organization and project info)
*/
async getTokenContext(apiToken, apiUrl) {
return await this.makeRequest('/api/sdk/token/context', apiToken, apiUrl);
}
/**
* Get comparison details
*/
async getComparison(comparisonId, apiToken, apiUrl) {
if (!comparisonId || typeof comparisonId !== 'string') {
throw new Error('comparisonId is required and must be a non-empty string');
}
let data = await this.makeRequest(`/api/sdk/comparisons/${comparisonId}`, apiToken, apiUrl);
return data.comparison;
}
/**
* Search for comparisons by name across builds
*/
async searchComparisons(name, apiToken, options = {}) {
if (!name || typeof name !== 'string') {
throw new Error('name is required and must be a non-empty string');
}
let { branch, limit = 50, offset = 0, apiUrl } = options;
let queryParams = new URLSearchParams({
name,
limit: limit.toString(),
offset: offset.toString()
});
if (branch) {
queryParams.append('branch', branch);
}
let data = await this.makeRequest(
`/api/sdk/comparisons/search?${queryParams}`,
apiToken,
apiUrl
);
return data;
}
// ==================================================================
// BUILD COMMENTS
// ==================================================================
/**
* Create a comment on a build
*/
async createBuildComment(buildId, content, type, apiToken, apiUrl) {
if (!buildId || typeof buildId !== 'string') {
throw new Error('buildId is required and must be a non-empty string');
}
if (!content || typeof content !== 'string') {
throw new Error('content is required and must be a non-empty string');
}
let url = `${apiUrl || this.defaultApiUrl}/api/sdk/builds/${buildId}/comments`;
let response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiToken}`,
'User-Agent': 'Vizzly-Claude-Plugin/0.1.0',
'Content-Type': 'application/json'
},
body: JSON.stringify({ content, type })
});
if (!response.ok) {
let error = await response.text();
throw new Error(`Failed to create comment (${response.status}): ${error}`);
}
return response.json();
}
/**
* List comments for a build
*/
async listBuildComments(buildId, apiToken, apiUrl) {
if (!buildId || typeof buildId !== 'string') {
throw new Error('buildId is required and must be a non-empty string');
}
let data = await this.makeRequest(`/api/sdk/builds/${buildId}/comments`, apiToken, apiUrl);
// Filter out unnecessary fields from comments for MCP
let filterComment = (comment) => {
// eslint-disable-next-line no-unused-vars
let { profile_photo_url, email, ...filtered } = comment;
// Recursively filter replies if they exist
if (filtered.replies && Array.isArray(filtered.replies)) {
filtered.replies = filtered.replies.map(filterComment);
}
return filtered;
};
return {
...data,
comments: data.comments ? data.comments.map(filterComment) : []
};
}
// ==================================================================
// COMPARISON APPROVALS
// ==================================================================
/**
* Approve a comparison
*/
async approveComparison(comparisonId, comment, apiToken, apiUrl) {
if (!comparisonId || typeof comparisonId !== 'string') {
throw new Error('comparisonId is required and must be a non-empty string');
}
let url = `${apiUrl || this.defaultApiUrl}/api/sdk/comparisons/${comparisonId}/approve`;
let response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiToken}`,
'User-Agent': 'Vizzly-Claude-Plugin/0.1.0',
'Content-Type': 'application/json'
},
body: JSON.stringify({ comment })
});
if (!response.ok) {
let error = await response.text();
throw new Error(`Failed to approve comparison (${response.status}): ${error}`);
}
return response.json();
}
/**
* Reject a comparison
*/
async rejectComparison(comparisonId, reason, apiToken, apiUrl) {
if (!comparisonId || typeof comparisonId !== 'string') {
throw new Error('comparisonId is required and must be a non-empty string');
}
if (!reason || typeof reason !== 'string') {
throw new Error('reason is required and must be a non-empty string');
}
let url = `${apiUrl || this.defaultApiUrl}/api/sdk/comparisons/${comparisonId}/reject`;
let response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiToken}`,
'User-Agent': 'Vizzly-Claude-Plugin/0.1.0',
'Content-Type': 'application/json'
},
body: JSON.stringify({ reason })
});
if (!response.ok) {
let error = await response.text();
throw new Error(`Failed to reject comparison (${response.status}): ${error}`);
}
return response.json();
}
/**
* Update comparison approval status
*/
async updateComparisonApproval(comparisonId, approvalStatus, comment, apiToken, apiUrl) {
let url = `${apiUrl || this.defaultApiUrl}/api/sdk/comparisons/${comparisonId}/approval`;
let response = await fetch(url, {
method: 'PUT',
headers: {
Authorization: `Bearer ${apiToken}`,
'User-Agent': 'Vizzly-Claude-Plugin/0.1.0',
'Content-Type': 'application/json'
},
body: JSON.stringify({ approval_status: approvalStatus, comment })
});
if (!response.ok) {
let error = await response.text();
throw new Error(`Failed to update comparison approval (${response.status}): ${error}`);
}
return response.json();
}
// ==================================================================
// REVIEW STATUS
// ==================================================================
/**
* Get review summary for a build
*/
async getReviewSummary(buildId, apiToken, apiUrl) {
if (!buildId || typeof buildId !== 'string') {
throw new Error('buildId is required and must be a non-empty string');
}
let data = await this.makeRequest(
`/api/sdk/builds/${buildId}/review-summary`,
apiToken,
apiUrl
);
return data;
}
// ==================================================================
// TDD WORKFLOW
// ==================================================================
/**
* Download baseline screenshots from a cloud build
* Returns screenshot data that can be saved locally
*/
async downloadBaselines(buildId, screenshotNames, apiToken, apiUrl) {
if (!buildId || typeof buildId !== 'string') {
throw new Error('buildId is required and must be a non-empty string');
}
let data = await this.makeRequest(
`/api/sdk/builds/${buildId}?include=screenshots`,
apiToken,
apiUrl
);
let { build } = data;
let screenshots = build.screenshots || [];
// Filter by screenshot names if provided
if (screenshotNames && screenshotNames.length > 0) {
screenshots = screenshots.filter((s) => screenshotNames.includes(s.name));
}
return {
buildId: build.id,
buildName: build.name,
screenshots: screenshots.map((s) => ({
name: s.name,
url: s.original_url,
sha256: s.sha256,
width: s.viewport_width,
height: s.viewport_height,
browser: s.browser
}))
};
}
}