@vizzly-testing/cli 0.35.3-beta.1 → 0.35.3-beta.3

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 (34) hide show
  1. package/README.md +8 -3
  2. package/dist/api/endpoints.js +1 -46
  3. package/dist/api/index.js +1 -1
  4. package/dist/cli.js +5 -5
  5. package/dist/commands/api.js +3 -3
  6. package/dist/commands/builds.js +12 -15
  7. package/dist/commands/comparisons.js +7 -9
  8. package/dist/commands/context.js +10 -20
  9. package/dist/commands/init.js +62 -13
  10. package/dist/commands/review.js +122 -35
  11. package/dist/commands/run.js +1 -3
  12. package/dist/commands/status.js +35 -79
  13. package/dist/commands/upload.js +1 -1
  14. package/dist/context/local-workspace-provider.js +36 -126
  15. package/dist/reporter/reporter-bundle.iife.js +49 -81
  16. package/dist/server/handlers/tdd-handler.js +2 -7
  17. package/dist/server/routers/dashboard.js +5 -1
  18. package/dist/tdd/runtime-context.js +0 -2
  19. package/dist/tdd/services/comparison-service.js +36 -76
  20. package/dist/tdd/tdd-service.js +7 -205
  21. package/dist/types/index.d.ts +1 -1
  22. package/dist/uploader/core.js +7 -8
  23. package/dist/utils/visual-context-normalizers.js +86 -139
  24. package/package.json +4 -4
  25. package/skills/vizzly/SKILL.md +42 -52
  26. package/skills/vizzly/references/cli-context.md +49 -52
  27. package/skills/vizzly/references/dynamic-content.md +18 -29
  28. package/skills/vizzly/references/sdks.md +20 -28
  29. package/skills/vizzly/references/setup-ci.md +18 -42
  30. package/dist/tdd/core/hotspot-coverage.js +0 -112
  31. package/dist/tdd/core/region-coverage.js +0 -203
  32. package/dist/tdd/metadata/hotspot-metadata.js +0 -93
  33. package/dist/tdd/metadata/region-metadata.js +0 -56
  34. package/skills/vizzly/agents/openai.yaml +0 -4
@@ -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
  }
@@ -49,67 +49,38 @@ async function fetchOptionalPreviewInfo(getPreviewInfo, client, buildId) {
49
49
  }
50
50
  }
51
51
  export function normalizeBuildStatus(buildStatus) {
52
- return buildStatus.build || buildStatus;
52
+ return buildStatus.build;
53
53
  }
54
54
 
55
55
  /**
56
- * Read exact processing counts from canonical or legacy status responses.
56
+ * Read the processing counts from the status response.
57
57
  *
58
- * Legacy `pending_screenshots` is review state, not queue state, so it is
59
- * deliberately excluded. Missing processing fields remain missing instead of
60
- * becoming client-authored zeroes.
61
- *
62
- * @param {Object} status - Canonical status bundle or legacy build record.
63
- * @returns {Object|undefined} API-provided processing facts when available.
58
+ * @param {Object} status - Status response.
59
+ * @returns {Object} API-provided processing facts.
64
60
  */
65
61
  export function getProcessingStatus(status = {}) {
66
- if (status.processing) {
67
- return status.processing;
68
- }
69
- let build = normalizeBuildStatus(status);
70
- let processing = {};
71
- let fields = [['total', build.screenshot_count], ['completed', build.completed_jobs], ['failed', build.failed_jobs], ['active', build.processing_screenshots]];
72
- for (let [name, value] of fields) {
73
- if (value != null) {
74
- processing[name] = value;
75
- }
76
- }
77
- return Object.keys(processing).length > 0 ? processing : undefined;
62
+ return status.processing;
78
63
  }
79
64
 
80
65
  /**
81
66
  * Read comparison totals without deriving them from screenshot counts.
82
67
  *
83
- * @param {Object} status - Canonical status bundle or legacy build record.
84
- * @returns {Object|undefined} API-provided comparison facts when available.
68
+ * @param {Object} status - Status response.
69
+ * @returns {Object} API-provided comparison facts.
85
70
  */
86
71
  export function getComparisonStatus(status = {}) {
87
- if (status.comparisons) {
88
- return status.comparisons;
89
- }
90
- let build = normalizeBuildStatus(status);
91
- let comparisons = {};
92
- let fields = [['total', build.total_comparisons], ['new', build.new_comparisons], ['changed', build.changed_comparisons], ['identical', build.identical_comparisons]];
93
- for (let [name, value] of fields) {
94
- if (value != null) {
95
- comparisons[name] = value;
96
- }
97
- }
98
- return Object.keys(comparisons).length > 0 ? comparisons : undefined;
72
+ return status.comparisons;
99
73
  }
