@vizzly-testing/cli 0.35.3-beta.0 → 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.
@@ -264,12 +264,4 @@ export async function doctorCommand(options = {}, globalOptions = {}, deps = {})
264
264
  output.cleanup();
265
265
  if (hasErrors) exit(1);
266
266
  }
267
- }
268
-
269
- /**
270
- * Validate doctor options (no specific validation needed)
271
- * @param {Object} options - Command options
272
- */
273
- export function validateDoctorOptions() {
274
- return []; // No specific validation for now
275
267
  }
@@ -37,7 +37,7 @@ function configureOutput(output, options) {
37
37
  export function getInitConfigPath(cwd = process.cwd()) {
38
38
  return path.join(cwd, 'vizzly.config.js');
39
39
  }
40
- export function getPackagedAgentSkillPath(baseDir = commandDir) {
40
+ function getPackagedAgentSkillPath(baseDir = commandDir) {
41
41
  return path.resolve(baseDir, '..', '..', 'skills', 'vizzly');
42
42
  }
43
43
  export function getProjectAgentSkillPath(cwd = process.cwd()) {
@@ -54,10 +54,10 @@ export async function fileExists(filePath, access = fs.access) {
54
54
  return false;
55
55
  }
56
56
  }
57
- export function getPluginsWithConfig(plugins = []) {
57
+ function getPluginsWithConfig(plugins = []) {
58
58
  return plugins.filter(plugin => plugin.configSchema);
59
59
  }
60
- export function getPluginConfigNames(plugins = []) {
60
+ function getPluginConfigNames(plugins = []) {
61
61
  return getPluginsWithConfig(plugins).map(plugin => plugin.name);
62
62
  }
63
63
  function formatObjectKey(key) {
@@ -115,7 +115,7 @@ export function formatPluginConfig(plugin, output = null) {
115
115
  return '';
116
116
  }
117
117
  }
118
- export function generatePluginConfigs(plugins = [], output = null) {
118
+ function generatePluginConfigs(plugins = [], output = null) {
119
119
  let sections = [];
120
120
  for (let plugin of plugins) {
121
121
  if (plugin.configSchema) {
@@ -212,7 +212,7 @@ async function shouldInstallAgentSkill(options, deps) {
212
212
  }
213
213
  let agentGuidanceStart = '<!-- vizzly-agent-guidance -->';
214
214
  let agentGuidanceEnd = '<!-- /vizzly-agent-guidance -->';
215
- export function createAgentGuidanceContent() {
215
+ function createAgentGuidanceContent() {
216
216
  return `${agentGuidanceStart}
217
217
  ## Visual Testing With Vizzly
218
218
 
@@ -232,7 +232,7 @@ function appendSection(content, section) {
232
232
  }
233
233
  return `${content.replace(/\s*$/, '')}\n\n${section}`;
234
234
  }
235
- export async function upsertProjectAgentGuidance({
235
+ async function upsertProjectAgentGuidance({
236
236
  cwd,
237
237
  access = fs.access,
238
238
  readFile = fs.readFile,
@@ -362,16 +362,11 @@ async function loadInitPlugins(options, deps) {
362
362
  }
363
363
  try {
364
364
  let config = await deps.loadConfig(options.config, {});
365
- return await deps.loadPlugins(options.config, config, null);
365
+ return await deps.loadPlugins(options.config, config);
366
366
  } catch {
367
367
  return [];
368
368
  }
369
369
  }
370
-
371
- // Export factory function for CLI
372
- export function createInitCommand(options) {
373
- return () => init(options);
374
- }
375
370
  export async function init(options = {}, deps = {}) {
376
371
  let resolvedDeps = createInitDeps(deps);
377
372
  configureOutput(resolvedDeps.output, options);
@@ -236,17 +236,4 @@ export async function loginCommand(options = {}, globalOptions = {}, deps = {})
236
236
  exit(1);
237
237
  }
238
238
  }
239
- }
240
-
241
- /**
242
- * Validate login options
243
- * @param {Object} options - Command options
244
- */
245
- export function validateLoginOptions() {
246
- let errors = [];
247
-
248
- // No specific validation needed for login command
249
- // OAuth device flow handles everything via browser
250
-
251
- return errors;
252
239
  }
@@ -70,16 +70,4 @@ export async function logoutCommand(options = {}, globalOptions = {}, deps = {})
70
70
  output.cleanup();
71
71
  exit(1);
72
72
  }
73
- }
74
-
75
- /**
76
- * Validate logout options
77
- * @param {Object} options - Command options
78
- */
79
- export function validateLogoutOptions() {
80
- let errors = [];
81
-
82
- // No specific validation needed for logout command
83
-
84
- return errors;
85
73
  }
@@ -82,13 +82,4 @@ export async function orgsCommand(_options = {}, globalOptions = {}, deps = {})
82
82
  output.cleanup();
83
83
  exit(1);
84
84
  }
85
- }
86
-
87
- /**
88
- * Validate orgs options
89
- * @param {Object} _options - Command options
90
- * @returns {string[]} Validation errors
91
- */
92
- export function validateOrgsOptions(_options = {}) {
93
- return [];
94
85
  }
@@ -182,16 +182,4 @@ export async function whoamiCommand(options = {}, globalOptions = {}, deps = {})
182
182
  exit(1);
183
183
  }
184
184
  }
185
- }
186
-
187
- /**
188
- * Validate whoami options
189
- * @param {Object} options - Command options
190
- */
191
- export function validateWhoamiOptions() {
192
- let errors = [];
193
-
194
- // No specific validation needed for whoami command
195
-
196
- return errors;
197
185
  }
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { existsSync as defaultExistsSync, readFileSync } from 'node:fs';
2
3
  import { basename, isAbsolute, join } from 'node:path';
3
4
  import { pathToFileURL } from 'node:url';
@@ -204,31 +205,6 @@ function buildReviewState(build, reviewSummary) {
204
205
  unresolved_comments: 0
205
206
  };
206
207
  }
207
- function mapLocalScreenshot(snapshot, comparison) {
208
- let mapped = mapLocalComparison(snapshot, comparison);
209
- let baselineBuildId = snapshot.baselineMetadata?.buildId || null;
210
- return {
211
- id: mapped.screenshot.id,
212
- name: mapped.screenshot.name,
213
- browser: mapped.screenshot.browser,
214
- viewport: {
215
- width: mapped.screenshot.viewport_width,
216
- height: mapped.screenshot.viewport_height
217
- },
218
- url: mapped.screenshot.original_url,
219
- baseline: mapped.baseline ? {
220
- id: mapped.baseline.id,
221
- build_id: baselineBuildId,
222
- name: mapped.baseline.name,
223
- browser: mapped.baseline.browser,
224
- viewport: {
225
- width: mapped.baseline.viewport_width,
226
- height: mapped.baseline.viewport_height
227
- },
228
- url: mapped.baseline.original_url
229
- } : null
230
- };
231
- }
232
208
  function mapLocalComparison(snapshot, comparison) {
233
209
  let details = snapshot.comparisonDetails[comparison.id] || {};
234
210
  let comparisonName = comparison.originalName || comparison.name;
@@ -306,6 +282,141 @@ function mapLocalComparison(snapshot, comparison) {
306
282
  }
307
283
  };
308
284
  }
285
+ function projectLocalScreenshot(screenshot, baseline) {
286
+ if (!screenshot) return null;
287
+ return {
288
+ id: screenshot.id,
289
+ name: screenshot.name,
290
+ browser: screenshot.browser,
291
+ viewport: {
292
+ width: screenshot.viewport_width,
293
+ height: screenshot.viewport_height
294
+ },
295
+ url: screenshot.original_url,
296
+ baseline: baseline ? {
297
+ id: baseline.id,
298
+ build_id: baseline.build_id,
299
+ name: baseline.name,
300
+ browser: baseline.browser,
301
+ viewport: {
302
+ width: baseline.viewport_width,
303
+ height: baseline.viewport_height
304
+ },
305
+ url: baseline.original_url
306
+ } : null
307
+ };
308
+ }
309
+ function projectLocalDiff(diff, includeDiffs) {
310
+ let projected = {
311
+ percentage: diff?.percentage ?? null,
312
+ changed_pixels: diff?.changed_pixels ?? null,
313
+ total_pixels: diff?.total_pixels ?? null,
314
+ threshold: diff?.threshold ?? null,
315
+ image_url: diff?.image_url ?? null,
316
+ fingerprint_hash: diff?.fingerprint_hash ?? null,
317
+ region_count: Array.isArray(diff?.regions) ? diff.regions.length : null
318
+ };
319
+ if (includeDiffs) {
320
+ projected.regions = diff?.regions || [];
321
+ projected.cluster_metadata = diff?.cluster_metadata ?? null;
322
+ projected.fingerprint_data = diff?.fingerprint_data ?? null;
323
+ projected.diff_lines = diff?.diff_lines ?? [];
324
+ }
325
+ return projected;
326
+ }
327
+ function projectLocalEvidence(comparison, includeDiffs) {
328
+ return {
329
+ type: 'comparison',
330
+ id: comparison.id,
331
+ screenshot_name: comparison.screenshot_name,
332
+ status: comparison.status,
333
+ result: comparison.result,
334
+ approval_status: comparison.approval_status,
335
+ needs_review: comparison.needs_review,
336
+ build_id: comparison.build_id,
337
+ build_name: comparison.build_name,
338
+ build_branch: comparison.build_branch,
339
+ build_commit_sha: comparison.build_commit_sha,
340
+ build_created_at: comparison.build_created_at,
341
+ screenshot: projectLocalScreenshot(comparison.screenshot, comparison.baseline),
342
+ diff: projectLocalDiff(comparison.diff, includeDiffs)
343
+ };
344
+ }
345
+ function projectLocalFocusedComparison(comparison, includeDiffs) {
346
+ if (includeDiffs) return comparison;
347
+ let analysis = comparison.analysis || {};
348
+ let {
349
+ diff_regions: _diffRegions,
350
+ cluster_metadata: _clusterMetadata,
351
+ fingerprint_data: _fingerprintData,
352
+ diff_lines: _diffLines,
353
+ ...summaryAnalysis
354
+ } = analysis;
355
+ return {
356
+ ...comparison,
357
+ diff: projectLocalDiff(comparison.diff, false),
358
+ analysis: summaryAnalysis
359
+ };
360
+ }
361
+ function projectLocalHistoryItem(comparison) {
362
+ return {
363
+ id: comparison.id,
364
+ screenshot_name: comparison.screenshot_name,
365
+ result: comparison.result,
366
+ status: comparison.status,
367
+ needs_review: comparison.needs_review,
368
+ build_id: comparison.build_id,
369
+ build_name: comparison.build_name,
370
+ build_branch: comparison.build_branch,
371
+ build_created_at: comparison.build_created_at,
372
+ screenshot: projectLocalScreenshot(comparison.screenshot, comparison.baseline),
373
+ diff: {
374
+ percentage: comparison.diff?.percentage ?? null,
375
+ fingerprint_hash: comparison.diff?.fingerprint_hash ?? null,
376
+ region_count: Array.isArray(comparison.diff?.regions) ? comparison.diff.regions.length : null
377
+ }
378
+ };
379
+ }
380
+ function compactLocalCollection(items) {
381
+ return {
382
+ total: Array.isArray(items) ? items.length : null,
383
+ included: false,
384
+ details_available: Array.isArray(items) && items.length > 0
385
+ };
386
+ }
387
+ function createLocalDynamicRegions(comparison = null) {
388
+ let confirmedRegions = comparison?.analysis?.confirmed_regions;
389
+ let hotspotAnalysis = comparison?.analysis?.hotspot_analysis;
390
+ return {
391
+ decision: null,
392
+ patterns: compactLocalCollection([]),
393
+ confirmed_regions: compactLocalCollection(confirmedRegions),
394
+ exclusions: {
395
+ total: null,
396
+ included: false,
397
+ details_available: false
398
+ },
399
+ hotspot_analysis: hotspotAnalysis ? {
400
+ total_builds_analyzed: hotspotAnalysis.total_builds_analyzed ?? null,
401
+ confidence: hotspotAnalysis.confidence ?? null,
402
+ confidence_score: hotspotAnalysis.confidence_score ?? null,
403
+ data_source: hotspotAnalysis.data_source ?? null,
404
+ coverage: hotspotAnalysis.coverage ?? null,
405
+ confirmed_region_coverage: hotspotAnalysis.confirmed_region_coverage ?? null
406
+ } : null
407
+ };
408
+ }
409
+ function createLocalSnapshotRevision(snapshot) {
410
+ let revisionInput = {
411
+ serverInfo: snapshot.serverInfo,
412
+ reportData: snapshot.reportData,
413
+ comparisonDetails: snapshot.comparisonDetails,
414
+ baselineMetadata: snapshot.baselineMetadata,
415
+ hotspotFile: snapshot.hotspotFile,
416
+ regionFile: snapshot.regionFile
417
+ };
418
+ return createHash('sha256').update(JSON.stringify(revisionInput)).digest('base64url');
419
+ }
309
420
  function buildReviewSummary(comparisons = []) {
310
421
  let approved = comparisons.filter(comparison => mapApprovalStatus(comparison.status) === 'approved').length;
311
422
  let rejected = comparisons.filter(comparison => mapApprovalStatus(comparison.status) === 'rejected').length;
@@ -405,17 +516,66 @@ export function createLocalWorkspaceContextProvider(options = {}, deps = {}) {
405
516
  } : {})
406
517
  };
407
518
  }
408
- function getBuildContext(buildId) {
519
+ function createLocalCursor(resource, target, query, stream, offset, revision) {
520
+ return Buffer.from(JSON.stringify({
521
+ version: 1,
522
+ resource,
523
+ target,
524
+ query,
525
+ stream,
526
+ offset,
527
+ revision
528
+ })).toString('base64url');
529
+ }
530
+ function readLocalCursor(cursor, resource, target, query, revision, streams) {
531
+ if (!cursor) return null;
532
+ try {
533
+ let parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString());
534
+ if (parsed.version !== 1 || parsed.resource !== resource || parsed.target !== target || parsed.query !== query || parsed.revision !== revision || !streams.includes(parsed.stream) || !Number.isInteger(parsed.offset) || parsed.offset < 0) {
535
+ throw new Error('mismatch');
536
+ }
537
+ return parsed;
538
+ } catch {
539
+ throw createLocalWorkspaceError('The local context cursor is invalid or the local results changed. Start again without --cursor.');
540
+ }
541
+ }
542
+ function createLocalPage(items, {
543
+ limit,
544
+ offset,
545
+ resource,
546
+ target,
547
+ query,
548
+ stream,
549
+ revision
550
+ }) {
551
+ if (offset > items.length) {
552
+ throw createLocalWorkspaceError('The local context cursor is invalid or the local results changed. Start again without --cursor.');
553
+ }
554
+ let pageItems = items.slice(offset, offset + limit);
555
+ let nextOffset = offset + pageItems.length;
556
+ let hasMore = nextOffset < items.length;
557
+ return {
558
+ items: pageItems,
559
+ page: {
560
+ limit,
561
+ returned: pageItems.length,
562
+ total: items.length,
563
+ has_more: hasMore,
564
+ next_cursor: hasMore ? createLocalCursor(resource, target, query, stream, nextOffset, revision) : null
565
+ }
566
+ };
567
+ }
568
+ function getBuildContext(buildId, query = {}) {
409
569
  let snapshot = loadSnapshot();
410
570
  let resolvedBuild = buildBuildSnapshot(snapshot);
411
571
  if (!(buildId === 'current' || buildId === 'local' || buildId === resolvedBuild.id)) {
412
572
  throw createLocalWorkspaceError(`Local workspace context is only available for the active local build (${resolvedBuild.id})`);
413
573
  }
414
574
  let mappedComparisons = snapshot.reportData.comparisons.map(comparison => mapLocalComparison(snapshot, comparison));
415
- let mappedScreenshots = snapshot.reportData.comparisons.map(comparison => mapLocalScreenshot(snapshot, comparison));
575
+ let mappedScreenshots = mappedComparisons.map(comparison => projectLocalScreenshot(comparison.screenshot, comparison.baseline));
416
576
  let reviewSummary = buildReviewSummary(snapshot.reportData.comparisons);
417
577
  let reviewState = buildReviewState(resolvedBuild, reviewSummary);
418
- return {
578
+ let context = {
419
579
  resource: 'build_context',
420
580
  source: LOCAL_CONTEXT_SOURCE,
421
581
  scope: createScope(),
@@ -446,8 +606,48 @@ export function createLocalWorkspaceContextProvider(options = {}, deps = {}) {
446
606
  },
447
607
  links: createBuildLinks(snapshot)
448
608
  };
609
+ if (!query.details) return context;
610
+ let revision = createLocalSnapshotRevision(snapshot);
611
+ let limit = query.limit || 10;
612
+ let cursorQuery = `${query.details}:${limit}`;
613
+ let cursor = readLocalCursor(query.cursor, 'build_context', resolvedBuild.id, cursorQuery, revision, ['evidence']);
614
+ let includeDiffs = query.details === 'diffs';
615
+ let evidence = mappedComparisons.map(comparison => projectLocalEvidence(comparison, includeDiffs));
616
+ return {
617
+ resource: 'build_context',
618
+ source: LOCAL_CONTEXT_SOURCE,
619
+ scope: context.scope,
620
+ build: context.build,
621
+ baseline: context.baseline,
622
+ status: context.status,
623
+ summary: context.summary,
624
+ preview: null,
625
+ signature_properties: [],
626
+ dynamic_regions: {
627
+ exclusions: {
628
+ total: null,
629
+ included: false,
630
+ details_available: false
631
+ },
632
+ item_details_included: false
633
+ },
634
+ evidence: createLocalPage(evidence, {
635
+ limit,
636
+ offset: cursor?.offset || 0,
637
+ resource: 'build_context',
638
+ target: resolvedBuild.id,
639
+ query: cursorQuery,
640
+ stream: 'evidence',
641
+ revision
642
+ }),
643
+ comments: {
644
+ build: compactLocalCollection(context.comments.build),
645
+ screenshot_count: context.comments.screenshot_count
646
+ },
647
+ links: context.links
648
+ };
449
649
  }
450
- function getComparisonContext(comparisonId) {
650
+ function getComparisonContext(comparisonId, query = {}) {
451
651
  let snapshot = loadSnapshot();
452
652
  let comparison = findComparison(snapshot, comparisonId);
453
653
  if (!comparison) {
@@ -456,7 +656,7 @@ export function createLocalWorkspaceContextProvider(options = {}, deps = {}) {
456
656
  let mappedComparison = mapLocalComparison(snapshot, comparison);
457
657
  let comparisonName = comparison.originalName || comparison.name;
458
658
  let history = snapshot.reportData.comparisons.filter(candidate => candidate.id !== comparison.id && (candidate.originalName || candidate.name) === comparisonName).map(candidate => mapLocalComparison(snapshot, candidate));
459
- return {
659
+ let context = {
460
660
  resource: 'comparison_context',
461
661
  source: LOCAL_CONTEXT_SOURCE,
462
662
  scope: createScope(),
@@ -485,6 +685,52 @@ export function createLocalWorkspaceContextProvider(options = {}, deps = {}) {
485
685
  },
486
686
  links: buildComparisonLinks(snapshot, comparison.id)
487
687
  };
688
+ if (!query.details) return context;
689
+ let revision = createLocalSnapshotRevision(snapshot);
690
+ let limit = query.limit || 10;
691
+ let cursorQuery = `${query.details}:${limit}`;
692
+ let cursor = readLocalCursor(query.cursor, 'comparison_context', comparison.id, cursorQuery, revision, ['similar_by_fingerprint', 'recent_by_name']);
693
+ let activeStream = cursor?.stream;
694
+ let similarOffset = activeStream === 'similar_by_fingerprint' ? cursor.offset : 0;
695
+ let recentOffset = activeStream === 'recent_by_name' ? cursor.offset : 0;
696
+ return {
697
+ resource: 'comparison_context',
698
+ source: LOCAL_CONTEXT_SOURCE,
699
+ scope: context.scope,
700
+ build: context.build,
701
+ signature_properties: [],
702
+ comparison: projectLocalFocusedComparison(context.comparison, query.details === 'diffs'),
703
+ dynamic_regions: createLocalDynamicRegions(context.comparison),
704
+ history: {
705
+ active_stream: activeStream || null,
706
+ similar_by_fingerprint: createLocalPage([], {
707
+ limit,
708
+ offset: similarOffset,
709
+ resource: 'comparison_context',
710
+ target: comparison.id,
711
+ query: cursorQuery,
712
+ stream: 'similar_by_fingerprint',
713
+ revision
714
+ }),
715
+ recent_by_name: createLocalPage(history.map(projectLocalHistoryItem), {
716
+ limit,
717
+ offset: recentOffset,
718
+ resource: 'comparison_context',
719
+ target: comparison.id,
720
+ query: cursorQuery,
721
+ stream: 'recent_by_name',
722
+ revision
723
+ })
724
+ },
725
+ review: {
726
+ review_summary: context.review.review_summary,
727
+ assignments: compactLocalCollection(context.review.assignments),
728
+ build_comments: compactLocalCollection(context.review.build_comments),
729
+ screenshot_comments: compactLocalCollection(context.review.screenshot_comments)
730
+ },
731
+ links: context.links,
732
+ details: query.details
733
+ };
488
734
  }
489
735
  function getScreenshotContext(screenshotName) {
490
736
  let snapshot = loadSnapshot();
@@ -55,7 +55,7 @@ export async function loadPlugins(configPath, config) {
55
55
  * Discover installed plugins from node_modules/@vizzly-testing/*
56
56
  * @returns {Promise<Array>} Array of plugin info objects
57
57
  */
58
- export async function discoverInstalledPlugins() {
58
+ async function discoverInstalledPlugins() {
59
59
  let plugins = [];
60
60
  try {
61
61
  // Find all @vizzly-testing packages