@vizzly-testing/cli 0.35.3-beta.2 → 0.35.3-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -4
- package/dist/api/core.js +12 -5
- package/dist/api/endpoints.js +5 -50
- package/dist/api/index.js +1 -1
- package/dist/cli.js +4 -4
- package/dist/client/index.js +2 -1
- package/dist/commands/api.js +3 -3
- package/dist/commands/builds.js +12 -15
- package/dist/commands/comparisons.js +7 -9
- package/dist/commands/context.js +3 -24
- package/dist/commands/review.js +122 -35
- package/dist/commands/run.js +1 -3
- package/dist/commands/status.js +35 -79
- package/dist/commands/upload.js +1 -1
- package/dist/context/local-workspace-provider.js +36 -126
- package/dist/reporter/reporter-bundle.iife.js +49 -81
- package/dist/screenshot-server/core.js +5 -1
- package/dist/sdk/index.js +2 -1
- package/dist/server/handlers/api-handler.js +18 -2
- package/dist/server/handlers/tdd-handler.js +12 -11
- package/dist/server/routers/dashboard.js +5 -1
- package/dist/server/routers/screenshot.js +25 -2
- package/dist/tdd/runtime-context.js +0 -2
- package/dist/tdd/services/comparison-service.js +4 -68
- package/dist/tdd/tdd-service.js +10 -208
- package/dist/types/index.d.ts +6 -2
- package/dist/uploader/core.js +7 -8
- package/dist/utils/screenshot-options.js +61 -22
- package/dist/utils/visual-context-normalizers.js +86 -139
- package/package.json +1 -1
- package/skills/vizzly/SKILL.md +4 -4
- package/skills/vizzly/references/dynamic-content.md +2 -2
- package/dist/tdd/core/hotspot-coverage.js +0 -112
- package/dist/tdd/core/region-coverage.js +0 -203
- package/dist/tdd/metadata/hotspot-metadata.js +0 -93
- package/dist/tdd/metadata/region-metadata.js +0 -56
package/README.md
CHANGED
|
@@ -153,10 +153,9 @@ test('homepage looks correct', async ({ page }) => {
|
|
|
153
153
|
|
|
154
154
|
`properties` is your metadata bag for baseline grouping, filtering, and
|
|
155
155
|
debugging. SDK options such as `threshold`, `minClusterSize`, `fullPage`,
|
|
156
|
-
`requestTimeout`, and `buildId` stay at the top level.
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
be cleaned up.
|
|
156
|
+
`requestTimeout`, and `buildId` stay at the top level. Reserved SDK and capture
|
|
157
|
+
fields inside `properties` are ignored and produce a warning; they are never
|
|
158
|
+
interpreted as options or stored as user metadata.
|
|
160
159
|
|
|
161
160
|
The client SDK is lightweight. It posts screenshots to the local Vizzly server
|
|
162
161
|
or the cloud build wrapper. It works with any test runner.
|
package/dist/api/core.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import crypto from 'node:crypto';
|
|
9
9
|
import { URLSearchParams } from 'node:url';
|
|
10
|
+
import { createScreenshotProperties, getScreenshotOptionsPayload } from '../utils/screenshot-options.js';
|
|
10
11
|
|
|
11
12
|
// ============================================================================
|
|
12
13
|
// Header Building
|
|
@@ -77,11 +78,14 @@ export function buildRequestHeaders({
|
|
|
77
78
|
* @param {string|null} sha256 - Pre-computed SHA256 hash (optional)
|
|
78
79
|
* @returns {Object} Screenshot upload payload
|
|
79
80
|
*/
|
|
80
|
-
export function buildScreenshotPayload(name, buffer, metadata = {}, sha256 = null) {
|
|
81
|
+
export function buildScreenshotPayload(name, buffer, metadata = {}, sha256 = null, screenshotOptions = {}) {
|
|
81
82
|
let payload = {
|
|
82
83
|
name,
|
|
83
|
-
properties:
|
|
84
|
+
properties: createScreenshotProperties({
|
|
85
|
+
properties: metadata
|
|
86
|
+
})
|
|
84
87
|
};
|
|
88
|
+
Object.assign(payload, getScreenshotOptionsPayload(screenshotOptions));
|
|
85
89
|
if (buffer) {
|
|
86
90
|
payload.image_data = buffer.toString('base64');
|
|
87
91
|
}
|
|
@@ -98,8 +102,8 @@ export function buildScreenshotPayload(name, buffer, metadata = {}, sha256 = nul
|
|
|
98
102
|
* @param {string} sha256 - Pre-computed SHA256 hash
|
|
99
103
|
* @returns {Object} Screenshot resolution payload
|
|
100
104
|
*/
|
|
101
|
-
export function buildScreenshotResolvePayload(name, metadata = {}, sha256) {
|
|
102
|
-
return buildScreenshotPayload(name, null, metadata, sha256);
|
|
105
|
+
export function buildScreenshotResolvePayload(name, metadata = {}, sha256, screenshotOptions = {}) {
|
|
106
|
+
return buildScreenshotPayload(name, null, metadata, sha256, screenshotOptions);
|
|
103
107
|
}
|
|
104
108
|
|
|
105
109
|
/**
|
|
@@ -180,13 +184,16 @@ export function buildShaCheckPayload(screenshots, buildId) {
|
|
|
180
184
|
*/
|
|
181
185
|
export function buildScreenshotCheckObject(sha256, name, metadata = {}) {
|
|
182
186
|
let meta = metadata || {};
|
|
187
|
+
let properties = createScreenshotProperties({
|
|
188
|
+
properties: meta
|
|
189
|
+
});
|
|
183
190
|
return {
|
|
184
191
|
sha256,
|
|
185
192
|
name,
|
|
186
193
|
browser: meta.browser || 'chrome',
|
|
187
194
|
viewport_width: meta.viewport?.width || meta.viewport_width || 1920,
|
|
188
195
|
viewport_height: meta.viewport?.height || meta.viewport_height || 1080,
|
|
189
|
-
properties
|
|
196
|
+
properties
|
|
190
197
|
};
|
|
191
198
|
}
|
|
192
199
|
|
package/dist/api/endpoints.js
CHANGED
|
@@ -38,7 +38,7 @@ export async function getBuild(client, buildId, include = null) {
|
|
|
38
38
|
*
|
|
39
39
|
* @param {Object} client - API client
|
|
40
40
|
* @param {string} buildId - Build ID
|
|
41
|
-
* @returns {Promise<Object>}
|
|
41
|
+
* @returns {Promise<Object>} Build status response
|
|
42
42
|
*/
|
|
43
43
|
export async function getBuildStatus(client, buildId) {
|
|
44
44
|
return client.request(`/api/sdk/builds/${buildId}/status`);
|
|
@@ -179,10 +179,10 @@ function canUploadBytesAfterShaResolveError(error) {
|
|
|
179
179
|
* @param {boolean} skipDedup - Skip SHA deduplication (uploadAll mode)
|
|
180
180
|
* @returns {Promise<Object>} Upload result
|
|
181
181
|
*/
|
|
182
|
-
export async function uploadScreenshot(client, buildId, name, buffer, metadata = {}, skipDedup = false) {
|
|
182
|
+
export async function uploadScreenshot(client, buildId, name, buffer, metadata = {}, skipDedup = false, screenshotOptions = {}) {
|
|
183
183
|
// Skip SHA deduplication if requested
|
|
184
184
|
if (skipDedup) {
|
|
185
|
-
let payload = buildScreenshotPayload(name, buffer, metadata);
|
|
185
|
+
let payload = buildScreenshotPayload(name, buffer, metadata, null, screenshotOptions);
|
|
186
186
|
return client.request(`/api/sdk/builds/${buildId}/screenshots`, {
|
|
187
187
|
method: 'POST',
|
|
188
188
|
headers: {
|
|
@@ -194,7 +194,7 @@ export async function uploadScreenshot(client, buildId, name, buffer, metadata =
|
|
|
194
194
|
|
|
195
195
|
// Normal flow with server-side SHA resolution.
|
|
196
196
|
let sha256 = computeSha256(buffer);
|
|
197
|
-
let resolvePayload = buildScreenshotResolvePayload(name, metadata, sha256);
|
|
197
|
+
let resolvePayload = buildScreenshotResolvePayload(name, metadata, sha256, screenshotOptions);
|
|
198
198
|
try {
|
|
199
199
|
let resolveResult = await client.request(`/api/sdk/builds/${buildId}/screenshots`, {
|
|
200
200
|
method: 'POST',
|
|
@@ -222,7 +222,7 @@ export async function uploadScreenshot(client, buildId, name, buffer, metadata =
|
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
// File doesn't exist, or resolve failed; proceed with upload.
|
|
225
|
-
let payload = buildScreenshotPayload(name, buffer, metadata, sha256);
|
|
225
|
+
let payload = buildScreenshotPayload(name, buffer, metadata, sha256, screenshotOptions);
|
|
226
226
|
return client.request(`/api/sdk/builds/${buildId}/screenshots`, {
|
|
227
227
|
method: 'POST',
|
|
228
228
|
headers: {
|
|
@@ -343,51 +343,6 @@ export async function searchComparisons(client, name, filters = {}) {
|
|
|
343
343
|
return client.request(endpoint);
|
|
344
344
|
}
|
|
345
345
|
|
|
346
|
-
// ============================================================================
|
|
347
|
-
// Hotspot Endpoints
|
|
348
|
-
// ============================================================================
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* Get hotspot analysis for a single screenshot
|
|
352
|
-
* @param {Object} client - API client
|
|
353
|
-
* @param {string} screenshotName - Screenshot name
|
|
354
|
-
* @param {Object} options - Optional settings
|
|
355
|
-
* @returns {Promise<Object>} Hotspot analysis data
|
|
356
|
-
*/
|
|
357
|
-
export async function getScreenshotHotspots(client, screenshotName, options = {}) {
|
|
358
|
-
let {
|
|
359
|
-
windowSize = 20
|
|
360
|
-
} = options;
|
|
361
|
-
let encodedName = encodeURIComponent(screenshotName);
|
|
362
|
-
let endpoint = buildEndpointWithParams(`/api/sdk/screenshots/${encodedName}/hotspots`, {
|
|
363
|
-
windowSize: String(windowSize)
|
|
364
|
-
});
|
|
365
|
-
return client.request(endpoint);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/**
|
|
369
|
-
* Batch get hotspot analysis for multiple screenshots
|
|
370
|
-
* @param {Object} client - API client
|
|
371
|
-
* @param {string[]} screenshotNames - Array of screenshot names
|
|
372
|
-
* @param {Object} options - Optional settings
|
|
373
|
-
* @returns {Promise<Object>} Hotspots keyed by screenshot name
|
|
374
|
-
*/
|
|
375
|
-
export async function getBatchHotspots(client, screenshotNames, options = {}) {
|
|
376
|
-
let {
|
|
377
|
-
windowSize = 20
|
|
378
|
-
} = options;
|
|
379
|
-
return client.request('/api/sdk/screenshots/hotspots', {
|
|
380
|
-
method: 'POST',
|
|
381
|
-
headers: {
|
|
382
|
-
'Content-Type': 'application/json'
|
|
383
|
-
},
|
|
384
|
-
body: JSON.stringify({
|
|
385
|
-
screenshot_names: screenshotNames,
|
|
386
|
-
windowSize
|
|
387
|
-
})
|
|
388
|
-
});
|
|
389
|
-
}
|
|
390
|
-
|
|
391
346
|
// ============================================================================
|
|
392
347
|
// Auth/Token Endpoints
|
|
393
348
|
// ============================================================================
|
package/dist/api/index.js
CHANGED
|
@@ -16,4 +16,4 @@ export { createApiClient, DEFAULT_API_URL } from './client.js';
|
|
|
16
16
|
export { buildApiUrl, buildAuthHeader, buildBuildPayload, buildEndpointWithParams, buildQueryParams, buildRequestHeaders, buildScreenshotCheckObject, buildScreenshotPayload, buildScreenshotResolvePayload, buildShaCheckPayload, buildUserAgent, computeSha256, extractErrorBody, findScreenshotBySha, isAuthError, isRateLimited, parseApiError, partitionByShaExistence, shaExists, shouldRetryWithRefresh } from './core.js';
|
|
17
17
|
|
|
18
18
|
// Endpoint functions
|
|
19
|
-
export { checkShas, createBuild, finalizeBuild, finalizeParallelBuild,
|
|
19
|
+
export { checkShas, createBuild, finalizeBuild, finalizeParallelBuild, getBuild, getBuildContext, getBuildStatus, getBuilds, getComparison, getComparisonContext, getPreviewInfo, getReviewQueueContext, getScreenshotContext, getSimilarFingerprintContext, getTddBaselines, getTokenContext, searchComparisons, updateBuildStatus, uploadPreviewZip, uploadScreenshot } from './endpoints.js';
|
package/dist/cli.js
CHANGED
|
@@ -681,7 +681,7 @@ Examples:
|
|
|
681
681
|
}
|
|
682
682
|
await contextComparisonCommand(comparisonId, options, globalOptions);
|
|
683
683
|
});
|
|
684
|
-
contextCmd.command('screenshot').description('Fetch screenshot context and historical memory').argument('<name>', 'Screenshot name').option('--source <source>', 'Context source: auto, cloud, or local', 'auto').option('-p, --project <slug-or-id>', 'Project scope for user auth lookups').option('--org <slug>', 'Organization slug when project slug is ambiguous').option('--recent-limit <n>', 'Maximum recent comparisons to return (1-50)', Number).
|
|
684
|
+
contextCmd.command('screenshot').description('Fetch screenshot context and historical memory').argument('<name>', 'Screenshot name').option('--source <source>', 'Context source: auto, cloud, or local', 'auto').option('-p, --project <slug-or-id>', 'Project scope for user auth lookups').option('--org <slug>', 'Organization slug when project slug is ambiguous').option('--recent-limit <n>', 'Maximum recent comparisons to return (1-50)', Number).addHelpText('after', `
|
|
685
685
|
Examples:
|
|
686
686
|
$ vizzly context screenshot Dashboard --source cloud
|
|
687
687
|
$ vizzly context screenshot Dashboard --source local
|
|
@@ -738,15 +738,15 @@ Note: Baselines are stored locally in .vizzly/baselines/ during TDD mode.
|
|
|
738
738
|
let globalOptions = getGlobalOptions();
|
|
739
739
|
await baselinesCommand(options, globalOptions);
|
|
740
740
|
});
|
|
741
|
-
program.command('api').description('Make raw API requests (for power users)').argument('<endpoint>', 'API endpoint (e.g., /api/sdk/builds)').option('-X, --method <method>', 'HTTP method (GET or POST for
|
|
741
|
+
program.command('api').description('Make raw API requests (for power users)').argument('<endpoint>', 'API endpoint (e.g., /api/sdk/builds)').option('-X, --method <method>', 'HTTP method (GET or POST for build comments)', 'GET').option('-d, --data <json>', 'Request body (JSON)').option('-H, --header <header>', 'Add header (key:value), can be repeated', (val, prev) => prev ? [...prev, val] : [val]).option('-q, --query <param>', 'Add query param (key=value), can be repeated', (val, prev) => prev ? [...prev, val] : [val]).addHelpText('after', `
|
|
742
742
|
Examples:
|
|
743
743
|
$ vizzly api /api/sdk/builds # List builds
|
|
744
744
|
$ vizzly api /api/sdk/builds -q limit=5 # With query params
|
|
745
745
|
$ vizzly api /api/sdk/builds/abc123 # Get specific build
|
|
746
|
-
$ vizzly api /api/sdk/
|
|
746
|
+
$ vizzly api /api/sdk/builds/abc123/comments -X POST -d '{"content":"Looks good"}'
|
|
747
747
|
$ vizzly api /api/sdk/builds/abc123/comments -X POST -d '{"content":"Nice!"}'
|
|
748
748
|
|
|
749
|
-
Note: POST is restricted to approve
|
|
749
|
+
Note: POST is restricted to build comment endpoints. Use dedicated approve/reject commands for review decisions.
|
|
750
750
|
Most operations have dedicated commands (builds, comparisons, approve, etc.).
|
|
751
751
|
`).action(async (endpoint, options) => {
|
|
752
752
|
let globalOptions = getGlobalOptions();
|
package/dist/client/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import { request as httpRequest } from 'node:http';
|
|
|
8
8
|
import { request as httpsRequest } from 'node:https';
|
|
9
9
|
import { dirname, join, parse } from 'node:path';
|
|
10
10
|
import { getBuildId, getServerUrl, isTddMode, setVizzlyEnabled } from '../utils/environment-config.js';
|
|
11
|
-
import { normalizeScreenshotOptions } from '../utils/screenshot-options.js';
|
|
11
|
+
import { getScreenshotOptionsPayload, normalizeScreenshotOptions } from '../utils/screenshot-options.js';
|
|
12
12
|
|
|
13
13
|
// Internal client state
|
|
14
14
|
let currentClient = null;
|
|
@@ -219,6 +219,7 @@ function createSimpleClient(serverUrl, clientOptions = {}) {
|
|
|
219
219
|
type,
|
|
220
220
|
properties: normalizedOptions.properties
|
|
221
221
|
};
|
|
222
|
+
Object.assign(screenshotData, getScreenshotOptionsPayload(normalizedOptions));
|
|
222
223
|
if (normalizedOptions.warnings.length > 0) {
|
|
223
224
|
screenshotData.warnings = normalizedOptions.warnings;
|
|
224
225
|
}
|
package/dist/commands/api.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { createApiClient as defaultCreateApiClient } from '../api/index.js';
|
|
6
6
|
import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
|
|
7
7
|
import * as defaultOutput from '../utils/output.js';
|
|
8
|
-
let ALLOWED_POST_ENDPOINTS = [/^\/api\/sdk\/
|
|
8
|
+
let ALLOWED_POST_ENDPOINTS = [/^\/api\/sdk\/builds\/[^/]+\/comments$/];
|
|
9
9
|
function createApiCommandDeps(deps = {}) {
|
|
10
10
|
return {
|
|
11
11
|
loadConfig: deps.loadConfig || defaultLoadConfig,
|
|
@@ -70,11 +70,11 @@ export function validateApiRequest({
|
|
|
70
70
|
}) {
|
|
71
71
|
let errors = [];
|
|
72
72
|
if (method !== 'GET' && method !== 'POST') {
|
|
73
|
-
errors.push(`Method ${method} not allowed. Use GET for queries or POST for
|
|
73
|
+
errors.push(`Method ${method} not allowed. Use GET for queries or POST for build comments.`);
|
|
74
74
|
return errors;
|
|
75
75
|
}
|
|
76
76
|
if (method === 'POST' && !isAllowedPostEndpoint(endpoint)) {
|
|
77
|
-
errors.push(`POST not allowed for ${endpoint}. Only
|
|
77
|
+
errors.push(`POST not allowed for ${endpoint}. Only build comment endpoints support POST.`);
|
|
78
78
|
}
|
|
79
79
|
if (hasData && method !== 'POST') {
|
|
80
80
|
errors.push('Request data requires --method POST.');
|
package/dist/commands/builds.js
CHANGED
|
@@ -112,13 +112,11 @@ export async function buildsCommand(options = {}, globalOptions = {}, deps = {})
|
|
|
112
112
|
for (let build of builds) {
|
|
113
113
|
let statusColor = getStatusColor(colors, build.status);
|
|
114
114
|
let statusBadge = statusColor(build.status.toUpperCase());
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (build.approval_status && build.status === 'completed') {
|
|
119
|
-
approvalBadge = ` ${getApprovalBadge(colors, build.approval_status)}`;
|
|
115
|
+
let reviewBadge = '';
|
|
116
|
+
if (build.visual_review?.state && build.status === 'completed') {
|
|
117
|
+
reviewBadge = ` ${getReviewBadge(colors, build.visual_review.state)}`;
|
|
120
118
|
}
|
|
121
|
-
output.print(` ${colors.bold(build.name || build.id)} ${statusBadge}${
|
|
119
|
+
output.print(` ${colors.bold(build.name || build.id)} ${statusBadge}${reviewBadge}`);
|
|
122
120
|
let details = [];
|
|
123
121
|
if (build.branch) details.push(build.branch);
|
|
124
122
|
if (build.commit_sha) details.push(build.commit_sha.substring(0, 7));
|
|
@@ -174,7 +172,7 @@ function formatBuildForJson(build, includeComparisons = false) {
|
|
|
174
172
|
changed: build.changed_comparisons || 0,
|
|
175
173
|
identical: build.identical_comparisons || 0
|
|
176
174
|
},
|
|
177
|
-
|
|
175
|
+
visual_review: build.visual_review || null,
|
|
178
176
|
createdAt: build.created_at,
|
|
179
177
|
completedAt: build.completed_at
|
|
180
178
|
};
|
|
@@ -192,7 +190,7 @@ function formatBuildForJson(build, includeComparisons = false) {
|
|
|
192
190
|
name: c.name || c.current_name,
|
|
193
191
|
status: c.status,
|
|
194
192
|
diffPercentage: c.diff_percentage,
|
|
195
|
-
|
|
193
|
+
visual_review: c.visual_review || null,
|
|
196
194
|
urls: {
|
|
197
195
|
baseline: c.baseline_screenshot?.original_url || c.baseline_original_url || c.baseline_screenshot_url || null,
|
|
198
196
|
current: c.current_screenshot?.original_url || c.current_original_url || c.current_screenshot_url || null,
|
|
@@ -238,8 +236,8 @@ function displayBuild(output, build, verbose) {
|
|
|
238
236
|
if (stats.length > 0) {
|
|
239
237
|
output.labelValue('Comparisons', stats.join(colors.dim(' · ')));
|
|
240
238
|
}
|
|
241
|
-
if (build.
|
|
242
|
-
output.labelValue('
|
|
239
|
+
if (build.visual_review?.state) {
|
|
240
|
+
output.labelValue('Review', build.visual_review.state);
|
|
243
241
|
}
|
|
244
242
|
output.blank();
|
|
245
243
|
if (build.created_at) {
|
|
@@ -290,19 +288,18 @@ function getStatusColor(colors, status) {
|
|
|
290
288
|
}
|
|
291
289
|
|
|
292
290
|
/**
|
|
293
|
-
* Get colored
|
|
291
|
+
* Get colored review badge
|
|
294
292
|
*/
|
|
295
|
-
function
|
|
296
|
-
switch (
|
|
293
|
+
function getReviewBadge(colors, reviewState) {
|
|
294
|
+
switch (reviewState) {
|
|
297
295
|
case 'approved':
|
|
298
|
-
case 'auto_approved':
|
|
299
296
|
return colors.brand.success('APPROVED');
|
|
300
297
|
case 'rejected':
|
|
301
298
|
return colors.brand.error('REJECTED');
|
|
302
299
|
case 'pending':
|
|
303
300
|
return colors.brand.warning('PENDING');
|
|
304
301
|
default:
|
|
305
|
-
return colors.dim(
|
|
302
|
+
return colors.dim(reviewState?.toUpperCase() || '');
|
|
306
303
|
}
|
|
307
304
|
}
|
|
308
305
|
|
|
@@ -6,7 +6,7 @@ import { createApiClient as defaultCreateApiClient, getBuild as defaultGetBuild,
|
|
|
6
6
|
import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
|
|
7
7
|
import * as defaultOutput from '../utils/output.js';
|
|
8
8
|
import { createWildcardMatcher } from '../utils/patterns.js';
|
|
9
|
-
import { getComparisonBrowser, getComparisonName, getComparisonResult, getComparisonViewport
|
|
9
|
+
import { getComparisonBrowser, getComparisonName, getComparisonResult, getComparisonViewport } from '../utils/visual-context-normalizers.js';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Comparisons command - query comparisons with various filters
|
|
@@ -165,19 +165,17 @@ function formatComparisonForJson(comparison) {
|
|
|
165
165
|
let ssimScore = comparison.ssim_score ?? diffImage.ssim_score ?? null;
|
|
166
166
|
let gmsdScore = comparison.gmsd_score ?? diffImage.gmsd_score ?? null;
|
|
167
167
|
let fingerprintHash = comparison.fingerprint_hash || diffImage.fingerprint_hash || null;
|
|
168
|
-
let
|
|
168
|
+
let analysisDetails = comparison.analysis_details || diffImage.analysis_details || null;
|
|
169
169
|
let diffRegions = comparison.diff_regions ?? diffImage.diff_regions ?? null;
|
|
170
|
-
let regionCount = comparison.region_count ?? diffImage.region_count ??
|
|
171
|
-
let hasHoneydiff = clusterMetadata || ssimScore != null || gmsdScore != null || fingerprintHash ||
|
|
170
|
+
let regionCount = comparison.region_count ?? diffImage.region_count ?? analysisDetails?.clusters?.count ?? (Array.isArray(diffRegions) ? diffRegions.length : null);
|
|
171
|
+
let hasHoneydiff = clusterMetadata || ssimScore != null || gmsdScore != null || fingerprintHash || analysisDetails || regionCount != null;
|
|
172
172
|
return {
|
|
173
173
|
id: comparison.id,
|
|
174
174
|
name: getComparisonName(comparison),
|
|
175
175
|
status: comparison.status,
|
|
176
176
|
result: getComparisonResult(comparison),
|
|
177
177
|
diffPercentage: comparison.diff_percentage ?? null,
|
|
178
|
-
|
|
179
|
-
reviewState: getVisualReviewState(comparison),
|
|
180
|
-
visualReview: comparison.visual_review || null,
|
|
178
|
+
visual_review: comparison.visual_review || null,
|
|
181
179
|
viewport: getComparisonViewport(comparison),
|
|
182
180
|
browser: getComparisonBrowser(comparison),
|
|
183
181
|
urls: {
|
|
@@ -192,7 +190,7 @@ function formatComparisonForJson(comparison) {
|
|
|
192
190
|
clusterMetadata,
|
|
193
191
|
fingerprintHash,
|
|
194
192
|
regionCount,
|
|
195
|
-
|
|
193
|
+
details: analysisDetails,
|
|
196
194
|
diffRegions,
|
|
197
195
|
diffLines: comparison.diff_lines ?? diffImage.diff_lines ?? null,
|
|
198
196
|
fingerprintData: comparison.fingerprint_data ?? diffImage.fingerprint_data ?? null
|
|
@@ -214,7 +212,7 @@ function displayComparison(output, comparison, verbose) {
|
|
|
214
212
|
Name: formatted.name,
|
|
215
213
|
Status: formatted.result?.toUpperCase(),
|
|
216
214
|
'Diff %': comparison.diff_percentage != null ? `${(comparison.diff_percentage * 100).toFixed(2)}%` : 'N/A',
|
|
217
|
-
Review: formatted.
|
|
215
|
+
Review: formatted.visual_review?.state || 'unknown'
|
|
218
216
|
});
|
|
219
217
|
output.blank();
|
|
220
218
|
if (formatted.viewport) {
|
package/dist/commands/context.js
CHANGED
|
@@ -571,9 +571,6 @@ function formatNeedsReview(status = {}) {
|
|
|
571
571
|
}
|
|
572
572
|
return details.length > 0 ? `yes · ${details.join(' · ')}` : 'yes';
|
|
573
573
|
}
|
|
574
|
-
function formatConfirmedRegionLabels(regions = []) {
|
|
575
|
-
return regions.map(region => region.label).filter(Boolean).slice(0, 3).join(' · ');
|
|
576
|
-
}
|
|
577
574
|
function printComparisonList(output, comparisons = [], {
|
|
578
575
|
limit = 5
|
|
579
576
|
} = {}) {
|
|
@@ -741,19 +738,15 @@ function displayComparisonContext(output, context) {
|
|
|
741
738
|
let statusTone = getStatusTone(colors, displayState);
|
|
742
739
|
let screenshotName = context.comparison.screenshot?.name || context.comparison.id;
|
|
743
740
|
let analysis = context.comparison.analysis || {};
|
|
744
|
-
let confirmedRegionLabels = formatConfirmedRegionLabels(context.history.confirmed_regions);
|
|
745
741
|
output.print(` ${colors.bold(screenshotName)} ${statusTone(displayState.toUpperCase())}`);
|
|
746
742
|
output.print(` ${colors.dim(`@${context.scope.organization.slug}/${context.scope.project.slug}`)}`);
|
|
747
743
|
output.blank();
|
|
748
744
|
output.labelValue('Eyes', `${analysis.diff_image_url ? 'baseline/current/diff' : 'comparison metadata only'}`);
|
|
749
|
-
output.labelValue('Memory', `${context.history.similar_by_fingerprint.length} similar · ${context.history.recent_by_name.length} recent
|
|
745
|
+
output.labelValue('Memory', `${context.history.similar_by_fingerprint.length} similar · ${context.history.recent_by_name.length} recent`);
|
|
750
746
|
output.labelValue('Review', `${context.review.build_comments.length} build comments · ${countScreenshotCommentEntries(context.review.screenshot_comments)} screenshot comments`);
|
|
751
747
|
if (analysis.fingerprint_hash) {
|
|
752
748
|
output.labelValue('Fingerprint', analysis.fingerprint_hash);
|
|
753
749
|
}
|
|
754
|
-
if (confirmedRegionLabels) {
|
|
755
|
-
output.labelValue('Known Regions', confirmedRegionLabels);
|
|
756
|
-
}
|
|
757
750
|
if (context.links?.comparison_url) {
|
|
758
751
|
output.labelValue('Comparison URL', context.links.comparison_url);
|
|
759
752
|
}
|
|
@@ -766,20 +759,10 @@ function displayComparisonContext(output, context) {
|
|
|
766
759
|
function displayScreenshotContext(output, context) {
|
|
767
760
|
output.header('context', 'screenshot');
|
|
768
761
|
let colors = output.getColors();
|
|
769
|
-
let confirmedRegionLabels = formatConfirmedRegionLabels(context.confirmed_regions);
|
|
770
|
-
let hotspotAnalysis = context.hotspot_analysis;
|
|
771
762
|
output.print(` ${colors.bold(context.screenshot.name)}`);
|
|
772
763
|
output.print(` ${colors.dim(`@${context.scope.organization.slug}/${context.scope.project.slug}`)}`);
|
|
773
764
|
output.blank();
|
|
774
|
-
output.labelValue('Memory', `${context.history.recent_comparisons.length} recent comparisons
|
|
775
|
-
if (hotspotAnalysis) {
|
|
776
|
-
output.labelValue('Hotspots', `${hotspotAnalysis.total_builds_analyzed ?? 'unknown'} builds analyzed · ${hotspotAnalysis.confidence ?? 'unknown'}`);
|
|
777
|
-
} else {
|
|
778
|
-
output.labelValue('Hotspots', 'unavailable');
|
|
779
|
-
}
|
|
780
|
-
if (confirmedRegionLabels) {
|
|
781
|
-
output.labelValue('Known Regions', confirmedRegionLabels);
|
|
782
|
-
}
|
|
765
|
+
output.labelValue('Memory', `${context.history.recent_comparisons.length} recent comparisons`);
|
|
783
766
|
if (context.history.recent_comparisons.length > 0) {
|
|
784
767
|
output.blank();
|
|
785
768
|
output.print(' Recent Comparisons');
|
|
@@ -945,8 +928,7 @@ export async function contextScreenshotCommand(screenshotName, options = {}, glo
|
|
|
945
928
|
return;
|
|
946
929
|
}
|
|
947
930
|
let query = buildScopeQuery(options, {
|
|
948
|
-
recentLimit: options.recentLimit
|
|
949
|
-
windowSize: options.windowSize
|
|
931
|
+
recentLimit: options.recentLimit
|
|
950
932
|
});
|
|
951
933
|
output.startSpinner('Fetching screenshot context...');
|
|
952
934
|
let context = await runtime.provider.getScreenshotContext(screenshotName, query);
|
|
@@ -1063,9 +1045,6 @@ export function validateContextScreenshotOptions(options = {}) {
|
|
|
1063
1045
|
errors.push(...validateLimitRange(options.recentLimit, '--recent-limit', {
|
|
1064
1046
|
max: 50
|
|
1065
1047
|
}));
|
|
1066
|
-
errors.push(...validateLimitRange(options.windowSize, '--window-size', {
|
|
1067
|
-
max: 50
|
|
1068
|
-
}));
|
|
1069
1048
|
return errors;
|
|
1070
1049
|
}
|
|
1071
1050
|
export function validateContextSimilarOptions(options = {}) {
|
package/dist/commands/review.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Review commands - approve, reject, and comment on comparisons/builds
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { randomUUID as defaultRandomUUID } from 'node:crypto';
|
|
5
6
|
import { createApiClient as defaultCreateApiClient } from '../api/index.js';
|
|
6
7
|
import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
|
|
7
8
|
import { getAccessToken as defaultGetAccessToken } from '../utils/global-config.js';
|
|
@@ -11,6 +12,7 @@ function createReviewDeps(deps = {}) {
|
|
|
11
12
|
loadConfig: deps.loadConfig || defaultLoadConfig,
|
|
12
13
|
createApiClient: deps.createApiClient || defaultCreateApiClient,
|
|
13
14
|
getAccessToken: deps.getAccessToken || defaultGetAccessToken,
|
|
15
|
+
randomUUID: deps.randomUUID || defaultRandomUUID,
|
|
14
16
|
output: deps.output || defaultOutput,
|
|
15
17
|
exit: deps.exit || (code => process.exit(code))
|
|
16
18
|
};
|
|
@@ -144,12 +146,12 @@ async function runReviewMutation({
|
|
|
144
146
|
}
|
|
145
147
|
export function createApprovalBody(options = {}) {
|
|
146
148
|
return options.comment ? {
|
|
147
|
-
|
|
149
|
+
annotation: options.comment.trim()
|
|
148
150
|
} : {};
|
|
149
151
|
}
|
|
150
152
|
export function createRejectionBody(options = {}) {
|
|
151
153
|
return {
|
|
152
|
-
|
|
154
|
+
annotation: options.reason.trim()
|
|
153
155
|
};
|
|
154
156
|
}
|
|
155
157
|
export function createCommentBody(message, options = {}) {
|
|
@@ -158,6 +160,116 @@ export function createCommentBody(message, options = {}) {
|
|
|
158
160
|
type: options.type || 'general'
|
|
159
161
|
};
|
|
160
162
|
}
|
|
163
|
+
function requireReviewLocator(response) {
|
|
164
|
+
let locator = response?.review_locator;
|
|
165
|
+
if (!locator?.comparison_id || !locator?.build_id || !locator?.organization_slug) {
|
|
166
|
+
let error = new Error('Comparison response did not include review location details');
|
|
167
|
+
error.code = 'INVALID_REVIEW_CONTEXT';
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
return locator;
|
|
171
|
+
}
|
|
172
|
+
async function runComparisonDecision({
|
|
173
|
+
command,
|
|
174
|
+
comparisonId,
|
|
175
|
+
decision,
|
|
176
|
+
annotation,
|
|
177
|
+
failureMessage,
|
|
178
|
+
globalOptions,
|
|
179
|
+
options,
|
|
180
|
+
spinnerMessage,
|
|
181
|
+
writeHumanSuccess,
|
|
182
|
+
deps,
|
|
183
|
+
configure = true
|
|
184
|
+
}) {
|
|
185
|
+
let {
|
|
186
|
+
loadConfig,
|
|
187
|
+
createApiClient,
|
|
188
|
+
getAccessToken,
|
|
189
|
+
randomUUID,
|
|
190
|
+
output,
|
|
191
|
+
exit
|
|
192
|
+
} = createReviewDeps(deps);
|
|
193
|
+
if (configure) {
|
|
194
|
+
configureOutput(output, globalOptions);
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
let config = await loadReviewConfig({
|
|
198
|
+
loadConfig,
|
|
199
|
+
options,
|
|
200
|
+
globalOptions
|
|
201
|
+
});
|
|
202
|
+
let token = await getReviewToken(config, getAccessToken);
|
|
203
|
+
if (!token) {
|
|
204
|
+
output.error('User login required for review actions');
|
|
205
|
+
output.hint('Run "vizzly login" to approve, reject, or comment');
|
|
206
|
+
output.cleanup();
|
|
207
|
+
exit(1);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
output.startSpinner(spinnerMessage);
|
|
211
|
+
let client = createReviewClient({
|
|
212
|
+
createApiClient,
|
|
213
|
+
config,
|
|
214
|
+
command,
|
|
215
|
+
token
|
|
216
|
+
});
|
|
217
|
+
let comparisonResponse = await client.request(`/api/sdk/comparisons/${comparisonId}`);
|
|
218
|
+
let locator = requireReviewLocator(comparisonResponse);
|
|
219
|
+
if (locator.comparison_id !== comparisonId) {
|
|
220
|
+
let error = new Error('Comparison response did not match the requested comparison');
|
|
221
|
+
error.code = 'INVALID_REVIEW_CONTEXT';
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
let organizationHeaders = {
|
|
225
|
+
'X-Organization': locator.organization_slug
|
|
226
|
+
};
|
|
227
|
+
let commandId = randomUUID();
|
|
228
|
+
let response = await client.request(`/api/visual-review/builds/${locator.build_id}/comparisons/${comparisonId}/decision`, {
|
|
229
|
+
method: 'POST',
|
|
230
|
+
headers: {
|
|
231
|
+
...organizationHeaders,
|
|
232
|
+
'Content-Type': 'application/json'
|
|
233
|
+
},
|
|
234
|
+
body: JSON.stringify({
|
|
235
|
+
commandId,
|
|
236
|
+
decision,
|
|
237
|
+
...(annotation ? {
|
|
238
|
+
annotation
|
|
239
|
+
} : {})
|
|
240
|
+
})
|
|
241
|
+
});
|
|
242
|
+
output.stopSpinner();
|
|
243
|
+
if (globalOptions.json) {
|
|
244
|
+
output.data({
|
|
245
|
+
eventId: response.eventId,
|
|
246
|
+
idempotent: response.idempotent,
|
|
247
|
+
review: response.review
|
|
248
|
+
});
|
|
249
|
+
output.cleanup();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
writeHumanSuccess(output);
|
|
253
|
+
output.cleanup();
|
|
254
|
+
} catch (error) {
|
|
255
|
+
output.stopSpinner();
|
|
256
|
+
if (globalOptions.json) {
|
|
257
|
+
output.data({
|
|
258
|
+
comparisonId,
|
|
259
|
+
error: {
|
|
260
|
+
message: error.message,
|
|
261
|
+
code: error.code
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
output.cleanup();
|
|
265
|
+
exit(1);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
output.error(failureMessage, error);
|
|
269
|
+
output.cleanup();
|
|
270
|
+
exit(1);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
161
273
|
|
|
162
274
|
/**
|
|
163
275
|
* Approve a comparison
|
|
@@ -167,27 +279,15 @@ export function createCommentBody(message, options = {}) {
|
|
|
167
279
|
* @param {Object} deps - Dependencies for testing
|
|
168
280
|
*/
|
|
169
281
|
export async function approveCommand(comparisonId, options = {}, globalOptions = {}, deps = {}) {
|
|
170
|
-
return await
|
|
282
|
+
return await runComparisonDecision({
|
|
171
283
|
command: 'approve',
|
|
172
|
-
|
|
284
|
+
comparisonId,
|
|
285
|
+
decision: 'approved',
|
|
286
|
+
annotation: createApprovalBody(options).annotation,
|
|
173
287
|
failureMessage: 'Failed to approve comparison',
|
|
174
288
|
globalOptions,
|
|
175
289
|
options,
|
|
176
|
-
requestBody: createApprovalBody(options),
|
|
177
290
|
spinnerMessage: 'Approving comparison...',
|
|
178
|
-
writeJsonError: (output, error) => output.data({
|
|
179
|
-
approved: false,
|
|
180
|
-
comparisonId,
|
|
181
|
-
error: {
|
|
182
|
-
message: error.message,
|
|
183
|
-
code: error.code
|
|
184
|
-
}
|
|
185
|
-
}),
|
|
186
|
-
writeJsonSuccess: (output, response) => output.data({
|
|
187
|
-
approved: true,
|
|
188
|
-
comparisonId,
|
|
189
|
-
comparison: response.comparison
|
|
190
|
-
}),
|
|
191
291
|
writeHumanSuccess: output => {
|
|
192
292
|
output.complete(`Comparison ${comparisonId} approved`);
|
|
193
293
|
if (options.comment) {
|
|
@@ -218,28 +318,15 @@ export async function rejectCommand(comparisonId, options = {}, globalOptions =
|
|
|
218
318
|
exit(1);
|
|
219
319
|
return;
|
|
220
320
|
}
|
|
221
|
-
return await
|
|
321
|
+
return await runComparisonDecision({
|
|
222
322
|
command: 'reject',
|
|
223
|
-
|
|
323
|
+
comparisonId,
|
|
324
|
+
decision: 'rejected',
|
|
325
|
+
annotation: createRejectionBody(options).annotation,
|
|
224
326
|
failureMessage: 'Failed to reject comparison',
|
|
225
327
|
globalOptions,
|
|
226
328
|
options,
|
|
227
|
-
requestBody: createRejectionBody(options),
|
|
228
329
|
spinnerMessage: 'Rejecting comparison...',
|
|
229
|
-
writeJsonError: (output, error) => output.data({
|
|
230
|
-
rejected: false,
|
|
231
|
-
comparisonId,
|
|
232
|
-
error: {
|
|
233
|
-
message: error.message,
|
|
234
|
-
code: error.code
|
|
235
|
-
}
|
|
236
|
-
}),
|
|
237
|
-
writeJsonSuccess: (output, response) => output.data({
|
|
238
|
-
rejected: true,
|
|
239
|
-
comparisonId,
|
|
240
|
-
reason: options.reason,
|
|
241
|
-
comparison: response.comparison
|
|
242
|
-
}),
|
|
243
330
|
writeHumanSuccess: output => {
|
|
244
331
|
output.complete(`Comparison ${comparisonId} rejected`);
|
|
245
332
|
output.hint(`Reason: "${options.reason}"`);
|
package/dist/commands/run.js
CHANGED
|
@@ -493,9 +493,7 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
|
|
|
493
493
|
if (Object.keys(comparisons).length > 0) {
|
|
494
494
|
jsonResult.comparisons = comparisons;
|
|
495
495
|
}
|
|
496
|
-
|
|
497
|
-
jsonResult.approvalStatus = buildResult.approvalStatus;
|
|
498
|
-
}
|
|
496
|
+
jsonResult.visual_review = buildResult.visual_review;
|
|
499
497
|
if (buildResult.build?.conclusion !== undefined) {
|
|
500
498
|
jsonResult.conclusion = buildResult.build.conclusion;
|
|
501
499
|
}
|