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

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 (43) hide show
  1. package/README.md +8 -5
  2. package/dist/api/client.js +20 -5
  3. package/dist/api/core.js +17 -3
  4. package/dist/api/endpoints.js +24 -3
  5. package/dist/api/index.js +1 -1
  6. package/dist/cli.js +25 -65
  7. package/dist/commands/baselines.js +0 -7
  8. package/dist/commands/comparisons.js +52 -45
  9. package/dist/commands/config-cmd.js +0 -7
  10. package/dist/commands/context.js +444 -238
  11. package/dist/commands/doctor.js +0 -8
  12. package/dist/commands/init.js +7 -12
  13. package/dist/commands/login.js +0 -13
  14. package/dist/commands/logout.js +0 -12
  15. package/dist/commands/orgs.js +0 -9
  16. package/dist/commands/run.js +84 -29
  17. package/dist/commands/status.js +276 -59
  18. package/dist/commands/tdd.js +19 -3
  19. package/dist/commands/whoami.js +0 -12
  20. package/dist/context/local-workspace-provider.js +293 -37
  21. package/dist/plugin-loader.js +1 -1
  22. package/dist/reporter/reporter-bundle.css +1 -1
  23. package/dist/reporter/reporter-bundle.iife.js +17 -17
  24. package/dist/server/routers/events.js +4 -4
  25. package/dist/test-runner/operations.js +70 -28
  26. package/dist/types/index.d.ts +4 -2
  27. package/dist/uploader/core.js +6 -6
  28. package/dist/uploader/operations.js +4 -1
  29. package/dist/utils/visual-context-normalizers.js +455 -0
  30. package/package.json +13 -13
  31. package/skills/vizzly/SKILL.md +68 -101
  32. package/skills/vizzly/agents/openai.yaml +2 -2
  33. package/skills/vizzly/references/cli-context.md +79 -27
  34. package/skills/vizzly/references/dynamic-content.md +28 -33
  35. package/skills/vizzly/references/sdks.md +26 -39
  36. package/skills/vizzly/references/setup-ci.md +44 -20
  37. package/dist/config/index.js +0 -13
  38. package/dist/project/index.js +0 -13
  39. package/dist/tdd/index.js +0 -19
  40. package/dist/tdd/services/baseline-downloader.js +0 -151
  41. package/dist/tdd/services/hotspot-service.js +0 -58
  42. package/dist/utils/environment.js +0 -109
  43. package/skills/vizzly/references/public-screenshots.md +0 -44
@@ -5,9 +5,11 @@
5
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
6
  import { createLocalWorkspaceContextProvider as defaultCreateLocalWorkspaceContextProvider } from '../context/local-workspace-provider.js';
7
7
  import { resolveContextSource as defaultResolveContextSource } from '../context/provider-resolver.js';
8
+ import { VizzlyError } from '../errors/vizzly-error.js';
8
9
  import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
9
10
  import * as defaultOutput from '../utils/output.js';
10
11
  import { readSession as defaultReadSession } from '../utils/session.js';
12
+ import { normalizeBuildContext } from '../utils/visual-context-normalizers.js';
11
13
  function buildAuthErrorMessage() {
12
14
  return 'Authentication required. Use --token, set VIZZLY_TOKEN, run "vizzly login", or link a project.';
13
15
  }
@@ -15,7 +17,7 @@ function buildSourceErrorMessage() {
15
17
  return '--source must be one of: auto, cloud, local';
16
18
  }
17
19
  function buildIncludeErrorMessage() {
18
- return '--include must contain only: screenshots, diffs, comments';
20
+ return '--include must contain only: diffs';
19
21
  }