100
74
 
101
75
  /**
102
- * Prefer canonical Cricket review state while retaining the legacy fallback.
103
- *
104
- * Processing status stays separate: a pending review never means a screenshot
105
- * is still being processed.
76
+ * Read the build review state without mixing it with processing status.
106
77
  *
107
- * @param {Object} status - Canonical status bundle or legacy build record.
78
+ * @param {Object} status - Status response.
108
79
  * @returns {string|null} API-provided review state when available.
109
80
  */
110
81
  export function getBuildReviewState(status = {}) {
111
82
  let build = normalizeBuildStatus(status);
112
- return getVisualReviewState(build) || getVisualReviewState(status.visualReview?.build || status.visual_review) || null;
83
+ return getVisualReviewState(build);
113
84
  }
114
85
 
115
86
  /**
@@ -132,13 +103,10 @@ export function createStatusSuggestedCommands(build = {}) {
132
103
  }
133
104
 
134
105
  /**
135
- * Preserve the established JSON fields while exposing canonical status facts.
136
- *
137
106
  * Every lifecycle, processing, comparison, and review value comes directly
138
- * from the API. Undefined values intentionally disappear during JSON encoding
139
- * rather than being presented as false certainty.
107
+ * from the API.
140
108
  *
141
- * @param {Object} status - Canonical status bundle or legacy build record.
109
+ * @param {Object} status - Status response.
142
110
  * @param {Object|null} previewInfo - Optional preview response.
143
111
  * @returns {Object} Machine-readable status payload.
144
112
  */
