@vizzly-testing/cli 0.29.5 → 0.30.0

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.
@@ -0,0 +1,559 @@
1
+ /**
2
+ * Context commands - fetch visual context bundles for agents and reviewers
3
+ */
4
+
5
+ import { createApiClient as defaultCreateApiClient, getBuildContext as defaultGetBuildContext, getComparisonContext as defaultGetComparisonContext, getReviewQueueContext as defaultGetReviewQueueContext, getScreenshotContext as defaultGetScreenshotContext, getSimilarFingerprintContext as defaultGetSimilarFingerprintContext } from '../api/index.js';
6
+ import { createLocalWorkspaceContextProvider as defaultCreateLocalWorkspaceContextProvider } from '../context/local-workspace-provider.js';
7
+ import { resolveContextSource as defaultResolveContextSource } from '../context/provider-resolver.js';
8
+ import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
9
+ import * as defaultOutput from '../utils/output.js';
10
+ function buildAuthErrorMessage() {
11
+ return 'API token required. Use --token, set VIZZLY_TOKEN, or run "vizzly login"';
12
+ }
13
+ function buildSourceErrorMessage() {
14
+ return '--source must be one of: auto, cloud, local';
15
+ }
16
+ function validateLimitRange(value, flagName, {
17
+ min = 1,
18
+ max
19
+ }) {
20
+ if (value == null) {
21
+ return [];
22
+ }
23
+ if (!Number.isInteger(value) || value < min || value > max) {
24
+ return [`${flagName} must be a number between ${min} and ${max}`];
25
+ }
26
+ return [];
27
+ }
28
+ function validateOffset(value) {
29
+ if (value == null) {
30
+ return [];
31
+ }
32
+ if (!Number.isInteger(value) || value < 0) {
33
+ return ['--offset must be a non-negative number'];
34
+ }
35
+ return [];
36
+ }
37
+ function validateSourceOption(value) {
38
+ if (value == null) {
39
+ return [];
40
+ }
41
+ if (!['auto', 'cloud', 'local'].includes(value)) {
42
+ return [buildSourceErrorMessage()];
43
+ }
44
+ return [];
45
+ }
46
+ function validateScopedProjectOptions(options = {}) {
47
+ let errors = [];
48
+ if (options.org && !options.project) {
49
+ errors.push('--org requires --project');
50
+ }
51
+ return errors;
52
+ }
53
+ function createClient(config, createApiClient) {
54
+ return createApiClient({
55
+ baseUrl: config.apiUrl,
56
+ token: config.apiKey,
57
+ command: 'context'
58
+ });
59
+ }
60
+ async function loadContextConfig(globalOptions, options, deps) {
61
+ let {
62
+ loadConfig = defaultLoadConfig,
63
+ requireApiKey = true,
64
+ output = defaultOutput,
65
+ exit = code => process.exit(code)
66
+ } = deps;
67
+ let allOptions = {
68
+ ...globalOptions,
69
+ ...options
70
+ };
71
+ let config = await loadConfig(globalOptions.config, allOptions);
72
+ if (requireApiKey && !config.apiKey) {
73
+ output.error(buildAuthErrorMessage());
74
+ output.cleanup();
75
+ exit(1);
76
+ return null;
77
+ }
78
+ return config;
79
+ }
80
+ function createCloudContextProvider(config, deps = {}) {
81
+ let {
82
+ createApiClient = defaultCreateApiClient,
83
+ getBuildContext = defaultGetBuildContext,
84
+ getComparisonContext = defaultGetComparisonContext,
85
+ getScreenshotContext = defaultGetScreenshotContext,
86
+ getSimilarFingerprintContext = defaultGetSimilarFingerprintContext,
87
+ getReviewQueueContext = defaultGetReviewQueueContext
88
+ } = deps;
89
+ let client = createClient(config, createApiClient);
90
+ return {
91
+ source: 'cloud',
92
+ async getBuildContext(buildId) {
93
+ return await getBuildContext(client, buildId);
94
+ },
95
+ async getComparisonContext(comparisonId, query) {
96
+ return await getComparisonContext(client, comparisonId, query);
97
+ },
98
+ async getScreenshotContext(screenshotName, query) {
99
+ return await getScreenshotContext(client, screenshotName, query);
100
+ },
101
+ async getSimilarFingerprintContext(fingerprintHash, query) {
102
+ return await getSimilarFingerprintContext(client, fingerprintHash, query);
103
+ },
104
+ async getReviewQueueContext(query) {
105
+ return await getReviewQueueContext(client, query);
106
+ }
107
+ };
108
+ }
109
+ function buildLocalFingerprintCapabilityError() {
110
+ let error = new Error('Local workspace context does not support fingerprint similarity yet. Use --source cloud for this query.');
111
+ error.code = 'LOCAL_WORKSPACE_CONTEXT';
112
+ return error;
113
+ }
114
+ function shouldExplainLocalSimilarityGap(requestedSource, command, localProvider) {
115
+ return requestedSource === 'auto' && command === 'similar' && localProvider.isAvailable();
116
+ }
117
+ async function loadContextRuntime(command, target, globalOptions, options, deps = {}) {
118
+ let {
119
+ createLocalWorkspaceContextProvider = defaultCreateLocalWorkspaceContextProvider,
120
+ resolveContextSource = defaultResolveContextSource,
121
+ output = defaultOutput,
122
+ exit = code => process.exit(code)
123
+ } = deps;
124
+ let config = await loadContextConfig(globalOptions, options, {
125
+ ...deps,
126
+ output,
127
+ exit,
128
+ requireApiKey: false
129
+ });
130
+ let requestedSource = options.source || 'auto';
131
+ let projectRoot = deps.projectRoot || process.cwd();
132
+ let localProvider = createLocalWorkspaceContextProvider({
133
+ projectRoot
134
+ });
135
+ let source = resolveContextSource({
136
+ requestedSource,
137
+ command,
138
+ target,
139
+ projectRoot
140
+ }, {
141
+ createLocalWorkspaceContextProvider
142
+ });
143
+ if (source === 'cloud' && !config.apiKey) {
144
+ if (shouldExplainLocalSimilarityGap(requestedSource, command, localProvider)) {
145
+ throw buildLocalFingerprintCapabilityError();
146
+ }
147
+ output.error(buildAuthErrorMessage());
148
+ output.cleanup();
149
+ exit(1);
150
+ return null;
151
+ }
152
+ let provider = source === 'local' ? localProvider : createCloudContextProvider(config, deps);
153
+ return {
154
+ config,
155
+ source,
156
+ provider
157
+ };
158
+ }
159
+ function buildScopeQuery(options = {}, query = {}) {
160
+ let scopedQuery = {
161
+ ...query
162
+ };
163
+ if (options.project) {
164
+ scopedQuery.project = options.project;
165
+ }
166
+ if (options.org) {
167
+ scopedQuery.organization = options.org;
168
+ }
169
+ return scopedQuery;
170
+ }
171
+ function getStatusTone(colors, status) {
172
+ if (status === 'changed' || status === 'pending' || status === 'failed') {
173
+ return colors.brand.warning;
174
+ }
175
+ if (status === 'approved' || status === 'completed' || status === 'identical') {
176
+ return colors.brand.success;
177
+ }
178
+ if (status === 'rejected' || status === 'error') {
179
+ return colors.brand.error;
180
+ }
181
+ return colors.brand.info;
182
+ }
183
+ function getComparisonDisplayState(comparison = {}) {
184
+ return comparison.result || comparison.status || 'unknown';
185
+ }
186
+ function formatConfirmedRegionLabels(regions = []) {
187
+ return regions.map(region => region.label).filter(Boolean).slice(0, 3).join(' · ');
188
+ }
189
+ function printComparisonList(output, comparisons = [], {
190
+ limit = 5
191
+ } = {}) {
192
+ let colors = output.getColors();
193
+ for (let comparison of comparisons.slice(0, limit)) {
194
+ let displayState = getComparisonDisplayState(comparison);
195
+ let statusTone = getStatusTone(colors, displayState);
196
+ let screenshotName = comparison.screenshot?.name || comparison.name || comparison.id;
197
+ let diffPercentage = comparison.diff_percentage == null ? null : `${comparison.diff_percentage}%`;
198
+ let fingerprint = comparison.analysis?.fingerprint_hash || null;
199
+ let details = [];
200
+ if (diffPercentage) {
201
+ details.push(diffPercentage);
202
+ }
203
+ if (fingerprint) {
204
+ details.push(`fp:${fingerprint}`);
205
+ }
206
+ if (comparison.build_branch) {
207
+ details.push(comparison.build_branch);
208
+ }
209
+ output.print(` ${colors.bold(screenshotName)} ${statusTone(displayState.toUpperCase())}`);
210
+ if (details.length > 0) {
211
+ output.print(` ${colors.dim(details.join(' · '))}`);
212
+ }
213
+ }
214
+ }
215
+ function displayBuildContext(output, context) {
216
+ output.header('context', 'build');
217
+ let colors = output.getColors();
218
+ let buildTone = getStatusTone(colors, context.build.status);
219
+ output.print(` ${colors.bold(context.build.name || context.build.id)} ${buildTone((context.build.status || 'unknown').toUpperCase())}`);
220
+ output.print(` ${colors.dim(`@${context.scope.organization.slug}/${context.scope.project.slug}`)}`);
221
+ output.blank();
222
+ output.labelValue('Comparisons', String(context.comparisons.length));
223
+ output.labelValue('Review', `${context.summary.review.pending || 0} pending · ${context.summary.review.approved || 0} approved · ${context.summary.review.rejected || 0} rejected`);
224
+ output.labelValue('Memory', `${context.review.comments.length} build comments · ${context.review.assignments.length} assignments`);
225
+ if (context.preview) {
226
+ output.labelValue('Preview', `${context.preview.status}${context.preview.preview_url ? ' · available' : ''}`);
227
+ }
228
+ if (context.links?.build_url) {
229
+ output.labelValue('Build URL', context.links.build_url);
230
+ }
231
+ if (context.comparisons.length > 0) {
232
+ output.blank();
233
+ output.print(' Comparisons');
234
+ printComparisonList(output, context.comparisons);
235
+ }
236
+ }
237
+ function countScreenshotCommentEntries(groups = []) {
238
+ return groups.reduce((total, group) => total + (group.comments?.length || 0), 0);
239
+ }
240
+ function displayComparisonContext(output, context) {
241
+ output.header('context', 'comparison');
242
+ let colors = output.getColors();
243
+ let displayState = getComparisonDisplayState(context.comparison);
244
+ let statusTone = getStatusTone(colors, displayState);
245
+ let screenshotName = context.comparison.screenshot?.name || context.comparison.id;
246
+ let analysis = context.comparison.analysis || {};
247
+ let confirmedRegionLabels = formatConfirmedRegionLabels(context.history.confirmed_regions);
248
+ output.print(` ${colors.bold(screenshotName)} ${statusTone(displayState.toUpperCase())}`);
249
+ output.print(` ${colors.dim(`@${context.scope.organization.slug}/${context.scope.project.slug}`)}`);
250
+ output.blank();
251
+ output.labelValue('Eyes', `${analysis.diff_image_url ? 'baseline/current/diff' : 'comparison metadata only'}`);
252
+ output.labelValue('Memory', `${context.history.similar_by_fingerprint.length} similar · ${context.history.recent_by_name.length} recent · ${context.history.confirmed_regions.length} confirmed regions`);
253
+ output.labelValue('Review', `${context.review.build_comments.length} build comments · ${countScreenshotCommentEntries(context.review.screenshot_comments)} screenshot comments`);
254
+ if (analysis.fingerprint_hash) {
255
+ output.labelValue('Fingerprint', analysis.fingerprint_hash);
256
+ }
257
+ if (confirmedRegionLabels) {
258
+ output.labelValue('Known Regions', confirmedRegionLabels);
259
+ }
260
+ if (context.links?.comparison_url) {
261
+ output.labelValue('Comparison URL', context.links.comparison_url);
262
+ }
263
+ if (context.history.similar_by_fingerprint.length > 0) {
264
+ output.blank();
265
+ output.print(' Similar Diffs');
266
+ printComparisonList(output, context.history.similar_by_fingerprint);
267
+ }
268
+ }
269
+ function displayScreenshotContext(output, context) {
270
+ output.header('context', 'screenshot');
271
+ let colors = output.getColors();
272
+ let confirmedRegionLabels = formatConfirmedRegionLabels(context.confirmed_regions);
273
+ output.print(` ${colors.bold(context.screenshot.name)}`);
274
+ output.print(` ${colors.dim(`@${context.scope.organization.slug}/${context.scope.project.slug}`)}`);
275
+ output.blank();
276
+ output.labelValue('Memory', `${context.history.recent_comparisons.length} recent comparisons · ${context.confirmed_regions.length} confirmed regions`);
277
+ output.labelValue('Hotspots', `${context.hotspot_analysis.total_builds_analyzed} builds analyzed · ${context.hotspot_analysis.confidence}`);
278
+ if (confirmedRegionLabels) {
279
+ output.labelValue('Known Regions', confirmedRegionLabels);
280
+ }
281
+ if (context.history.recent_comparisons.length > 0) {
282
+ output.blank();
283
+ output.print(' Recent Comparisons');
284
+ printComparisonList(output, context.history.recent_comparisons);
285
+ }
286
+ }
287
+ function displayFingerprintContext(output, context) {
288
+ output.header('context', 'similar');
289
+ let colors = output.getColors();
290
+ let fingerprintHash = context.fingerprint?.hash || context.fingerprint_hash || 'unknown';
291
+ let comparisons = context.comparisons || context.matches || [];
292
+ output.print(` ${colors.bold(fingerprintHash)}`);
293
+ output.print(` ${colors.dim(`@${context.scope.organization.slug}/${context.scope.project.slug}`)}`);
294
+ output.blank();
295
+ output.labelValue('Matches', String(comparisons.length));
296
+ if (comparisons.length > 0) {
297
+ output.blank();
298
+ output.print(' Similar Diffs');
299
+ printComparisonList(output, comparisons, {
300
+ limit: 10
301
+ });
302
+ }
303
+ }
304
+ function displayReviewQueueContext(output, context) {
305
+ output.header('context', 'review');
306
+ let colors = output.getColors();
307
+ output.print(` ${colors.bold(`${context.summary.total} pending comparisons`)}`);
308
+ output.print(` ${colors.dim(`@${context.scope.organization.slug}/${context.scope.project.slug}`)}`);
309
+ output.blank();
310
+ output.labelValue('Queue', `${context.summary.changed} changed · ${context.summary.new} new · ${context.summary.builds} builds`);
311
+ if (context.comparisons.length > 0) {
312
+ output.blank();
313
+ output.print(' Needs Review');
314
+ printComparisonList(output, context.comparisons, {
315
+ limit: 10
316
+ });
317
+ }
318
+ }
319
+ export async function contextBuildCommand(buildId, options = {}, globalOptions = {}, deps = {}) {
320
+ let {
321
+ output = defaultOutput,
322
+ exit = code => process.exit(code)
323
+ } = deps;
324
+ output.configure({
325
+ json: globalOptions.json,
326
+ verbose: globalOptions.verbose,
327
+ color: !globalOptions.noColor
328
+ });
329
+ try {
330
+ let runtime = await loadContextRuntime('build', buildId, globalOptions, options, {
331
+ ...deps,
332
+ output,
333
+ exit
334
+ });
335
+ if (!runtime) {
336
+ return;
337
+ }
338
+ output.startSpinner('Fetching build context...');
339
+ let context = await runtime.provider.getBuildContext(buildId);
340
+ output.stopSpinner();
341
+ if (globalOptions.json) {
342
+ output.data(context);
343
+ output.cleanup();
344
+ return;
345
+ }
346
+ displayBuildContext(output, context);
347
+ output.cleanup();
348
+ } catch (error) {
349
+ output.stopSpinner();
350
+ output.error('Failed to fetch build context', error);
351
+ output.cleanup();
352
+ exit(1);
353
+ }
354
+ }
355
+ export async function contextComparisonCommand(comparisonId, options = {}, globalOptions = {}, deps = {}) {
356
+ let {
357
+ output = defaultOutput,
358
+ exit = code => process.exit(code)
359
+ } = deps;
360
+ output.configure({
361
+ json: globalOptions.json,
362
+ verbose: globalOptions.verbose,
363
+ color: !globalOptions.noColor
364
+ });
365
+ try {
366
+ let runtime = await loadContextRuntime('comparison', comparisonId, globalOptions, options, {
367
+ ...deps,
368
+ output,
369
+ exit
370
+ });
371
+ if (!runtime) {
372
+ return;
373
+ }
374
+ let query = {
375
+ similarLimit: options.similarLimit,
376
+ recentLimit: options.recentLimit,
377
+ windowSize: options.windowSize
378
+ };
379
+ output.startSpinner('Fetching comparison context...');
380
+ let context = await runtime.provider.getComparisonContext(comparisonId, query);
381
+ output.stopSpinner();
382
+ if (globalOptions.json) {
383
+ output.data(context);
384
+ output.cleanup();
385
+ return;
386
+ }
387
+ displayComparisonContext(output, context);
388
+ output.cleanup();
389
+ } catch (error) {
390
+ output.stopSpinner();
391
+ output.error('Failed to fetch comparison context', error);
392
+ output.cleanup();
393
+ exit(1);
394
+ }
395
+ }
396
+ export async function contextScreenshotCommand(screenshotName, options = {}, globalOptions = {}, deps = {}) {
397
+ let {
398
+ output = defaultOutput,
399
+ exit = code => process.exit(code)
400
+ } = deps;
401
+ output.configure({
402
+ json: globalOptions.json,
403
+ verbose: globalOptions.verbose,
404
+ color: !globalOptions.noColor
405
+ });
406
+ try {
407
+ let runtime = await loadContextRuntime('screenshot', screenshotName, globalOptions, options, {
408
+ ...deps,
409
+ output,
410
+ exit
411
+ });
412
+ if (!runtime) {
413
+ return;
414
+ }
415
+ let query = buildScopeQuery(options, {
416
+ recentLimit: options.recentLimit,
417
+ windowSize: options.windowSize
418
+ });
419
+ output.startSpinner('Fetching screenshot context...');
420
+ let context = await runtime.provider.getScreenshotContext(screenshotName, query);
421
+ output.stopSpinner();
422
+ if (globalOptions.json) {
423
+ output.data(context);
424
+ output.cleanup();
425
+ return;
426
+ }
427
+ displayScreenshotContext(output, context);
428
+ output.cleanup();
429
+ } catch (error) {
430
+ output.stopSpinner();
431
+ output.error('Failed to fetch screenshot context', error);
432
+ output.cleanup();
433
+ exit(1);
434
+ }
435
+ }
436
+ export async function contextSimilarCommand(fingerprintHash, options = {}, globalOptions = {}, deps = {}) {
437
+ let {
438
+ output = defaultOutput,
439
+ exit = code => process.exit(code)
440
+ } = deps;
441
+ output.configure({
442
+ json: globalOptions.json,
443
+ verbose: globalOptions.verbose,
444
+ color: !globalOptions.noColor
445
+ });
446
+ try {
447
+ let runtime = await loadContextRuntime('similar', fingerprintHash, globalOptions, options, {
448
+ ...deps,
449
+ output,
450
+ exit
451
+ });
452
+ if (!runtime) {
453
+ return;
454
+ }
455
+ let query = buildScopeQuery(options, {
456
+ limit: options.limit
457
+ });
458
+ output.startSpinner('Fetching similar visual context...');
459
+ let context = await runtime.provider.getSimilarFingerprintContext(fingerprintHash, query);
460
+ output.stopSpinner();
461
+ if (globalOptions.json) {
462
+ output.data(context);
463
+ output.cleanup();
464
+ return;
465
+ }
466
+ displayFingerprintContext(output, context);
467
+ output.cleanup();
468
+ } catch (error) {
469
+ output.stopSpinner();
470
+ output.error('Failed to fetch similar visual context', error);
471
+ output.cleanup();
472
+ exit(1);
473
+ }
474
+ }
475
+ export async function contextReviewQueueCommand(options = {}, globalOptions = {}, deps = {}) {
476
+ let {
477
+ output = defaultOutput,
478
+ exit = code => process.exit(code)
479
+ } = deps;
480
+ output.configure({
481
+ json: globalOptions.json,
482
+ verbose: globalOptions.verbose,
483
+ color: !globalOptions.noColor
484
+ });
485
+ try {
486
+ let runtime = await loadContextRuntime('review-queue', null, globalOptions, options, {
487
+ ...deps,
488
+ output,
489
+ exit
490
+ });
491
+ if (!runtime) {
492
+ return;
493
+ }
494
+ let query = buildScopeQuery(options, {
495
+ limit: options.limit,
496
+ offset: options.offset
497
+ });
498
+ output.startSpinner('Fetching review queue context...');
499
+ let context = await runtime.provider.getReviewQueueContext(query);
500
+ output.stopSpinner();
501
+ if (globalOptions.json) {
502
+ output.data(context);
503
+ output.cleanup();
504
+ return;
505
+ }
506
+ displayReviewQueueContext(output, context);
507
+ output.cleanup();
508
+ } catch (error) {
509
+ output.stopSpinner();
510
+ output.error('Failed to fetch review queue context', error);
511
+ output.cleanup();
512
+ exit(1);
513
+ }
514
+ }
515
+ export function validateContextBuildOptions(_options = {}) {
516
+ return validateSourceOption(_options.source);
517
+ }
518
+ export function validateContextComparisonOptions(options = {}) {
519
+ let errors = [];
520
+ errors.push(...validateSourceOption(options.source));
521
+ errors.push(...validateLimitRange(options.similarLimit, '--similar-limit', {
522
+ max: 50
523
+ }));
524
+ errors.push(...validateLimitRange(options.recentLimit, '--recent-limit', {
525
+ max: 50
526
+ }));
527
+ errors.push(...validateLimitRange(options.windowSize, '--window-size', {
528
+ max: 50
529
+ }));
530
+ return errors;
531
+ }
532
+ export function validateContextScreenshotOptions(options = {}) {
533
+ let errors = validateScopedProjectOptions(options);
534
+ errors.push(...validateSourceOption(options.source));
535
+ errors.push(...validateLimitRange(options.recentLimit, '--recent-limit', {
536
+ max: 50
537
+ }));
538
+ errors.push(...validateLimitRange(options.windowSize, '--window-size', {
539
+ max: 50
540
+ }));
541
+ return errors;
542
+ }
543
+ export function validateContextSimilarOptions(options = {}) {
544
+ let errors = validateScopedProjectOptions(options);
545
+ errors.push(...validateSourceOption(options.source));
546
+ errors.push(...validateLimitRange(options.limit, '--limit', {
547
+ max: 50
548
+ }));
549
+ return errors;
550
+ }
551
+ export function validateContextReviewQueueOptions(options = {}) {
552
+ let errors = validateScopedProjectOptions(options);
553
+ errors.push(...validateSourceOption(options.source));
554
+ errors.push(...validateLimitRange(options.limit, '--limit', {
555
+ max: 100
556
+ }));
557
+ errors.push(...validateOffset(options.offset));
558
+ return errors;
559
+ }
@@ -21,7 +21,7 @@ export async function logoutCommand(options = {}, globalOptions = {}) {
21
21
  try {
22
22
  // Check if user is logged in
23
23
  let auth = await getAuthTokens();
24
- if (!auth || !auth.accessToken) {
24
+ if (!auth?.accessToken) {
25
25
  if (globalOptions.json) {
26
26
  output.data({
27
27
  loggedOut: false,
@@ -21,7 +21,7 @@ export async function whoamiCommand(options = {}, globalOptions = {}) {
21
21
  try {
22
22
  // Check if user is logged in
23
23
  let auth = await getAuthTokens();
24
- if (!auth || !auth.accessToken) {
24
+ if (!auth?.accessToken) {
25
25
  if (globalOptions.json) {
26
26
  output.data({
27
27
  authenticated: false
@@ -390,7 +390,7 @@ export function serializeConfig(config, filepath) {
390
390
  * @returns {{ config: Object|null, filepath: string|null }}
391
391
  */
392
392
  export function extractCosmiconfigResult(result) {
393
- if (!result || !result.config) {
393
+ if (!result?.config) {
394
394
  return {
395
395
  config: null,
396
396
  filepath: null