@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,412 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { basename, isAbsolute, join } from 'node:path';
3
+ import { normalizeReportData } from '../utils/report-data.js';
4
+ let LOCAL_CONTEXT_SOURCE = 'local_workspace';
5
+ let DEFAULT_LOCAL_REVIEW_QUEUE_LIMIT = 50;
6
+ function readJsonIfExists(path) {
7
+ if (!existsSync(path)) {
8
+ return null;
9
+ }
10
+ try {
11
+ return JSON.parse(readFileSync(path, 'utf8'));
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+ function createEmptyReportData() {
17
+ return {
18
+ timestamp: Date.now(),
19
+ comparisons: [],
20
+ summary: {
21
+ total: 0,
22
+ passed: 0,
23
+ failed: 0,
24
+ rejected: 0,
25
+ errors: 0
26
+ }
27
+ };
28
+ }
29
+ function mapComparisonResult(status) {
30
+ if (status === 'new') {
31
+ return 'new';
32
+ }
33
+ if (status === 'failed' || status === 'rejected') {
34
+ return 'changed';
35
+ }
36
+ if (status === 'passed' || status === 'baseline-created') {
37
+ return 'identical';
38
+ }
39
+ if (status === 'error') {
40
+ return 'error';
41
+ }
42
+ return status || 'unknown';
43
+ }
44
+ function mapApprovalStatus(status) {
45
+ if (status === 'failed' || status === 'new') {
46
+ return 'pending';
47
+ }
48
+ if (status === 'rejected') {
49
+ return 'rejected';
50
+ }
51
+ if (status === 'passed' || status === 'baseline-created') {
52
+ return 'approved';
53
+ }
54
+ return status || 'unknown';
55
+ }
56
+ function buildLocalScope(projectRoot) {
57
+ let projectName = basename(projectRoot);
58
+ return {
59
+ organization: {
60
+ id: null,
61
+ name: 'Local Workspace',
62
+ slug: 'local'
63
+ },
64
+ project: {
65
+ id: null,
66
+ name: projectName,
67
+ slug: projectName
68
+ }
69
+ };
70
+ }
71
+ function resolveAssetReference(assetPath, snapshot) {
72
+ if (!assetPath) {
73
+ return null;
74
+ }
75
+ if (/^https?:\/\//.test(assetPath) || isAbsolute(assetPath)) {
76
+ return assetPath;
77
+ }
78
+ if (assetPath.startsWith('/images/')) {
79
+ if (snapshot.serverInfo?.port) {
80
+ return `http://127.0.0.1:${snapshot.serverInfo.port}${assetPath}`;
81
+ }
82
+ return join(snapshot.vizzlyDir, assetPath.replace('/images/', ''));
83
+ }
84
+ return assetPath;
85
+ }
86
+ function normalizeConfirmedRegions(regions = []) {
87
+ return regions.map((region, index) => ({
88
+ id: region.id || `local-region-${index}`,
89
+ x1: region.x1 ?? region.x ?? null,
90
+ y1: region.y1 ?? region.y ?? null,
91
+ x2: region.x2 ?? (region.x != null && region.width != null ? region.x + region.width : null),
92
+ y2: region.y2 ?? (region.y != null && region.height != null ? region.y + region.height : null),
93
+ label: region.label || null
94
+ }));
95
+ }
96
+ function mergeConfirmedRegions(snapshot, comparisonName, details = {}) {
97
+ let workspaceRegions = snapshot.regions?.[comparisonName]?.confirmed || [];
98
+ let detailRegions = details.confirmedRegions || [];
99
+ let merged = [...workspaceRegions, ...detailRegions];
100
+ let seen = new Set();
101
+ return normalizeConfirmedRegions(merged).filter(region => {
102
+ let key = `${region.label || ''}:${region.x1}:${region.y1}:${region.x2}:${region.y2}`;
103
+ if (seen.has(key)) {
104
+ return false;
105
+ }
106
+ seen.add(key);
107
+ return true;
108
+ });
109
+ }
110
+ function buildHotspotAnalysis(snapshot, comparisonName, details = {}) {
111
+ let hotspotMetadata = snapshot.hotspots?.[comparisonName] || null;
112
+ let hotspotAnalysis = details.hotspotAnalysis || null;
113
+ if (!hotspotMetadata && !hotspotAnalysis) {
114
+ return {
115
+ regions: [],
116
+ total_builds_analyzed: 0,
117
+ confidence: 'no_data',
118
+ confidence_score: null,
119
+ data_source: 'local_workspace'
120
+ };
121
+ }
122
+ return {
123
+ regions: hotspotMetadata?.regions || [],
124
+ total_builds_analyzed: 1,
125
+ confidence: hotspotAnalysis?.confidence || hotspotMetadata?.confidence || 'workspace',
126
+ confidence_score: hotspotAnalysis?.confidenceScore || null,
127
+ data_source: 'local_workspace'
128
+ };
129
+ }
130
+ function buildComparisonLinks(snapshot, comparisonId) {
131
+ if (!snapshot.serverInfo?.port) {
132
+ return {};
133
+ }
134
+ return {
135
+ build_url: `http://127.0.0.1:${snapshot.serverInfo.port}/builds`,
136
+ comparison_url: `http://127.0.0.1:${snapshot.serverInfo.port}/comparison/${encodeURIComponent(comparisonId)}`
137
+ };
138
+ }
139
+ function buildBuildSnapshot(snapshot) {
140
+ let buildId = snapshot.session?.buildId || snapshot.serverInfo?.buildId || 'local-workspace';
141
+ return {
142
+ id: buildId,
143
+ name: buildId,
144
+ branch: snapshot.session?.branch || 'local',
145
+ commit_sha: snapshot.session?.commit || null,
146
+ commit_message: null,
147
+ approval_status: snapshot.serverInfo ? 'pending' : 'approved',
148
+ status: snapshot.serverInfo ? 'running' : 'completed',
149
+ created_at: snapshot.session?.createdAt || null
150
+ };
151
+ }
152
+ function mapLocalComparison(snapshot, comparison) {
153
+ let details = snapshot.comparisonDetails[comparison.id] || {};
154
+ let comparisonName = comparison.originalName || comparison.name;
155
+ let confirmedRegions = mergeConfirmedRegions(snapshot, comparisonName, details);
156
+ let hotspotAnalysis = buildHotspotAnalysis(snapshot, comparisonName, details);
157
+ let properties = comparison.properties || {};
158
+ let buildSnapshot = buildBuildSnapshot(snapshot);
159
+ return {
160
+ id: comparison.id,
161
+ name: comparisonName,
162
+ status: comparison.status,
163
+ result: mapComparisonResult(comparison.status),
164
+ approval_status: mapApprovalStatus(comparison.status),
165
+ build_id: buildSnapshot.id,
166
+ build_name: buildSnapshot.name,
167
+ build_branch: buildSnapshot.branch,
168
+ build_commit_sha: buildSnapshot.commit_sha,
169
+ build_created_at: buildSnapshot.created_at,
170
+ threshold: comparison.threshold ?? null,
171
+ diff_percentage: comparison.diffPercentage ?? null,
172
+ changed_pixels: comparison.diffCount ?? null,
173
+ total_pixels: comparison.totalPixels ?? null,
174
+ screenshot: {
175
+ id: comparison.id,
176
+ name: comparisonName,
177
+ browser: properties.browser ?? null,
178
+ viewport_width: properties.viewport_width ?? null,
179
+ viewport_height: properties.viewport_height ?? null,
180
+ original_url: resolveAssetReference(comparison.current, snapshot)
181
+ },
182
+ baseline: comparison.baseline ? {
183
+ id: `${comparison.id}-baseline`,
184
+ name: comparisonName,
185
+ browser: properties.browser ?? null,
186
+ viewport_width: properties.viewport_width ?? null,
187
+ viewport_height: properties.viewport_height ?? null,
188
+ original_url: resolveAssetReference(comparison.baseline, snapshot)
189
+ } : null,
190
+ analysis: {
191
+ diff_image_url: resolveAssetReference(comparison.diff, snapshot),
192
+ diff_regions: details.diffClusters || [],
193
+ cluster_metadata: details.diffClusters ? {
194
+ clusterCount: details.diffClusters.length,
195
+ local_workspace: true
196
+ } : null,
197
+ diff_lines: null,
198
+ fingerprint_hash: null,
199
+ fingerprint_data: null,
200
+ hotspot_analysis: hotspotAnalysis,
201
+ region_analysis: details.regionAnalysis || null,
202
+ confirmed_regions: confirmedRegions
203
+ }
204
+ };
205
+ }
206
+ function buildReviewSummary(comparisons = []) {
207
+ let approved = comparisons.filter(comparison => mapApprovalStatus(comparison.status) === 'approved').length;
208
+ let rejected = comparisons.filter(comparison => mapApprovalStatus(comparison.status) === 'rejected').length;
209
+ let pending = comparisons.filter(comparison => mapApprovalStatus(comparison.status) === 'pending').length;
210
+ return {
211
+ total: comparisons.length,
212
+ pending,
213
+ approved,
214
+ rejected
215
+ };
216
+ }
217
+ function createLocalWorkspaceError(message) {
218
+ let error = new Error(message);
219
+ error.code = 'LOCAL_WORKSPACE_CONTEXT';
220
+ return error;
221
+ }
222
+ export function createLocalWorkspaceContextProvider(options = {}, deps = {}) {
223
+ let projectRoot = options.projectRoot || process.cwd();
224
+ let readJson = deps.readJsonIfExists || readJsonIfExists;
225
+ let snapshotCache = null;
226
+ function loadSnapshot() {
227
+ if (snapshotCache) {
228
+ return snapshotCache;
229
+ }
230
+ let vizzlyDir = join(projectRoot, '.vizzly');
231
+ snapshotCache = {
232
+ projectRoot,
233
+ vizzlyDir,
234
+ serverInfo: readJson(join(vizzlyDir, 'server.json')),
235
+ session: readJson(join(vizzlyDir, 'session.json')),
236
+ reportData: normalizeReportData(readJson(join(vizzlyDir, 'report-data.json')) || createEmptyReportData()),
237
+ comparisonDetails: readJson(join(vizzlyDir, 'comparison-details.json')) || {},
238
+ baselineMetadata: readJson(join(vizzlyDir, 'baselines', 'metadata.json')),
239
+ hotspotFile: readJson(join(vizzlyDir, 'hotspots.json')),
240
+ regionFile: readJson(join(vizzlyDir, 'regions.json'))
241
+ };
242
+ snapshotCache.hotspots = snapshotCache.hotspotFile?.hotspots || null;
243
+ snapshotCache.regions = snapshotCache.regionFile?.regions || null;
244
+ return snapshotCache;
245
+ }
246
+ function isAvailable(snapshot = loadSnapshot()) {
247
+ return Boolean(snapshot.serverInfo || snapshot.session || snapshot.reportData.comparisons.length > 0 || snapshot.baselineMetadata);
248
+ }
249
+ function findComparison(snapshot, target) {
250
+ if (!target) {
251
+ return null;
252
+ }
253
+ return snapshot.reportData.comparisons.find(comparison => comparison.id === target) || snapshot.reportData.comparisons.find(comparison => comparison.signature === target) || snapshot.reportData.comparisons.find(comparison => (comparison.originalName || comparison.name) === target) || null;
254
+ }
255
+ function canHandle(command, target, snapshot = loadSnapshot()) {
256
+ if (!isAvailable(snapshot)) {
257
+ return false;
258
+ }
259
+ if (command === 'build') {
260
+ let buildId = buildBuildSnapshot(snapshot).id;
261
+ return target === 'current' || target === 'local' || target === buildId;
262
+ }
263
+ if (command === 'comparison') {
264
+ return Boolean(findComparison(snapshot, target));
265
+ }
266
+ if (command === 'screenshot') {
267
+ return Boolean(snapshot.reportData.comparisons.some(comparison => (comparison.originalName || comparison.name) === target) || snapshot.regions?.[target] || snapshot.hotspots?.[target]);
268
+ }
269
+ if (command === 'review-queue') {
270
+ return snapshot.reportData.comparisons.length > 0;
271
+ }
272
+ if (command === 'similar') {
273
+ return false;
274
+ }
275
+ return false;
276
+ }
277
+ function createScope() {
278
+ return buildLocalScope(projectRoot);
279
+ }
280
+ function createBuildLinks(snapshot) {
281
+ if (!snapshot.serverInfo?.port) {
282
+ return {};
283
+ }
284
+ let buildId = buildBuildSnapshot(snapshot).id;
285
+ return {
286
+ build_url: `http://127.0.0.1:${snapshot.serverInfo.port}/builds`,
287
+ comparison_url_prefix: `http://127.0.0.1:${snapshot.serverInfo.port}/comparison`,
288
+ current_build_id: buildId
289
+ };
290
+ }
291
+ function getBuildContext(buildId) {
292
+ let snapshot = loadSnapshot();
293
+ let resolvedBuild = buildBuildSnapshot(snapshot);
294
+ if (!(buildId === 'current' || buildId === 'local' || buildId === resolvedBuild.id)) {
295
+ throw createLocalWorkspaceError(`Local workspace context is only available for the active session build (${resolvedBuild.id})`);
296
+ }
297
+ let mappedComparisons = snapshot.reportData.comparisons.map(comparison => mapLocalComparison(snapshot, comparison));
298
+ let reviewSummary = buildReviewSummary(snapshot.reportData.comparisons);
299
+ return {
300
+ resource: 'build_context',
301
+ source: LOCAL_CONTEXT_SOURCE,
302
+ scope: createScope(),
303
+ build: resolvedBuild,
304
+ summary: {
305
+ comparisons: {
306
+ total: mappedComparisons.length,
307
+ changed: mappedComparisons.filter(comparison => comparison.result === 'changed').length,
308
+ new: mappedComparisons.filter(comparison => comparison.result === 'new').length
309
+ },
310
+ review: reviewSummary
311
+ },
312
+ review: {
313
+ comments: [],
314
+ assignments: []
315
+ },
316
+ comparisons: mappedComparisons,
317
+ links: createBuildLinks(snapshot)
318
+ };
319
+ }
320
+ function getComparisonContext(comparisonId) {
321
+ let snapshot = loadSnapshot();
322
+ let comparison = findComparison(snapshot, comparisonId);
323
+ if (!comparison) {
324
+ throw createLocalWorkspaceError(`No local comparison found for "${comparisonId}"`);
325
+ }
326
+ let mappedComparison = mapLocalComparison(snapshot, comparison);
327
+ let comparisonName = comparison.originalName || comparison.name;
328
+ let history = snapshot.reportData.comparisons.filter(candidate => candidate.id !== comparison.id && (candidate.originalName || candidate.name) === comparisonName).map(candidate => mapLocalComparison(snapshot, candidate));
329
+ return {
330
+ resource: 'comparison_context',
331
+ source: LOCAL_CONTEXT_SOURCE,
332
+ scope: createScope(),
333
+ build: buildBuildSnapshot(snapshot),
334
+ comparison: mappedComparison,
335
+ history: {
336
+ similar_by_fingerprint: [],
337
+ recent_by_name: history,
338
+ hotspot_analysis: buildHotspotAnalysis(snapshot, comparisonName, snapshot.comparisonDetails[comparison.id] || {}),
339
+ confirmed_regions: mergeConfirmedRegions(snapshot, comparisonName, snapshot.comparisonDetails[comparison.id] || {})
340
+ },
341
+ review: {
342
+ review_summary: {
343
+ total: 0,
344
+ completed: 0,
345
+ pending: 0,
346
+ approved: 0,
347
+ changes_requested: 0,
348
+ commented: 0,
349
+ has_changes_requested: false,
350
+ decisions: []
351
+ },
352
+ assignments: [],
353
+ build_comments: [],
354
+ screenshot_comments: []
355
+ },
356
+ links: buildComparisonLinks(snapshot, comparison.id)
357
+ };
358
+ }
359
+ function getScreenshotContext(screenshotName) {
360
+ let snapshot = loadSnapshot();
361
+ let matches = snapshot.reportData.comparisons.filter(comparison => (comparison.originalName || comparison.name) === screenshotName).map(comparison => mapLocalComparison(snapshot, comparison));
362
+ if (matches.length === 0 && !snapshot.regions?.[screenshotName] && !snapshot.hotspots?.[screenshotName]) {
363
+ throw createLocalWorkspaceError(`No local screenshot context found for "${screenshotName}"`);
364
+ }
365
+ return {
366
+ resource: 'screenshot_context',
367
+ source: LOCAL_CONTEXT_SOURCE,
368
+ scope: createScope(),
369
+ screenshot: {
370
+ name: screenshotName
371
+ },
372
+ hotspot_analysis: buildHotspotAnalysis(snapshot, screenshotName),
373
+ confirmed_regions: mergeConfirmedRegions(snapshot, screenshotName),
374
+ history: {
375
+ recent_comparisons: matches
376
+ }
377
+ };
378
+ }
379
+ function getReviewQueueContext(query = {}) {
380
+ let snapshot = loadSnapshot();
381
+ let unresolved = snapshot.reportData.comparisons.filter(comparison => comparison.status === 'failed' || comparison.status === 'new');
382
+ let offset = query.offset || 0;
383
+ let limit = query.limit || DEFAULT_LOCAL_REVIEW_QUEUE_LIMIT;
384
+ let visible = unresolved.slice(offset, offset + limit).map(comparison => mapLocalComparison(snapshot, comparison));
385
+ return {
386
+ resource: 'review_queue_context',
387
+ source: LOCAL_CONTEXT_SOURCE,
388
+ scope: createScope(),
389
+ summary: {
390
+ total: unresolved.length,
391
+ changed: unresolved.filter(comparison => comparison.status === 'failed').length,
392
+ new: unresolved.filter(comparison => comparison.status === 'new').length,
393
+ builds: unresolved.length > 0 ? 1 : 0
394
+ },
395
+ comparisons: visible
396
+ };
397
+ }
398
+ function getSimilarFingerprintContext() {
399
+ throw createLocalWorkspaceError('Local workspace context does not support fingerprint similarity yet. Use --source cloud for this query.');
400
+ }
401
+ return {
402
+ source: LOCAL_CONTEXT_SOURCE,
403
+ loadSnapshot,
404
+ isAvailable,
405
+ canHandle,
406
+ getBuildContext,
407
+ getComparisonContext,
408
+ getScreenshotContext,
409
+ getReviewQueueContext,
410
+ getSimilarFingerprintContext
411
+ };
412
+ }
@@ -0,0 +1,32 @@
1
+ import { createLocalWorkspaceContextProvider as defaultCreateLocalWorkspaceContextProvider } from './local-workspace-provider.js';
2
+ export function resolveContextSource(options = {}, deps = {}) {
3
+ let {
4
+ requestedSource = 'auto',
5
+ command,
6
+ target = null,
7
+ projectRoot = process.cwd()
8
+ } = options;
9
+ let {
10
+ createLocalWorkspaceContextProvider = defaultCreateLocalWorkspaceContextProvider
11
+ } = deps;
12
+ if (requestedSource === 'cloud') {
13
+ return 'cloud';
14
+ }
15
+ let localProvider = createLocalWorkspaceContextProvider({
16
+ projectRoot
17
+ });
18
+ let localSnapshot = typeof localProvider.loadSnapshot === 'function' ? localProvider.loadSnapshot() : null;
19
+ let isLocalAvailable = localSnapshot == null ? localProvider.isAvailable() : localProvider.isAvailable(localSnapshot);
20
+ if (requestedSource === 'local') {
21
+ if (!isLocalAvailable) {
22
+ let error = new Error('No local workspace context found. Start a local TDD session or ensure .vizzly/ has report data.');
23
+ error.code = 'LOCAL_WORKSPACE_CONTEXT';
24
+ throw error;
25
+ }
26
+ return 'local';
27
+ }
28
+ if (isLocalAvailable && (localSnapshot == null ? localProvider.canHandle(command, target) : localProvider.canHandle(command, target, localSnapshot))) {
29
+ return 'local';
30
+ }
31
+ return 'cloud';
32
+ }