@@ -146,7 +114,6 @@ export function createStatusData(status, previewInfo = null) {
146
114
  let build = normalizeBuildStatus(status);
147
115
  let processing = getProcessingStatus(status);
148
116
  let comparisons = getComparisonStatus(status);
149
- let visualReview = build.visual_review || status.visualReview?.build || status.visual_review;
150
117
  return {
151
118
  resource: status.resource,
152
119
  schemaVersion: status.schema_version,
@@ -161,18 +128,14 @@ export function createStatusData(status, previewInfo = null) {
161
128
  branch: build.branch,
162
129
  commit: build.commit_sha,
163
130
  commitMessage: build.commit_message,
164
- screenshotsTotal: processing?.total ?? build.screenshot_count,
131
+ screenshotsTotal: processing.total,
165
132
  processing,
166
- comparisonsTotal: comparisons?.total ?? build.total_comparisons,
133
+ comparisonsTotal: comparisons.total,
167
134
  comparisons,
168
- newComparisons: comparisons?.new ?? build.new_comparisons,
169
- changedComparisons: comparisons?.changed ?? build.changed_comparisons,
170
- identicalComparisons: comparisons?.identical ?? build.identical_comparisons,
171
- reviewState: getBuildReviewState(status),
172
- review: status.review,
173
- reviewFlow: status.reviewFlow,
174
- visualReview,
175
- approvalStatus: build.approval_status,
135
+ newComparisons: comparisons.new,
136
+ changedComparisons: comparisons.changed,
137
+ identicalComparisons: comparisons.identical,
138
+ visual_review: build.visual_review || null,
176
139
  executionTime: build.execution_time_ms,
177
140
  isBaseline: build.is_baseline,
178
141
  userAgent: build.user_agent,
@@ -203,19 +166,18 @@ export function createBuildInfo(build) {
203
166
  }
204
167
 
205
168
  /**
206
- * Format API comparison outcomes without filling missing buckets with zeroes.
169
+ * Format API comparison outcomes.
207
170
  *
208
- * @param {Object} status - Canonical status bundle or legacy build record.
171
+ * @param {Object} status - Status response.
209
172
  * @param {Object} colors - Output color helpers.
210
173
  * @returns {string} Human-readable comparison summary.
211
174
  */
212
175
  export function createComparisonStats(status, colors) {
213
- let build = normalizeBuildStatus(status);
214
- let comparisons = getComparisonStatus(status) || {};
176
+ let comparisons = getComparisonStatus(status);
215
177
  let stats = [];
216
- let newCount = comparisons.new ?? build.new_comparisons;
217
- let changedCount = comparisons.changed ?? build.changed_comparisons;
218
- let identicalCount = comparisons.identical ?? build.identical_comparisons;
178
+ let newCount = comparisons.new;
179
+ let changedCount = comparisons.changed;
180
+ let identicalCount = comparisons.identical;
219
181
  if (newCount > 0) {
220
182
  stats.push(`${colors.brand.info(newCount)} new`);
221
183
  }
@@ -229,45 +191,39 @@ export function createComparisonStats(status, colors) {
229
191
  }
230
192
 
231
193
  /**
232
- * Build the best available legacy link when the API did not return one.
233
- *
234
- * Slug routes match the current app, while the project-ID route remains as a
235
- * compatibility fallback for older build responses.
194
+ * Build the scoped app link when the API did not return one.
236
195
  *
237
196
  * @param {string} baseUrl - API or app base URL.
238
197
  * @param {Object} build - Build record.
239
- * @param {Object|null} scope - Canonical organization and project scope.
198
+ * @param {Object|null} scope - Organization and project details.
240
199
  * @returns {string|null} Build URL when the response has enough identity.
241
200
  */
242
201
  export function createBuildUrl(baseUrl, build, scope = null) {
243
202
  if (!baseUrl) {
244
203
  return null;
245
204
  }
246
- let organizationSlug = scope?.organization?.slug || build.organization_slug || build.org_slug;
247
- let projectSlug = scope?.project?.slug || build.project_slug;
205
+ let organizationSlug = scope?.organization?.slug;
206
+ let projectSlug = scope?.project?.slug;
248
207
  let appBaseUrl = getAppBaseUrl(baseUrl);
249
208
  if (organizationSlug && projectSlug) {
250
209
  return `${appBaseUrl}/${organizationSlug}/${projectSlug}/builds/${build.id}`;
251
210
  }
252
- if (build.project_id) {
253
- return `${appBaseUrl}/projects/${build.project_id}/builds/${build.id}`;
254
- }
255
211
  return null;
256
212
  }
257
213
 
258
214
  /**
259
- * Preserve status failure behavior while accepting the canonical conclusion.
215
+ * Preserve status failure behavior while using the server's conclusion.
260
216
  *
261
217
  * Review-required and rejected builds keep their existing successful command
262
218
  * exit behavior. Only build or processing failures produce a failing status.
263
219
  *
264
- * @param {Object} status - Canonical status bundle or legacy build record.
220
+ * @param {Object} status - Status response.
265
221
  * @returns {boolean} Whether human status should exit non-zero.
266
222
  */
267
223
  export function shouldFailStatus(status) {
268
224
  let build = normalizeBuildStatus(status);
269
225
  let processing = getProcessingStatus(status);
270
- return build.status === 'failed' || ['build_failed', 'processing_failed'].includes(status.conclusion) || (processing?.failed ?? 0) > 0;
226
+ return build.status === 'failed' || ['build_failed', 'processing_failed'].includes(status.conclusion) || processing.failed > 0;
271
227
  }
272
228
  function writeStatusTiming({
273
229
  build,
@@ -314,7 +270,7 @@ function writeSuggestedCommands({
314
270
  /**
315
271
  * Add optional API facts without inventing placeholders for missing metadata.
316
272
  *
317
- * @param {Object} status - Canonical status bundle or legacy build record.
273
+ * @param {Object} status - Status response.
318
274
  * @returns {Object} Available verbose fields.
319
275
  */
320
276
  function createVerboseInfo(status) {
@@ -348,7 +304,7 @@ function createVerboseInfo(status) {
348
304
  /**
349
305
  * Format only the processing facts supplied by the API.
350
306
  *
351
- * @param {Object|undefined} processing - Canonical processing counts.
307
+ * @param {Object|undefined} processing - Processing counts.
352
308
  * @returns {string} Human-readable processing summary.
353
309
  */
354
310
  function formatProcessingStatus(processing) {
@@ -387,7 +343,7 @@ function writeHumanStatus({
387
343
  let colors = output.getColors();
388
344
  let comparisonStats = createComparisonStats(status, colors);
389
345
  let processing = getProcessingStatus(status);
390
- let screenshotsTotal = processing?.total ?? build.screenshot_count;
346
+ let screenshotsTotal = processing.total;
391
347
  let processingSummary = formatProcessingStatus(processing);
392
348
  if (screenshotsTotal != null) {
393
349
  output.labelValue('Screenshots', String(screenshotsTotal));
@@ -262,7 +262,7 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
262
262
  failed: buildResult.failedComparisons || 0,
263
263
  new: buildResult.newComparisons || 0
264
264
  },
265
- approvalStatus: buildResult.approvalStatus || 'pending',
265
+ visual_review: buildResult.visual_review,
266
266
  executionTimeMs
267
267
  });
268
268
  output.cleanup();