@vizzly-testing/cli 0.35.3-beta.0 → 0.35.3-beta.2

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
  }
@@ -23,6 +23,7 @@ function createInitDeps(deps = {}) {
23
23
  output: deps.output || output,
24
24
  promptAgentSkill: deps.promptAgentSkill || promptAgentSkill,
25
25
  readFile: deps.readFile || fs.readFile,
26
+ remove: deps.remove || fs.rm,
26
27
  skillSourcePath: deps.skillSourcePath || getPackagedAgentSkillPath(),
27
28
  writeFile: deps.writeFile || fs.writeFile
28
29
  };
@@ -37,7 +38,7 @@ function configureOutput(output, options) {
37
38
  export function getInitConfigPath(cwd = process.cwd()) {
38
39
  return path.join(cwd, 'vizzly.config.js');
39
40
  }
40
- export function getPackagedAgentSkillPath(baseDir = commandDir) {
41
+ function getPackagedAgentSkillPath(baseDir = commandDir) {
41
42
  return path.resolve(baseDir, '..', '..', 'skills', 'vizzly');
42
43
  }
43
44
  export function getProjectAgentSkillPath(cwd = process.cwd()) {
@@ -54,10 +55,10 @@ export async function fileExists(filePath, access = fs.access) {
54
55
  return false;
55
56
  }
56
57
  }
57
- export function getPluginsWithConfig(plugins = []) {
58
+ function getPluginsWithConfig(plugins = []) {
58
59
  return plugins.filter(plugin => plugin.configSchema);
59
60
  }
60
- export function getPluginConfigNames(plugins = []) {
61
+ function getPluginConfigNames(plugins = []) {
61
62
  return getPluginsWithConfig(plugins).map(plugin => plugin.name);
62
63
  }
63
64
  function formatObjectKey(key) {
@@ -115,7 +116,7 @@ export function formatPluginConfig(plugin, output = null) {
115
116
  return '';
116
117
  }
117
118
  }
118
- export function generatePluginConfigs(plugins = [], output = null) {
119
+ function generatePluginConfigs(plugins = [], output = null) {
119
120
  let sections = [];
120
121
  for (let plugin of plugins) {
121
122
  if (plugin.configSchema) {
@@ -212,17 +213,21 @@ async function shouldInstallAgentSkill(options, deps) {
212
213
  }
213
214
  let agentGuidanceStart = '<!-- vizzly-agent-guidance -->';
214
215
  let agentGuidanceEnd = '<!-- /vizzly-agent-guidance -->';
215
- export function createAgentGuidanceContent() {
216
+ function createAgentGuidanceContent() {
217
+ let skillPath = '.agents/skills/vizzly/SKILL.md';
216
218
  return `${agentGuidanceStart}
217
219
  ## Visual Testing With Vizzly
218
220
 
219
- Use Vizzly for user-facing UI changes when this repo has Vizzly configured.
221
+ For user-facing changes, use the repo-local Vizzly skill at
222
+ \`${skillPath}\`.
220
223
 
221
- - Read visual history before changing UI: \`npx vizzly context build current --source local --agent\`
222
- - Inspect named screenshot history when useful: \`npx vizzly context screenshot "<screenshot-name>" --source local --json\`
223
- - Verify visual changes through the existing user workflow that owns the surface: \`npx vizzly tdd run "<test command>" --no-open\`
224
+ - Inspect existing visual evidence before and after the change.
225
+ - Use the repository's established Vizzly command and owning user workflow.
226
+ - Read bounded JSON with \`--agent --json\`, then inspect baseline, current, and
227
+ diff images together.
224
228
 
225
- Prefer existing E2E/user journeys over narrow screenshot-only specs. Treat Vizzly diffs as review evidence; do not approve or reject changes unless asked.
229
+ Treat Vizzly diffs as review evidence. Do not approve, reject, or replace
230
+ evidence unless the task explicitly asks for that mutation.
226
231
  ${agentGuidanceEnd}
227
232
  `;
228
233
  }
@@ -232,7 +237,7 @@ function appendSection(content, section) {
232
237
  }
233
238
  return `${content.replace(/\s*$/, '')}\n\n${section}`;
234
239
  }
235
- export async function upsertProjectAgentGuidance({
240
+ async function upsertProjectAgentGuidance({
236
241
  cwd,
237
242
  access = fs.access,
238
243
  readFile = fs.readFile,
@@ -248,9 +253,28 @@ export async function upsertProjectAgentGuidance({
248
253
  };
249
254
  }
250
255
  let existingContent = await readFile(agentsPath, 'utf8');
251
- if (existingContent.includes(agentGuidanceStart)) {
256
+ let startIndex = existingContent.indexOf(agentGuidanceStart);
257
+ let endIndex = existingContent.indexOf(agentGuidanceEnd);
258
+ let startCount = existingContent.split(agentGuidanceStart).length - 1;
259
+ let endCount = existingContent.split(agentGuidanceEnd).length - 1;
260
+ if (startCount !== endCount || startCount > 1 || startCount === 1 && endIndex < startIndex) {
252
261
  return {
253
- status: 'exists',
262
+ status: 'malformed',
263
+ agentsPath
264
+ };
265
+ }
266
+ if (startIndex >= 0 && endIndex >= startIndex) {
267
+ let blockEnd = endIndex + agentGuidanceEnd.length;
268
+ let refreshedContent = `${existingContent.slice(0, startIndex)}${guidance.trimEnd()}${existingContent.slice(blockEnd)}`;
269
+ if (refreshedContent === existingContent) {
270
+ return {
271
+ status: 'exists',
272
+ agentsPath
273
+ };
274
+ }
275
+ await writeFile(agentsPath, refreshedContent, 'utf8');
276
+ return {
277
+ status: 'refreshed',
254
278
  agentsPath
255
279
  };
256
280
  }
@@ -263,9 +287,11 @@ export async function upsertProjectAgentGuidance({
263
287
  export async function installProjectAgentSkill({
264
288
  cwd,
265
289
  sourcePath,
290
+ refresh = false,
266
291
  access = fs.access,
267
292
  copy = fs.cp,
268
- mkdir = fs.mkdir
293
+ mkdir = fs.mkdir,
294
+ remove = fs.rm
269
295
  }) {
270
296
  let targetPath = getProjectAgentSkillPath(cwd);
271
297
  if (!(await fileExists(path.join(sourcePath, 'SKILL.md'), access))) {
@@ -275,7 +301,8 @@ export async function installProjectAgentSkill({
275
301
  targetPath
276
302
  };
277
303
  }
278
- if (await fileExists(targetPath, access)) {
304
+ let targetExists = await fileExists(targetPath, access);
305
+ if (targetExists && !refresh) {
279
306
  return {
280
307
  status: 'exists',
281
308
  sourcePath,
@@ -287,11 +314,16 @@ export async function installProjectAgentSkill({
287
314
  });
288
315
  await copy(sourcePath, targetPath, {
289
316
  recursive: true,
290
- errorOnExist: true,
291
- force: false
317
+ errorOnExist: !targetExists,
318
+ force: targetExists
292
319
  });
320
+ if (targetExists) {
321
+ await remove(path.join(targetPath, 'agents', 'openai.yaml'), {
322
+ force: true
323
+ });
324
+ }
293
325
  return {
294
- status: 'installed',
326
+ status: targetExists ? 'refreshed' : 'installed',
295
327
  sourcePath,
296
328
  targetPath
297
329
  };
@@ -305,6 +337,11 @@ function writeAgentSkillOutput(output, result) {
305
337
  output.hint(`Installed at ${result.targetPath}`);
306
338
  return;
307
339
  }
340
+ if (result.status === 'refreshed') {
341
+ output.complete('Refreshed Vizzly agent skill');
342
+ output.hint(`Updated ${result.targetPath}`);
343
+ return;
344
+ }
308
345
  if (result.status === 'exists') {
309
346
  output.hint('Vizzly agent skill already exists in this repo');
310
347
  return;
@@ -327,8 +364,18 @@ function writeAgentGuidanceOutput(output, result) {
327
364
  output.hint(`Updated ${result.agentsPath}`);
328
365
  return;
329
366
  }
367
+ if (result.status === 'refreshed') {
368
+ output.complete('Refreshed Vizzly guidance in AGENTS.md');
369
+ output.hint(`Updated ${result.agentsPath}`);
370
+ return;
371
+ }
330
372
  if (result.status === 'exists') {
331
373
  output.hint('Vizzly guidance already exists in AGENTS.md');
374
+ return;
375
+ }
376
+ if (result.status === 'malformed') {
377
+ output.warn('Vizzly guidance markers in AGENTS.md are incomplete');
378
+ output.hint(`Repair the managed block in ${result.agentsPath}`);
332
379
  }
333
380
  }
334
381
  function withAgentSetupResults(payload, {
@@ -362,16 +409,11 @@ async function loadInitPlugins(options, deps) {
362
409
  }
363
410
  try {
364
411
  let config = await deps.loadConfig(options.config, {});
365
- return await deps.loadPlugins(options.config, config, null);
412
+ return await deps.loadPlugins(options.config, config);
366
413
  } catch {
367
414
  return [];
368
415
  }
369
416
  }
370
-
371
- // Export factory function for CLI
372
- export function createInitCommand(options) {
373
- return () => init(options);
374
- }
375
417
  export async function init(options = {}, deps = {}) {
376
418
  let resolvedDeps = createInitDeps(deps);
377
419
  configureOutput(resolvedDeps.output, options);
@@ -386,7 +428,9 @@ export async function init(options = {}, deps = {}) {
386
428
  sourcePath: resolvedDeps.skillSourcePath,
387
429
  access: resolvedDeps.access,
388
430
  copy: resolvedDeps.copy,
389
- mkdir: resolvedDeps.mkdir
431
+ mkdir: resolvedDeps.mkdir,
432
+ refresh: options.agentGuidance || options.agentSkill,
433
+ remove: resolvedDeps.remove
390
434
  });
391
435
  }
392
436
  if (options.agentGuidance) {
@@ -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