@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.
Files changed (36) hide show
  1. package/README.md +3 -4
  2. package/dist/api/core.js +12 -5
  3. package/dist/api/endpoints.js +5 -50
  4. package/dist/api/index.js +1 -1
  5. package/dist/cli.js +4 -4
  6. package/dist/client/index.js +2 -1
  7. package/dist/commands/api.js +3 -3
  8. package/dist/commands/builds.js +12 -15
  9. package/dist/commands/comparisons.js +7 -9
  10. package/dist/commands/context.js +3 -24
  11. package/dist/commands/review.js +122 -35
  12. package/dist/commands/run.js +1 -3
  13. package/dist/commands/status.js +35 -79
  14. package/dist/commands/upload.js +1 -1
  15. package/dist/context/local-workspace-provider.js +36 -126
  16. package/dist/reporter/reporter-bundle.iife.js +49 -81
  17. package/dist/screenshot-server/core.js +5 -1
  18. package/dist/sdk/index.js +2 -1
  19. package/dist/server/handlers/api-handler.js +18 -2
  20. package/dist/server/handlers/tdd-handler.js +12 -11
  21. package/dist/server/routers/dashboard.js +5 -1
  22. package/dist/server/routers/screenshot.js +25 -2
  23. package/dist/tdd/runtime-context.js +0 -2
  24. package/dist/tdd/services/comparison-service.js +4 -68
  25. package/dist/tdd/tdd-service.js +10 -208
  26. package/dist/types/index.d.ts +6 -2
  27. package/dist/uploader/core.js +7 -8
  28. package/dist/utils/screenshot-options.js +61 -22
  29. package/dist/utils/visual-context-normalizers.js +86 -139
  30. package/package.json +1 -1
  31. package/skills/vizzly/SKILL.md +4 -4
  32. package/skills/vizzly/references/dynamic-content.md +2 -2
  33. package/dist/tdd/core/hotspot-coverage.js +0 -112
  34. package/dist/tdd/core/region-coverage.js +0 -203
  35. package/dist/tdd/metadata/hotspot-metadata.js +0 -93
  36. 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. If one of those reserved
157
- SDK options is accidentally placed inside `properties`, Vizzly removes it from
158
- metadata, applies it as an option when possible, and warns so the call site can
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: metadata ?? {}
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: meta
196
+ properties
190
197
  };
191
198
  }
192
199
 
@@ -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>} Canonical build status bundle
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, getBatchHotspots, getBuild, getBuildContext, getBuildStatus, getBuilds, getComparison, getComparisonContext, getPreviewInfo, getReviewQueueContext, getScreenshotContext, getScreenshotHotspots, getSimilarFingerprintContext, getTddBaselines, getTokenContext, searchComparisons, updateBuildStatus, uploadPreviewZip, uploadScreenshot } from './endpoints.js';
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).option('--window-size <n>', 'Historical hotspot analysis window size (1-50)', Number).addHelpText('after', `
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 approve/reject/comment)', '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', `
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/comparisons/abc123/approve -X POST
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, reject, and comment endpoints.
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();
@@ -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
  }
@@ -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\/comparisons\/[^/]+\/approve$/, /^\/api\/sdk\/comparisons\/[^/]+\/reject$/, /^\/api\/sdk\/builds\/[^/]+\/comments$/];
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 approve/reject/comment.`);
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 approve, reject, and comment endpoints support POST.`);
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.');
@@ -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
- // Approval badge
117
- let approvalBadge = '';
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}${approvalBadge}`);
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
- approvalStatus: build.approval_status,
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
- approvalStatus: c.approval_status,
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.approval_status) {
242
- output.labelValue('Approval', build.approval_status);
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 approval badge
291
+ * Get colored review badge
294
292
  */
295
- function getApprovalBadge(colors, approvalStatus) {
296
- switch (approvalStatus) {
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(approvalStatus?.toUpperCase() || '');
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, getVisualReviewState } from '../utils/visual-context-normalizers.js';
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 projection = comparison.analysis_projection || comparison.projection || diffImage.analysis_projection || diffImage.projection || null;
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 ?? projection?.clusters?.count ?? (Array.isArray(diffRegions) ? diffRegions.length : null);
171
- let hasHoneydiff = clusterMetadata || ssimScore != null || gmsdScore != null || fingerprintHash || projection || regionCount != null;
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
- approvalStatus: comparison.approval_status,
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
- projection,
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.reviewState || 'unknown'
215
+ Review: formatted.visual_review?.state || 'unknown'
218
216
  });
219
217
  output.blank();
220
218
  if (formatted.viewport) {
@@ -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 · ${context.history.confirmed_regions.length} confirmed regions`);
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 · ${context.confirmed_regions.length} confirmed regions`);
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 = {}) {
@@ -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
- comment: options.comment
149
+ annotation: options.comment.trim()
148
150
  } : {};
149
151
  }
150
152
  export function createRejectionBody(options = {}) {
151
153
  return {
152
- reason: options.reason
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 runReviewMutation({
282
+ return await runComparisonDecision({
171
283
  command: 'approve',
172
- endpoint: `/api/sdk/comparisons/${comparisonId}/approve`,
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 runReviewMutation({
321
+ return await runComparisonDecision({
222
322
  command: 'reject',
223
- endpoint: `/api/sdk/comparisons/${comparisonId}/reject`,
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}"`);
@@ -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
- if (buildResult.approvalStatus !== undefined) {
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
  }