20
22
  function validateLimitRange(value, flagName, {
21
23
  min = 1,
@@ -29,15 +31,81 @@ function validateLimitRange(value, flagName, {
29
31
  }
30
32
  return [];
31
33
  }
32
- function validateOffset(value) {
34
+ function validateCursor(value) {
33
35
  if (value == null) {
34
36
  return [];
35
37
  }
38
+ if (typeof value !== 'string' || value.trim().length === 0) {
39
+ return ['--cursor must be a non-empty opaque cursor'];
40
+ }
41
+ return [];
42
+ }
43
+ function validateOffset(value) {
44
+ if (value == null) return [];
36
45
  if (!Number.isInteger(value) || value < 0) {
37
46
  return ['--offset must be a non-negative integer'];
38
47
  }
39
48
  return [];
40
49
  }
50
+ let COMPACT_CONTEXT_LIMIT = 10;
51
+ let COMPACT_SUMMARY_MAX_BYTES = 64 * 1024;
52
+ let COMPACT_DIFFS_MAX_BYTES = 512 * 1024;
53
+ function getCompactPage(collection, label) {
54
+ if (!collection || !Array.isArray(collection.items) || !collection.page) {
55
+ throw new VizzlyError(`Vizzly returned an invalid compact ${label} collection`, 'COMPACT_CONTEXT_INVALID');
56
+ }
57
+ if (collection.items.length > COMPACT_CONTEXT_LIMIT) {
58
+ throw new VizzlyError(`Vizzly returned more than ${COMPACT_CONTEXT_LIMIT} compact ${label} items`, 'COMPACT_CONTEXT_INVALID');
59
+ }
60
+ if (collection.items.some(item => !item || typeof item !== 'object')) {
61
+ throw new VizzlyError(`Vizzly returned an invalid item in compact ${label}`, 'COMPACT_CONTEXT_INVALID');
62
+ }
63
+ let {
64
+ page
65
+ } = collection;
66
+ let validLimit = Number.isInteger(page.limit) && page.limit >= 1 && page.limit <= COMPACT_CONTEXT_LIMIT;
67
+ let validReturned = Number.isInteger(page.returned) && page.returned === collection.items.length;
68
+ let validTotal = page.total == null || Number.isInteger(page.total) && page.total >= 0 && page.total >= page.returned;
69
+ let validHasMore = typeof page.has_more === 'boolean';
70
+ let validCursor = page.next_cursor == null || typeof page.next_cursor === 'string' && page.next_cursor.length > 0;
71
+ if (!validLimit || !validReturned || !validTotal || !validHasMore || !validCursor) {
72
+ throw new VizzlyError(`Vizzly returned invalid pagination facts for compact ${label}`, 'COMPACT_CONTEXT_INVALID');
73
+ }
74
+ if (page.included === false && collection.items.length > 0) {
75
+ throw new VizzlyError(`Vizzly returned items for an omitted compact ${label} collection`, 'COMPACT_CONTEXT_INVALID');
76
+ }
77
+ if (page.has_more !== Boolean(page.next_cursor)) {
78
+ throw new VizzlyError(`Vizzly returned contradictory pagination facts for compact ${label}`, 'COMPACT_CONTEXT_INVALID');
79
+ }
80
+ return collection;
81
+ }
82
+ function validateCompactContext(context, resource) {
83
+ if (!context || typeof context !== 'object') {
84
+ throw new VizzlyError(`Vizzly returned an invalid compact ${resource} context`, 'COMPACT_CONTEXT_INVALID');
85
+ }
86
+ if (context.resource !== `${resource}_context` || !context[resource]?.id) {
87
+ throw new VizzlyError(`Vizzly returned an invalid compact ${resource} context`, 'COMPACT_CONTEXT_INVALID');
88
+ }
89
+ if (resource === 'build') {
90
+ getCompactPage(context.evidence, 'build evidence');
91
+ } else {
92
+ let history = context.history || {};
93
+ getCompactPage(history.similar_by_fingerprint, 'similar history');
94
+ getCompactPage(history.recent_by_name, 'recent history');
95
+ }
96
+ return context;
97
+ }
98
+ function validateCompactOutputSize(payload, resource, details) {
99
+ let bytes = Buffer.byteLength(JSON.stringify(payload));
100
+ let maxBytes = details === 'diffs' ? COMPACT_DIFFS_MAX_BYTES : COMPACT_SUMMARY_MAX_BYTES;
101
+ if (bytes > maxBytes) {
102
+ throw new VizzlyError(`Vizzly produced an oversized compact ${resource} context`, 'COMPACT_CONTEXT_OVERSIZED', {
103
+ bytes,
104
+ max_bytes: maxBytes
105
+ });
106
+ }
107
+ return payload;
108
+ }
41
109
  function validateSourceOption(value) {
42
110
  if (value == null) {
43
111
  return [];
@@ -54,8 +122,23 @@ function parseIncludeOption(value) {
54
122
  let rawItems = Array.isArray(value) ? value : String(value).split(',');
55
123
  return rawItems.map(item => item.trim()).filter(Boolean);
56
124
  }
125
+ function buildCompactContextRequest(options = {}, globalOptions = {}) {
126
+ let include = parseIncludeOption(options.include);
127
+ let compact = !options.full && (!globalOptions.json || options.agent);
128
+ let details = include.includes('diffs') ? 'diffs' : 'summary';
129
+ return {
130
+ compact,
131
+ details,
132
+ include,
133
+ query: compact ? {
134
+ details,
135
+ limit: COMPACT_CONTEXT_LIMIT,
136
+ cursor: options.cursor
137
+ } : undefined
138
+ };
139
+ }
57
140
  function validateIncludeOption(value) {
58
- let allowed = new Set(['screenshots', 'diffs', 'comments']);
141
+ let allowed = new Set(['diffs']);
59
142
  let invalid = parseIncludeOption(value).filter(item => !allowed.has(item));
60
143
  return invalid.length > 0 ? [buildIncludeErrorMessage()] : [];
61
144
  }
@@ -76,6 +159,24 @@ function createClient(config, createApiClient) {
76
159
  command: 'context'
77
160
  });
78
161
  }
162
+
163
+ /**
164
+ * Attach the provider that produced a context payload.
165
+ *
166
+ * Context endpoints return API-owned evidence, while source selection belongs
167
+ * to the CLI. Keeping both in one payload prevents follow-up agent commands
168
+ * from silently crossing between cloud and local evidence.
169
+ *
170
+ * @param {Object} context - Context payload returned by a provider.
171
+ * @param {string} source - Provider source selected by the CLI.
172
+ * @returns {Object} Context payload with explicit source provenance.
173
+ */
174
+ function attachContextSource(context, source) {
175
+ return {
176
+ ...context,
177
+ source
178
+ };
179
+ }
79
180
  async function loadContextConfig(globalOptions, options, deps) {
80
181
  let {
81
182
  loadConfig = defaultLoadConfig,
@@ -108,20 +209,20 @@ function createCloudContextProvider(config, deps = {}) {
108
209
  let client = createClient(config, createApiClient);
109
210
  return {
110
211
  source: 'cloud',
111
- async getBuildContext(buildId) {
112
- return await getBuildContext(client, buildId);
212
+ async getBuildContext(buildId, query) {
213
+ return attachContextSource(await getBuildContext(client, buildId, query), 'cloud');
113
214
  },
114
215
  async getComparisonContext(comparisonId, query) {
115
- return await getComparisonContext(client, comparisonId, query);
216
+ return attachContextSource(await getComparisonContext(client, comparisonId, query), 'cloud');
116
217
  },
117
218
  async getScreenshotContext(screenshotName, query) {
118
- return await getScreenshotContext(client, screenshotName, query);
219
+ return attachContextSource(await getScreenshotContext(client, screenshotName, query), 'cloud');
119
220
  },
120
221
  async getSimilarFingerprintContext(fingerprintHash, query) {
121
- return await getSimilarFingerprintContext(client, fingerprintHash, query);
222
+ return attachContextSource(await getSimilarFingerprintContext(client, fingerprintHash, query), 'cloud');
122
223
  },
123
224
  async getReviewQueueContext(query) {
124
- return await getReviewQueueContext(client, query);
225
+ return attachContextSource(await getReviewQueueContext(client, query), 'cloud');
125
226
  }
126
227
  };
127
228
  }
@@ -220,12 +321,6 @@ function getStatusTone(colors, status) {
220
321
  function getComparisonDisplayState(comparison = {}) {
221
322
  return comparison.result || comparison.status || 'unknown';
222
323
  }
223
- function isChangedComparison(comparison = {}) {
224
- return ['changed', 'failed', 'pending'].includes(getComparisonDisplayState(comparison));
225
- }
226
- function isNewComparison(comparison = {}) {
227
- return getComparisonDisplayState(comparison) === 'new';
228
- }
229
324
  function getComparisonName(comparison = {}) {
230
325
  return comparison.screenshot_name || comparison.screenshot?.name || comparison.name || comparison.id || 'unknown screenshot';
231
326
  }
@@ -235,141 +330,203 @@ function getComparisonDiffPercentage(comparison = {}) {
235
330
  function getComparisonFingerprint(comparison = {}) {
236
331
  return comparison.diff?.fingerprint_hash || comparison.analysis?.fingerprint_hash || null;
237
332
  }
238
- function getComparisonDiffImageUrl(comparison = {}) {
239
- return comparison.diff?.image_url || comparison.analysis?.diff_image_url || null;
240
- }
241
- function getComparisonRegionCount(comparison = {}) {
242
- let regions = comparison.diff?.regions || comparison.analysis?.diff_regions;
243
- return Array.isArray(regions) ? regions.length : 0;
244
- }
245
- function getComparisonScreenshot(comparison = {}) {
246
- return comparison.screenshot || {};
247
- }
248
- function getComparisonBaseline(comparison = {}) {
249
- return comparison.baseline || comparison.screenshot?.baseline || {};
250
- }
251
- function buildCompactDiff(comparison = {}, includeDiffs = false) {
252
- let diff = comparison.diff || comparison.analysis || {};
253
- let compact = {
254
- percentage: getComparisonDiffPercentage(comparison),
255
- changed_pixels: diff.changed_pixels ?? comparison.changed_pixels ?? null,
256
- total_pixels: diff.total_pixels ?? comparison.total_pixels ?? null,
257
- threshold: diff.threshold ?? comparison.threshold ?? null,
258
- fingerprint_hash: getComparisonFingerprint(comparison),
259
- region_count: getComparisonRegionCount(comparison),
260
- image_url: getComparisonDiffImageUrl(comparison)
261
- };
262
- if (includeDiffs) {
263
- compact.regions = diff.regions || diff.diff_regions || [];
264
- compact.cluster_metadata = diff.cluster_metadata || null;
265
- compact.ssim_score = diff.ssim_score ?? null;
266
- compact.gmsd_score = diff.gmsd_score ?? null;
267
- compact.diff_lines = diff.diff_lines || [];
333
+
334
+ /**
335
+ * Quote a value only when a suggested command needs shell protection.
336
+ *
337
+ * Suggested commands are meant to be executable, so names with whitespace or
338
+ * apostrophes must survive copy and paste without changing their value.
339
+ *
340
+ * @param {unknown} value - CLI argument value.
341
+ * @returns {string} A shell-safe argument for the displayed command.
342
+ */
343
+ function quoteCommandArgument(value) {
344
+ let stringValue = String(value);
345
+ if (/^[A-Za-z0-9._:/-]+$/.test(stringValue)) {
346
+ return stringValue;
268
347
  }
269
- return compact;
348
+ return `'${stringValue.replaceAll("'", `'\\''`)}'`;
270
349
  }
271
- function buildCompactComparison(comparison = {}, {
272
- includeDiffs = false
273
- } = {}) {
274
- let screenshot = getComparisonScreenshot(comparison);
275
- let baseline = getComparisonBaseline(comparison);
276
- return {
277
- id: comparison.id || null,
278
- name: getComparisonName(comparison),
279
- result: getComparisonDisplayState(comparison),
280
- approval_status: comparison.approval_status || null,
281
- needs_review: Boolean(comparison.needs_review),
282
- is_flaky: Boolean(comparison.is_flaky),
283
- screenshot: {
284
- id: screenshot.id || null,
285
- url: screenshot.url || screenshot.original_url || null
286
- },
287
- baseline: {
288
- id: baseline.id || null,
289
- build_id: baseline.build_id || null,
290
- url: baseline.url || baseline.original_url || null
291
- },
292
- diff: buildCompactDiff(comparison, includeDiffs)
293
- };
350
+
351
+ /**
352
+ * Recognize both local source labels emitted across supported context shapes.
353
+ *
354
+ * @param {Object} context - Normalized context response.
355
+ * @returns {boolean} Whether follow-up commands must stay in local mode.
356
+ */
357
+ function isLocalContext(context = {}) {
358
+ return ['local', 'local_workspace'].includes(context.source);
359
+ }
360
+
361
+ /**
362
+ * Keep executable suggestions on the source that produced their evidence.
363
+ *
364
+ * Mixed workspaces can contain both a cloud run session and persisted local
365
+ * TDD results. Pinning either source prevents a follow-up command from
366
+ * silently crossing that boundary and describing a different build.
367
+ *
368
+ * @param {string} command - Base CLI command.
369
+ * @param {Object} context - Context that produced the command.
370
+ * @returns {string} Command pinned to its originating source.
371
+ */
372
+ function appendContextSource(command, context = {}) {
373
+ let source = isLocalContext(context) ? 'local' : 'cloud';
374
+ return `${command} --source ${source}`;
375
+ }
376
+ function buildCompactBuildCommands(context = {}, include = [], cursor = null) {
377
+ let evidence = context.evidence?.items || [];
378
+ let page = context.evidence?.page || {};
379
+ let commands = [];
380
+ let buildTarget = isLocalContext(context) ? 'current' : context.build?.id || null;
381
+ let firstComparison = evidence.find(item => item.type === 'comparison' && item.id);
382
+ let firstNamedEvidence = evidence.find(item => item.screenshot_name);
383
+ if (firstComparison) {
384
+ commands.push({
385
+ label: 'Inspect comparison context',
386
+ command: appendContextSource(`vizzly --json context comparison ${quoteCommandArgument(firstComparison.id)} --agent`, context)
387
+ });
388
+ }
389
+ if (firstNamedEvidence) {
390
+ let screenshotName = firstNamedEvidence.screenshot_name;
391
+ commands.push({
392
+ label: 'Inspect screenshot history',
393
+ command: appendContextSource(`vizzly --json context screenshot ${quoteCommandArgument(screenshotName)}`, context)
394
+ });
395
+ }
396
+ if (buildTarget && evidence.length > 0) {
397
+ let cursorFlag = cursor ? ` --cursor ${quoteCommandArgument(cursor)}` : '';
398
+ commands.push({
399
+ label: 'Load raw diff diagnostics',
400
+ command: appendContextSource(`vizzly --json context build ${quoteCommandArgument(buildTarget)} --agent --include diffs${cursorFlag}`, context)
401
+ });
402
+ }
403
+ if (buildTarget && page.has_more && page.next_cursor) {
404
+ let includeFlag = include.length > 0 ? ` --include ${include.join(',')}` : '';
405
+ commands.push({
406
+ label: 'Load next evidence page',
407
+ command: appendContextSource(`vizzly --json context build ${quoteCommandArgument(buildTarget)} --agent --cursor ${quoteCommandArgument(page.next_cursor)}${includeFlag}`, context)
408
+ });
409
+ }
410
+ if (buildTarget) {
411
+ commands.push({
412
+ label: 'Load full build context',
413
+ command: appendContextSource(`vizzly --json context build ${quoteCommandArgument(buildTarget)} --agent --full`, context)
414
+ });
415
+ }
416
+ return commands;
294
417
  }
295
- function summarizeComparisons(comparisons = []) {
418
+ function buildCompactBuildPayload(context, include = [], cursor = null) {
296
419
  return {
297
- total: comparisons.length,
298
- changed: comparisons.filter(isChangedComparison).length,
299
- new: comparisons.filter(isNewComparison).length,
300
- needs_review: comparisons.filter(comparison => comparison.needs_review).length
420
+ ...context,
421
+ resource: 'build_agent_context',
422
+ suggested_commands: buildCompactBuildCommands(context, include, cursor)
301
423
  };
302
424
  }
303
- function buildAgentNextActions(context = {}, comparisonSummary = {}) {
304
- if (context.status?.needs_review) {
305
- return ['Inspect the changed and new comparisons before editing related UI.', 'Use approved baselines as the expected visual behavior.', 'Leave approval decisions to human reviewers.'];
425
+ function buildCompactComparisonCommands(context = {}, include = []) {
426
+ let commands = [];
427
+ let comparisonId = context.comparison?.id;
428
+ let streams = [['similar_by_fingerprint', 'similar history'], ['recent_by_name', 'recent history']];
429
+ let includeFlag = include.length > 0 ? ` --include ${include.join(',')}` : '';
430
+ for (let [stream, label] of streams) {
431
+ let page = context.history?.[stream]?.page;
432
+ if (!comparisonId || !page?.has_more || !page.next_cursor) {
433
+ continue;
434
+ }
435
+ commands.push({
436
+ label: `Load next ${label} page`,
437
+ command: appendContextSource(`vizzly --json context comparison ${quoteCommandArgument(comparisonId)} --agent --cursor ${quoteCommandArgument(page.next_cursor)}${includeFlag}`, context)
438
+ });
306
439
  }
307
- if (comparisonSummary.total > 0) {
308
- return ['Use the approved baseline and reviewed screenshots as current visual truth.', 'Prefer targeted edits that preserve identical comparisons.'];
440
+ if (comparisonId) {
441
+ commands.push({
442
+ label: 'Load full comparison context',
443
+ command: appendContextSource(`vizzly --json context comparison ${quoteCommandArgument(comparisonId)} --agent --full`, context)
444
+ });
309
445
  }
310
- return ['No comparisons were returned for this build context.', 'Open the build URL for more detail if this is unexpected.'];
446
+ return commands;
311
447
  }
312
- function formatAgentStatus(status = {}) {
448
+ function buildCompactComparisonPayload(context, include = []) {
313
449
  return {
314
- needs_review: Boolean(status.needs_review),
315
- reasons: status.reasons || [],
316
- pending_comparisons: status.pending_comparisons || 0,
317
- unresolved_comments: status.unresolved_comments || 0
450
+ ...context,
451
+ resource: 'comparison_agent_context',
452
+ suggested_commands: buildCompactComparisonCommands(context, include)
318
453
  };
319
454
  }
320
- function formatAgentSummary(context = {}, comparisons = []) {
321
- return {
322
- comparisons: context.summary?.comparisons || summarizeComparisons(comparisons),
323
- review: context.summary?.review || {},
324
- comments: context.summary?.comments || {
325
- build: getBuildCommentsCount(context),
326
- screenshot: getScreenshotCommentsCount(context)
455
+ function formatKnownBoolean(value) {
456
+ if (value === true) return 'yes';
457
+ if (value === false) return 'no';
458
+ return 'unknown';
459
+ }
460
+ function formatCompactPage(collection) {
461
+ let page = collection?.page || {};
462
+ let returned = page.returned ?? collection?.items?.length;
463
+ let total = page.total;
464
+ if (returned == null) return 'unknown';
465
+ if (total == null) return String(returned);
466
+ return `${returned} of ${total}${page.has_more ? ' · more available' : ''}`;
467
+ }
468
+ function displayCompactHeading(output, context, title, status) {
469
+ let colors = output.getColors();
470
+ let tone = getStatusTone(colors, status);
471
+ let organization = context.scope?.organization?.slug || 'unknown';
472
+ let project = context.scope?.project?.slug || 'unknown';
473
+ output.print(` ${colors.bold(title)} ${tone(status.toUpperCase())}`);
474
+ output.print(` ${colors.dim(`@${organization}/${project}`)}`);
475
+ output.blank();
476
+ }
477
+ function displayCompactBuildContext(output, context) {
478
+ output.header('context', 'build');
479
+ let colors = output.getColors();
480
+ let build = context.build || {};
481
+ let displayStatus = build.status || context.status?.state || 'unknown';
482
+ displayCompactHeading(output, context, build.name || build.id || 'unknown build', displayStatus);
483
+ output.labelValue('Attention', formatKnownBoolean(context.status?.needs_review));
484
+ output.labelValue('Evidence', formatCompactPage(context.evidence));
485
+ let items = context.evidence?.items || [];
486
+ if (items.length > 0) {
487
+ output.blank();
488
+ output.print(' Evidence');
489
+ for (let item of items) {
490
+ let result = item.result || item.status || 'unknown';
491
+ let percentage = getComparisonDiffPercentage(item);
492
+ let detail = percentage == null ? '' : ` · ${percentage}% diff`;
493
+ output.print(` ${colors.dim('•')} ${getComparisonName(item)}: ${result}${detail}`);
327
494
  }
328
- };
495
+ }
496
+ if (context.evidence?.page?.has_more) {
497
+ let next = context.suggested_commands?.find(command => command.label === 'Load next evidence page');
498
+ if (next) {
499
+ output.blank();
500
+ output.labelValue('Next', next.command);
501
+ }
502
+ }
503
+ if (context.links?.build_url) {
504
+ output.labelValue('Build URL', context.links.build_url);
505
+ }
329
506
  }
330
- function buildAgentBuildPayload(context, {
331
- source = null,
332
- include = [],
333
- evidenceLimit = 10
334
- } = {}) {
335
- let comparisons = context.comparisons || [];
336
- let includeSet = new Set(include);
337
- let includeDiffs = includeSet.has('diffs');
338
- let changed = comparisons.filter(isChangedComparison);
339
- let fresh = comparisons.filter(isNewComparison);
340
- let evidence = [...changed, ...fresh].slice(0, evidenceLimit).map(comparison => buildCompactComparison(comparison, {
341
- includeDiffs
342
- }));
343
- let comparisonSummary = summarizeComparisons(comparisons);
344
- let payload = {
345
- resource: 'build_agent_context',
346
- source: context.source || source || 'cloud',
347
- scope: context.scope || null,
348
- project: {
349
- organization: context.scope?.organization?.slug || null,
350
- slug: context.scope?.project?.slug || null,
351
- name: context.scope?.project?.name || null,
352
- visibility: context.scope?.project?.visibility || null
353
- },
354
- build: context.build || null,
355
- baseline: {
356
- selected: context.baseline?.selected || null,
357
- selection_reason: context.baseline?.selection_reason || null
358
- },
359
- status: formatAgentStatus(context.status),
360
- summary: formatAgentSummary(context, comparisons),
361
- evidence,
362
- links: context.links || {},
363
- preview: context.preview || null,
364
- next_actions: buildAgentNextActions(context, comparisonSummary)
365
- };
366
- if (includeSet.has('screenshots')) {
367
- payload.screenshots = context.screenshots || [];
507
+ function displayCompactComparisonContext(output, context) {
508
+ output.header('context', 'comparison');
509
+ let comparison = context.comparison || {};
510
+ let displayState = getComparisonDisplayState(comparison);
511
+ displayCompactHeading(output, context, getComparisonName(comparison), displayState);
512
+ output.labelValue('Images', comparison.diff?.image_url || comparison.analysis?.diff_image_url ? 'baseline/current/diff available' : 'unavailable');
513
+ output.labelValue('Similar history', formatCompactPage(context.history?.similar_by_fingerprint));
514
+ output.labelValue('Recent history', formatCompactPage(context.history?.recent_by_name));
515
+ let similar = context.history?.similar_by_fingerprint?.items || [];
516
+ if (similar.length > 0) {
517
+ output.blank();
518
+ output.print(' Similar Diffs');
519
+ printComparisonList(output, similar);
368
520
  }
369
- if (includeSet.has('comments')) {
370
- payload.comments = context.comments || {};
521
+ let recent = context.history?.recent_by_name?.items || [];
522
+ if (recent.length > 0) {
523
+ output.blank();
524
+ output.print(' Recent Diffs');
525
+ printComparisonList(output, recent);
526
+ }
527
+ if (context.links?.comparison_url) {
528
+ output.labelValue('Comparison URL', context.links.comparison_url);
371
529
  }
372
- return payload;
373
530
  }
374
531
  function getBuildCommentsCount(context = {}) {
375
532
  if (Array.isArray(context.comments?.build)) {
@@ -396,12 +553,17 @@ function formatNeedsReview(status = {}) {
396
553
  if (!status || status.needs_review == null) {
397
554
  return null;
398
555
  }
399
- let pending = status.pending_comparisons || 0;
400
- let unresolved = status.unresolved_comments || 0;
401
556
  if (!status.needs_review) {
402
557
  return 'no';
403
558
  }
404
- return `yes · ${pending} comparisons · ${unresolved} unresolved comments`;
559
+ let details = [];
560
+ if (status.pending_comparisons != null) {
561
+ details.push(`${status.pending_comparisons} comparisons`);
562
+ }
563
+ if (status.unresolved_comments != null) {
564
+ details.push(`${status.unresolved_comments} unresolved comments`);
565
+ }
566
+ return details.length > 0 ? `yes · ${details.join(' · ')}` : 'yes';
405
567
  }
406
568
  function formatConfirmedRegionLabels(regions = []) {
407
569
  return regions.map(region => region.label).filter(Boolean).slice(0, 3).join(' · ');
@@ -436,98 +598,132 @@ function printComparisonList(output, comparisons = [], {
436
598
  }
437
599
  }
438
600
  }
601
+ function getGroupDisplayState(group = {}) {
602
+ let aggregate = group.aggregate_status || {};
603
+ if ((aggregate.failed_count ?? 0) > 0) return 'failed';
604
+ if (aggregate.has_rejected === true) return 'rejected';
605
+ if (aggregate.has_changes === true) return 'changed';
606
+ if (aggregate.has_new === true) return 'new';
607
+ if (aggregate.needs_review === true) return 'needs review';
608
+ if (aggregate.all_approved === true) return 'approved';
609
+ return null;
610
+ }
611
+ function formatViewport(viewport) {
612
+ if (!viewport) {
613
+ return null;
614
+ }
615
+ return `${viewport.width ?? '?'}×${viewport.height ?? '?'}`;
616
+ }
617
+ function printScreenshotGroups(output, groups = [], {
618
+ limit = 8
619
+ } = {}) {
620
+ let colors = output.getColors();
621
+ let visibleGroups = groups.slice(0, limit);
622
+ output.print(' Screenshot groups');
623
+ for (let group of visibleGroups) {
624
+ let displayState = getGroupDisplayState(group);
625
+ let tone = getStatusTone(colors, displayState);
626
+ let aggregate = group.aggregate_status || {};
627
+ let details = [];
628
+ if (group.variant_count != null) {
629
+ details.push(`${group.variant_count} variants`);
630
+ }
631
+ if (aggregate.needs_review_count != null) {
632
+ details.push(`${aggregate.needs_review_count} needs review`);
633
+ }
634
+ if (aggregate.failed_count != null && aggregate.failed_count > 0) {
635
+ details.push(`${aggregate.failed_count} failed`);
636
+ }
637
+ if (aggregate.max_diff_percentage != null) {
638
+ details.push(`${aggregate.max_diff_percentage}% max diff`);
639
+ }
640
+ output.print(` ${colors.bold(group.name)}${displayState ? ` ${tone(displayState.toUpperCase())}` : ''}`);
641
+ if (details.length > 0) {
642
+ output.print(` ${colors.dim(details.join(' · '))}`);
643
+ }
644
+ for (let variant of group.variants.slice(0, 3)) {
645
+ let variantDetails = [variant.review_state, variant.browser, formatViewport(variant.viewport)].filter(Boolean);
646
+ output.print(` ${getStatusTone(colors, variant.result)((variant.result || 'unknown').toUpperCase())}${variantDetails.length > 0 ? ` · ${colors.dim(variantDetails.join(' · '))}` : ''}`);
647
+ }
648
+ }
649
+ if (groups.length > visibleGroups.length) {
650
+ output.print(` ${colors.dim(`...${groups.length - visibleGroups.length} more groups`)}`);
651
+ }
652
+ }
653
+ function printFailedCaptures(output, captures = []) {
654
+ let colors = output.getColors();
655
+ output.print(' Failed captures');
656
+ for (let capture of captures.slice(0, 8)) {
657
+ let details = [capture.error_message, capture.browser, formatViewport(capture.viewport), capture.screenshot.url].filter(Boolean);
658
+ output.print(` ${colors.bold(capture.name)} ${colors.brand.error('FAILED')}`);
659
+ if (details.length > 0) {
660
+ output.print(` ${colors.dim(details.join(' · '))}`);
661
+ }
662
+ }
663
+ }
664
+ function formatReviewSummary(review = {}) {
665
+ let details = [review.pending != null ? `${review.pending} pending` : null, review.approved != null ? `${review.approved} approved` : null, review.rejected != null ? `${review.rejected} rejected` : null].filter(Boolean);
666
+ return details.length > 0 ? details.join(' · ') : null;
667
+ }
439
668
  function displayBuildContext(output, context) {
440
669
  output.header('context', 'build');
670
+ let normalizedContext = normalizeBuildContext(context);
441
671
  let colors = output.getColors();
442
- let buildTone = getStatusTone(colors, context.build.status);
443
- let comparisons = context.comparisons || [];
444
- let screenshots = context.screenshots || [];
445
- let reviewSummary = context.summary?.review || {};
446
- let commentsSummary = context.summary?.comments || {};
447
- let needsReview = formatNeedsReview(context.status);
448
- let baseline = context.baseline?.selected || null;
449
- output.print(` ${colors.bold(context.build.name || context.build.id)} ${buildTone((context.build.status || 'unknown').toUpperCase())}`);
450
- output.print(` ${colors.dim(`@${context.scope.organization.slug}/${context.scope.project.slug}`)}`);
672
+ let build = normalizedContext.build || {};
673
+ let buildTone = getStatusTone(colors, build.status);
674
+ let comparisons = normalizedContext.comparisons;
675
+ let groups = normalizedContext.groups;
676
+ let screenshots = normalizedContext.screenshots || [];
677
+ let failedCaptures = normalizedContext.failed_captures;
678
+ let reviewSummary = formatReviewSummary(normalizedContext.summary?.review);
679
+ let commentsSummary = normalizedContext.summary?.comments || {};
680
+ let needsReview = formatNeedsReview(normalizedContext.status);
681
+ let baseline = normalizedContext.baseline?.selected || null;
682
+ let comparisonCount = normalizedContext.summary?.comparisons?.total ?? normalizedContext.total_comparisons ?? (Array.isArray(context.comparisons) ? comparisons.length : null);
683
+ let screenshotCount = normalizedContext.summary?.screenshots?.total ?? normalizedContext.screenshot_count ?? (Array.isArray(context.screenshots) ? screenshots.length : null);
684
+ output.print(` ${colors.bold(build.name || build.id || 'unknown build')} ${buildTone((build.status || 'unknown').toUpperCase())}`);
685
+ output.print(` ${colors.dim(`@${normalizedContext.scope?.organization?.slug || 'unknown'}/${normalizedContext.scope?.project?.slug || 'unknown'}`)}`);
451
686
  output.blank();
452
- output.labelValue('Comparisons', String(comparisons.length));
453
- if (screenshots.length > 0) {
454
- output.labelValue('Screenshots', String(screenshots.length));
687
+ if (comparisonCount != null) {
688
+ output.labelValue('Comparisons', String(comparisonCount));
689
+ }
690
+ if (groups.length > 0) {
691
+ output.labelValue('Screenshot Groups', String(groups.length));
692
+ }
693
+ if (screenshotCount != null) {
694
+ output.labelValue('Screenshots', String(screenshotCount));
455
695
  }
456
696
  if (baseline) {
457
- output.labelValue('Baseline', `${baseline.name || baseline.id || 'selected'}${context.baseline.selection_reason ? ` · ${context.baseline.selection_reason}` : ''}`);
697
+ output.labelValue('Baseline', `${baseline.name || baseline.id || 'selected'}${normalizedContext.baseline.selection_reason ? ` · ${normalizedContext.baseline.selection_reason}` : ''}`);
458
698
  }
459
699
  if (needsReview) {
460
700
  output.labelValue('Needs Review', needsReview);
461
701
  }
462
- output.labelValue('Review', `${reviewSummary.pending || 0} pending · ${reviewSummary.approved || 0} approved · ${reviewSummary.rejected || 0} rejected`);
463
- output.labelValue('Memory', `${commentsSummary.build ?? getBuildCommentsCount(context)} build comments · ${commentsSummary.screenshot ?? getScreenshotCommentsCount(context)} screenshot comments · ${getReviewAssignmentsCount(context)} assignments`);
464
- if (context.preview) {
465
- let previewUrl = context.preview.preview_url || context.preview.url;
466
- output.labelValue('Preview', `${context.preview.status || 'unknown'}${previewUrl ? ' · available' : ''}`);
467
- }
468
- if (context.links?.build_url) {
469
- output.labelValue('Build URL', context.links.build_url);
470
- }
471
- if (comparisons.length > 0) {
472
- output.blank();
473
- output.print(' Comparisons');
474
- printComparisonList(output, comparisons);
702
+ if (reviewSummary) {
703
+ output.labelValue('Review', reviewSummary);
475
704
  }
476
- }
477
- function formatAgentBuildContext(context) {
478
- let comparisons = context.comparisons || [];
479
- let changed = comparisons.filter(isChangedComparison);
480
- let fresh = comparisons.filter(isNewComparison);
481
- let needsReview = comparisons.filter(comparison => comparison.needs_review);
482
- let baseline = context.baseline?.selected;
483
- let lines = [`# Vizzly Visual Context: ${context.build?.name || context.build?.id || 'Build'}`, '', `Project: ${context.scope?.organization?.slug || 'unknown'}/${context.scope?.project?.slug || 'unknown'}`, `Build: ${context.build?.id || 'unknown'} (${context.build?.status || 'unknown'})`];
484
- if (baseline) {
485
- lines.push(`Approved baseline: ${baseline.name || baseline.id || 'selected'} (${baseline.approval_status || 'unknown'})`);
705
+ let buildComments = commentsSummary.build ?? (Array.isArray(normalizedContext.comments?.build) ? getBuildCommentsCount(normalizedContext) : null);
706
+ let screenshotComments = commentsSummary.screenshot ?? (Number.isInteger(normalizedContext.comments?.screenshot_count) ? getScreenshotCommentsCount(normalizedContext) : null);
707
+ let assignments = Array.isArray(normalizedContext.review?.assignments) ? getReviewAssignmentsCount(normalizedContext) : null;
708
+ let memory = [buildComments != null ? `${buildComments} build comments` : null, screenshotComments != null ? `${screenshotComments} screenshot comments` : null, assignments != null ? `${assignments} assignments` : null].filter(Boolean);
709
+ if (memory.length > 0) {
710
+ output.labelValue('Memory', memory.join(' · '));
486
711
  }
487
- if (context.status) {
488
- lines.push(`Needs review: ${context.status.needs_review ? 'yes' : 'no'} (${context.status.pending_comparisons || 0} pending comparisons)`);
712
+ if (normalizedContext.preview) {
713
+ let previewUrl = normalizedContext.preview.preview_url || normalizedContext.preview.url;
714
+ output.labelValue('Preview', `${normalizedContext.preview.status || 'unknown'}${previewUrl ? ' · available' : ''}`);
489
715
  }
490
- if (context.preview?.url || context.preview?.preview_url) {
491
- lines.push(`Preview: ${context.preview.url || context.preview.preview_url}`);
716
+ if (normalizedContext.links?.build_url) {
717
+ output.labelValue('Build URL', normalizedContext.links.build_url);
492
718
  }
493
- if (context.links?.build_url) {
494
- lines.push(`Build URL: ${context.links.build_url}`);
495
- }
496
- if (context.links?.report_url) {
497
- lines.push(`Report: ${context.links.report_url}`);
498
- }
499
- lines.push('');
500
- lines.push('## Diff Summary');
501
- lines.push(`- Total comparisons: ${comparisons.length}`);
502
- lines.push(`- Changed: ${changed.length}`);
503
- lines.push(`- New: ${fresh.length}`);
504
- lines.push(`- Needs review: ${needsReview.length}`);
505
- if (changed.length > 0 || fresh.length > 0) {
506
- lines.push('');
507
- lines.push('## Evidence To Inspect');
508
- for (let comparison of [...changed, ...fresh].slice(0, 10)) {
509
- let diffPercentage = getComparisonDiffPercentage(comparison);
510
- let detail = diffPercentage == null ? '' : ` · ${diffPercentage}% diff`;
511
- let diffUrl = comparison.diff?.image_url || comparison.analysis?.diff_image_url;
512
- lines.push(`- ${getComparisonName(comparison)}: ${getComparisonDisplayState(comparison)}${detail}`);
513
- if (diffUrl) {
514
- lines.push(` Diff: ${diffUrl}`);
515
- }
516
- }
719
+ if (groups.length > 0) {
720
+ output.blank();
721
+ printScreenshotGroups(output, groups);
517
722
  }
518
- if (comparisons.length > 0 && changed.length === 0 && fresh.length === 0) {
519
- lines.push('');
520
- lines.push('## Reviewed Screenshots');
521
- for (let comparison of comparisons.slice(0, 10)) {
522
- lines.push(`- ${getComparisonName(comparison)}: ${getComparisonDisplayState(comparison)}`);
523
- }
524
- if (comparisons.length > 10) {
525
- lines.push(`- ...${comparisons.length - 10} more`);
526
- }
723
+ if (failedCaptures.length > 0) {
724
+ output.blank();
725
+ printFailedCaptures(output, failedCaptures);
527
726
  }
528
- lines.push('');
529
- lines.push('Use this as reviewed UI context. Treat approved baselines as visual truth, inspect meaningful diffs, and leave approval decisions to humans.');
530
- return lines.join('\n');
531
727
  }
532
728
  function countScreenshotCommentEntries(groups = []) {
533
729
  return groups.reduce((total, group) => total + (group.comments?.length || 0), 0);
@@ -631,14 +827,23 @@ export async function contextBuildCommand(buildId, options = {}, globalOptions =
631
827
  return;
632
828
  }
633
829
  let resolvedBuildId = resolveBuildContextId(buildId, runtime, deps);
830
+ let {
831
+ compact,
832
+ details,
833
+ include,
834
+ query
835
+ } = buildCompactContextRequest(options, globalOptions);
634
836
  output.startSpinner('Fetching build context...');
635
- let context = await runtime.provider.getBuildContext(resolvedBuildId);
837
+ let context = await runtime.provider.getBuildContext(resolvedBuildId, query);
636
838
  output.stopSpinner();
637
- if (globalOptions.json && options.agent && !options.full) {
638
- output.data(buildAgentBuildPayload(context, {
639
- source: runtime.source,
640
- include: parseIncludeOption(options.include)
641
- }));
839
+ if (compact) {
840
+ let compactContext = validateCompactContext(context, 'build');
841
+ let payload = validateCompactOutputSize(buildCompactBuildPayload(compactContext, include, options.cursor), 'build', details);
842
+ if (globalOptions.json) {
843
+ output.data(payload);
844
+ } else {
845
+ displayCompactBuildContext(output, payload);
846
+ }
642
847
  output.cleanup();
643
848
  return;
644
849
  }
@@ -647,11 +852,6 @@ export async function contextBuildCommand(buildId, options = {}, globalOptions =
647
852
  output.cleanup();
648
853
  return;
649
854
  }
650
- if (options.agent) {
651
- output.print(formatAgentBuildContext(context));
652
- output.cleanup();
653
- return;
654
- }
655
855
  displayBuildContext(output, context);
656
856
  output.cleanup();
657
857
  } catch (error) {
@@ -680,14 +880,26 @@ export async function contextComparisonCommand(comparisonId, options = {}, globa
680
880
  if (!runtime) {
681
881
  return;
682
882
  }
683
- let query = {
684
- similarLimit: options.similarLimit,
685
- recentLimit: options.recentLimit,
686
- windowSize: options.windowSize
687
- };
883
+ let {
884
+ compact,
885
+ details,
886
+ include,
887
+ query
888
+ } = buildCompactContextRequest(options, globalOptions);
688
889
  output.startSpinner('Fetching comparison context...');
689
890
  let context = await runtime.provider.getComparisonContext(comparisonId, query);
690
891
  output.stopSpinner();
892
+ if (compact) {
893
+ let compactContext = validateCompactContext(context, 'comparison');
894
+ let payload = validateCompactOutputSize(buildCompactComparisonPayload(compactContext, include), 'comparison', details);
895
+ if (globalOptions.json) {
896
+ output.data(payload);
897
+ } else {
898
+ displayCompactComparisonContext(output, payload);
899
+ }
900
+ output.cleanup();
901
+ return;
902
+ }
691
903
  if (globalOptions.json) {
692
904
  output.data(context);
693
905
  output.cleanup();
@@ -824,20 +1036,14 @@ export async function contextReviewQueueCommand(options = {}, globalOptions = {}
824
1036
  export function validateContextBuildOptions(_options = {}) {
825
1037
  let errors = validateSourceOption(_options.source);
826
1038
  errors.push(...validateIncludeOption(_options.include));
1039
+ errors.push(...validateCursor(_options.cursor));
827
1040
  return errors;
828
1041
  }
829
1042
  export function validateContextComparisonOptions(options = {}) {
830
1043
  let errors = [];
831
1044
  errors.push(...validateSourceOption(options.source));
832
- errors.push(...validateLimitRange(options.similarLimit, '--similar-limit', {
833
- max: 50
834
- }));
835
- errors.push(...validateLimitRange(options.recentLimit, '--recent-limit', {
836
- max: 50
837
- }));
838
- errors.push(...validateLimitRange(options.windowSize, '--window-size', {
839
- max: 50
840
- }));
1045
+ errors.push(...validateIncludeOption(options.include));
1046
+ errors.push(...validateCursor(options.cursor));
841
1047
  return errors;
842
1048
  }
843
1049
  export function validateContextScreenshotOptions(options = {}) {