@vizzly-testing/cli 0.26.2 → 0.27.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,265 @@
1
+ /**
2
+ * Baselines command - query local TDD baselines
3
+ */
4
+
5
+ import { existsSync, readdirSync, statSync } from 'node:fs';
6
+ import { basename, join, resolve } from 'node:path';
7
+ import { loadBaselineMetadata } from '../tdd/metadata/baseline-metadata.js';
8
+ import * as defaultOutput from '../utils/output.js';
9
+
10
+ /**
11
+ * Extract filename from a path
12
+ */
13
+ function getFilename(screenshot) {
14
+ if (screenshot.filename) return screenshot.filename;
15
+ if (screenshot.path) return basename(screenshot.path);
16
+ return null;
17
+ }
18
+
19
+ /**
20
+ * Extract viewport from screenshot properties
21
+ */
22
+ function getViewport(screenshot) {
23
+ if (screenshot.viewport) return screenshot.viewport;
24
+ if (screenshot.properties?.viewport_width && screenshot.properties?.viewport_height) {
25
+ return {
26
+ width: screenshot.properties.viewport_width,
27
+ height: screenshot.properties.viewport_height
28
+ };
29
+ }
30
+ return null;
31
+ }
32
+
33
+ /**
34
+ * Baselines command - list and query local TDD baselines
35
+ * @param {Object} options - Command options
36
+ * @param {Object} globalOptions - Global CLI options
37
+ * @param {Object} deps - Dependencies for testing
38
+ */
39
+ export async function baselinesCommand(options = {}, globalOptions = {}, deps = {}) {
40
+ let {
41
+ output = defaultOutput,
42
+ exit = code => process.exit(code),
43
+ cwd = process.cwd
44
+ } = deps;
45
+ output.configure({
46
+ json: globalOptions.json,
47
+ verbose: globalOptions.verbose,
48
+ color: !globalOptions.noColor
49
+ });
50
+ try {
51
+ let workingDir = resolve(cwd());
52
+ let vizzlyDir = join(workingDir, '.vizzly');
53
+ let baselinesDir = join(vizzlyDir, 'baselines');
54
+
55
+ // Check if .vizzly directory exists
56
+ if (!existsSync(vizzlyDir)) {
57
+ if (globalOptions.json) {
58
+ output.data({
59
+ baselines: [],
60
+ count: 0,
61
+ error: 'No .vizzly directory found'
62
+ });
63
+ output.cleanup();
64
+ return;
65
+ }
66
+ output.warn('No .vizzly directory found. Run visual tests first to create baselines.');
67
+ output.cleanup();
68
+ return;
69
+ }
70
+
71
+ // Load metadata
72
+ let metadata = loadBaselineMetadata(baselinesDir);
73
+ let screenshots = metadata?.screenshots || [];
74
+
75
+ // Get actual baseline files
76
+ let baselineFiles = [];
77
+ if (existsSync(baselinesDir)) {
78
+ baselineFiles = readdirSync(baselinesDir).filter(f => f.endsWith('.png')).map(f => {
79
+ let filePath = join(baselinesDir, f);
80
+ try {
81
+ let stat = statSync(filePath);
82
+ return {
83
+ filename: f,
84
+ path: filePath,
85
+ size: stat.size,
86
+ modifiedAt: stat.mtime.toISOString()
87
+ };
88
+ } catch {
89
+ // File was deleted between readdir and stat, skip it
90
+ return null;
91
+ }
92
+ }).filter(Boolean);
93
+ }
94
+
95
+ // Filter by name if provided
96
+ if (options.name) {
97
+ let pattern = options.name.replace(/\*/g, '.*');
98
+ let regex = new RegExp(pattern, 'i');
99
+ screenshots = screenshots.filter(s => regex.test(s.name));
100
+ baselineFiles = baselineFiles.filter(f => regex.test(f.filename));
101
+ }
102
+
103
+ // Get specific baseline info
104
+ if (options.info) {
105
+ let screenshot = screenshots.find(s => s.name === options.info || s.signature === options.info);
106
+ if (!screenshot) {
107
+ if (globalOptions.json) {
108
+ output.data({
109
+ error: 'Baseline not found',
110
+ name: options.info
111
+ });
112
+ output.cleanup();
113
+ return;
114
+ }
115
+ output.error(`Baseline "${options.info}" not found`);
116
+ output.cleanup();
117
+ exit(1);
118
+ return;
119
+ }
120
+ let filename = getFilename(screenshot);
121
+ let viewport = getViewport(screenshot);
122
+ let file = baselineFiles.find(f => f.filename === filename);
123
+ if (globalOptions.json) {
124
+ output.data({
125
+ name: screenshot.name,
126
+ signature: screenshot.signature,
127
+ filename,
128
+ path: screenshot.path || file?.path || join(baselinesDir, filename),
129
+ sha256: screenshot.sha256,
130
+ viewport,
131
+ browser: screenshot.browser || null,
132
+ createdAt: screenshot.createdAt || metadata?.createdAt,
133
+ fileSize: file?.size,
134
+ fileModifiedAt: file?.modifiedAt
135
+ });
136
+ output.cleanup();
137
+ return;
138
+ }
139
+ output.header('baseline');
140
+ output.keyValue({
141
+ Name: screenshot.name,
142
+ Signature: screenshot.signature,
143
+ File: filename
144
+ });
145
+ output.blank();
146
+ if (viewport) {
147
+ output.labelValue('Viewport', `${viewport.width}×${viewport.height}`);
148
+ }
149
+ if (screenshot.browser) {
150
+ output.labelValue('Browser', screenshot.browser);
151
+ }
152
+ if (screenshot.sha256) {
153
+ output.labelValue('SHA256', `${screenshot.sha256.substring(0, 16)}...`);
154
+ }
155
+ if (file) {
156
+ output.labelValue('Size', formatBytes(file.size));
157
+ output.labelValue('Modified', new Date(file.modifiedAt).toLocaleString());
158
+ }
159
+ output.cleanup();
160
+ return;
161
+ }
162
+
163
+ // JSON output for list
164
+ if (globalOptions.json) {
165
+ let baselines = screenshots.map(s => {
166
+ let filename = getFilename(s);
167
+ let viewport = getViewport(s);
168
+ let file = baselineFiles.find(f => f.filename === filename);
169
+ return {
170
+ name: s.name,
171
+ signature: s.signature,
172
+ filename,
173
+ path: s.path || file?.path || join(baselinesDir, filename),
174
+ sha256: s.sha256,
175
+ viewport,
176
+ browser: s.browser || null,
177
+ createdAt: s.createdAt,
178
+ fileSize: file?.size
179
+ };
180
+ });
181
+ output.data({
182
+ baselines,
183
+ count: baselines.length,
184
+ metadata: metadata ? {
185
+ buildId: metadata.buildId,
186
+ buildName: metadata.buildName,
187
+ branch: metadata.branch,
188
+ threshold: metadata.threshold,
189
+ createdAt: metadata.createdAt
190
+ } : null
191
+ });
192
+ output.cleanup();
193
+ return;
194
+ }
195
+
196
+ // Human-readable output
197
+ output.header('baselines');
198
+ if (screenshots.length === 0 && baselineFiles.length === 0) {
199
+ output.print(' No baselines found');
200
+ output.hint('Run visual tests to create baselines');
201
+ output.cleanup();
202
+ return;
203
+ }
204
+
205
+ // Show metadata info
206
+ if (metadata) {
207
+ output.labelValue('Source', metadata.buildName || metadata.buildId || 'Local');
208
+ if (metadata.branch && metadata.branch !== 'local') {
209
+ output.labelValue('Branch', metadata.branch);
210
+ }
211
+ output.labelValue('Threshold', `${metadata.threshold || 2.0}%`);
212
+ output.blank();
213
+ }
214
+ output.labelValue('Count', String(screenshots.length));
215
+ output.blank();
216
+ let colors = output.getColors();
217
+
218
+ // Show baselines (limited in non-verbose mode)
219
+ let displayLimit = globalOptions.verbose ? screenshots.length : 20;
220
+ for (let screenshot of screenshots.slice(0, displayLimit)) {
221
+ let viewport = getViewport(screenshot);
222
+ let viewportInfo = viewport ? colors.dim(` ${viewport.width}×${viewport.height}`) : '';
223
+ let browserInfo = screenshot.browser ? colors.dim(` ${screenshot.browser}`) : '';
224
+ output.print(` ${colors.brand.success('●')} ${screenshot.name}${viewportInfo}${browserInfo}`);
225
+ }
226
+ if (screenshots.length > displayLimit) {
227
+ output.blank();
228
+ output.hint(`... and ${screenshots.length - displayLimit} more. Use --verbose to see all.`);
229
+ }
230
+
231
+ // Show orphaned files (files without metadata)
232
+ if (globalOptions.verbose) {
233
+ let knownFiles = new Set(screenshots.map(s => getFilename(s)).filter(Boolean));
234
+ let orphaned = baselineFiles.filter(f => !knownFiles.has(f.filename));
235
+ if (orphaned.length > 0) {
236
+ output.blank();
237
+ output.labelValue('Orphaned Files', String(orphaned.length));
238
+ for (let file of orphaned) {
239
+ output.print(` ${colors.brand.warning('?')} ${file.filename}`);
240
+ }
241
+ }
242
+ }
243
+ output.cleanup();
244
+ } catch (error) {
245
+ output.error('Failed to read baselines', error);
246
+ output.cleanup();
247
+ exit(1);
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Format bytes to human readable
253
+ */
254
+ function formatBytes(bytes) {
255
+ if (bytes < 1024) return `${bytes} B`;
256
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
257
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
258
+ }
259
+
260
+ /**
261
+ * Validate baselines command options
262
+ */
263
+ export function validateBaselinesOptions() {
264
+ return [];
265
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Builds command - list and query builds
3
+ */
4
+
5
+ import { createApiClient as defaultCreateApiClient, getBuild as defaultGetBuild, getBuilds as defaultGetBuilds } from '../api/index.js';
6
+ import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
7
+ import * as defaultOutput from '../utils/output.js';
8
+
9
+ /**
10
+ * Builds list command - list builds with optional filters
11
+ * @param {Object} options - Command options
12
+ * @param {Object} globalOptions - Global CLI options
13
+ * @param {Object} deps - Dependencies for testing
14
+ */
15
+ export async function buildsCommand(options = {}, globalOptions = {}, deps = {}) {
16
+ let {
17
+ loadConfig = defaultLoadConfig,
18
+ createApiClient = defaultCreateApiClient,
19
+ getBuilds = defaultGetBuilds,
20
+ getBuild = defaultGetBuild,
21
+ output = defaultOutput,
22
+ exit = code => process.exit(code)
23
+ } = deps;
24
+ output.configure({
25
+ json: globalOptions.json,
26
+ verbose: globalOptions.verbose,
27
+ color: !globalOptions.noColor
28
+ });
29
+ try {
30
+ // Load configuration with CLI overrides
31
+ let allOptions = {
32
+ ...globalOptions,
33
+ ...options
34
+ };
35
+ let config = await loadConfig(globalOptions.config, allOptions);
36
+
37
+ // Validate API token
38
+ if (!config.apiKey) {
39
+ output.error('API token required. Use --token or set VIZZLY_TOKEN environment variable');
40
+ exit(1);
41
+ return;
42
+ }
43
+ let client = createApiClient({
44
+ baseUrl: config.apiUrl,
45
+ token: config.apiKey,
46
+ command: 'builds'
47
+ });
48
+
49
+ // If a specific build ID is provided, get that build
50
+ if (options.build) {
51
+ output.startSpinner('Fetching build...');
52
+ let include = options.comparisons ? 'comparisons' : undefined;
53
+ let response = await getBuild(client, options.build, {
54
+ include
55
+ });
56
+ output.stopSpinner();
57
+ let build = response.build || response;
58
+ if (globalOptions.json) {
59
+ output.data(formatBuildForJson(build, options.comparisons));
60
+ output.cleanup();
61
+ return;
62
+ }
63
+ displayBuild(output, build, globalOptions.verbose);
64
+ output.cleanup();
65
+ return;
66
+ }
67
+
68
+ // List builds with filters
69
+ output.startSpinner('Fetching builds...');
70
+ let filters = {
71
+ limit: options.limit || 20,
72
+ offset: options.offset || 0
73
+ };
74
+ if (options.branch) filters.branch = options.branch;
75
+ if (options.status) filters.status = options.status;
76
+ if (options.environment) filters.environment = options.environment;
77
+ let response = await getBuilds(client, filters);
78
+ output.stopSpinner();
79
+ let builds = response.builds || [];
80
+ let pagination = response.pagination || {
81
+ total: builds.length,
82
+ hasMore: false
83
+ };
84
+
85
+ // JSON output
86
+ if (globalOptions.json) {
87
+ output.data({
88
+ builds: builds.map(b => formatBuildForJson(b)),
89
+ pagination: {
90
+ total: pagination.total,
91
+ limit: filters.limit,
92
+ offset: filters.offset,
93
+ hasMore: pagination.hasMore
94
+ }
95
+ });
96
+ output.cleanup();
97
+ return;
98
+ }
99
+
100
+ // Human-readable output
101
+ output.header('builds');
102
+ if (builds.length === 0) {
103
+ output.print(' No builds found');
104
+ if (options.branch || options.status) {
105
+ output.hint('Try removing filters to see more results');
106
+ }
107
+ output.cleanup();
108
+ return;
109
+ }
110
+ let colors = output.getColors();
111
+ for (let build of builds) {
112
+ let statusColor = getStatusColor(colors, build.status);
113
+ let statusBadge = statusColor(build.status.toUpperCase());
114
+ output.print(` ${colors.bold(build.name || build.id)} ${statusBadge}`);
115
+ let details = [];
116
+ if (build.branch) details.push(build.branch);
117
+ if (build.commit_sha) details.push(build.commit_sha.substring(0, 7));
118
+ if (build.screenshot_count) details.push(`${build.screenshot_count} screenshots`);
119
+ if (details.length > 0) {
120
+ output.print(` ${colors.dim(details.join(' · '))}`);
121
+ }
122
+ if (build.created_at) {
123
+ output.print(` ${colors.dim(new Date(build.created_at).toLocaleString())}`);
124
+ }
125
+ output.blank();
126
+ }
127
+
128
+ // Pagination info
129
+ if (pagination.total > builds.length) {
130
+ output.hint(`Showing ${builds.length} of ${pagination.total} builds. ` + `Use --offset ${filters.offset + filters.limit} to see more.`);
131
+ }
132
+ output.cleanup();
133
+ } catch (error) {
134
+ output.stopSpinner();
135
+ output.error('Failed to fetch builds', error);
136
+ output.cleanup();
137
+ exit(1);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Format a build for JSON output
143
+ */
144
+ function formatBuildForJson(build, includeComparisons = false) {
145
+ let result = {
146
+ id: build.id,
147
+ name: build.name,
148
+ status: build.status,
149
+ branch: build.branch,
150
+ commit: build.commit_sha,
151
+ commitMessage: build.commit_message,
152
+ environment: build.environment,
153
+ screenshotCount: build.screenshot_count || 0,
154
+ comparisons: {
155
+ total: build.total_comparisons || 0,
156
+ new: build.new_comparisons || 0,
157
+ changed: build.changed_comparisons || 0,
158
+ identical: build.identical_comparisons || 0
159
+ },
160
+ approvalStatus: build.approval_status,
161
+ createdAt: build.created_at,
162
+ completedAt: build.completed_at
163
+ };
164
+ if (includeComparisons && build.comparisons) {
165
+ result.comparisonDetails = build.comparisons.map(c => ({
166
+ id: c.id,
167
+ name: c.name,
168
+ status: c.status,
169
+ diffPercentage: c.diff_percentage,
170
+ approvalStatus: c.approval_status
171
+ }));
172
+ }
173
+ return result;
174
+ }
175
+
176
+ /**
177
+ * Display a single build in human-readable format
178
+ */
179
+ function displayBuild(output, build, verbose) {
180
+ let colors = output.getColors();
181
+ output.header('build', build.status);
182
+ output.keyValue({
183
+ Name: build.name || build.id,
184
+ Status: build.status?.toUpperCase(),
185
+ Branch: build.branch,
186
+ Commit: build.commit_sha ? `${build.commit_sha.substring(0, 8)} - ${build.commit_message || 'No message'}` : 'N/A'
187
+ });
188
+ output.blank();
189
+
190
+ // Stats
191
+ let newCount = build.new_comparisons || 0;
192
+ let changedCount = build.changed_comparisons || 0;
193
+ let identicalCount = build.identical_comparisons || 0;
194
+ output.labelValue('Screenshots', String(build.screenshot_count || 0));
195
+ let stats = [];
196
+ if (newCount > 0) stats.push(`${colors.brand.info(newCount)} new`);
197
+ if (changedCount > 0) stats.push(`${colors.brand.warning(changedCount)} changed`);
198
+ if (identicalCount > 0) stats.push(`${colors.brand.success(identicalCount)} identical`);
199
+ if (stats.length > 0) {
200
+ output.labelValue('Comparisons', stats.join(colors.dim(' · ')));
201
+ }
202
+ if (build.approval_status) {
203
+ output.labelValue('Approval', build.approval_status);
204
+ }
205
+ output.blank();
206
+ if (build.created_at) {
207
+ output.hint(`Created ${new Date(build.created_at).toLocaleString()}`);
208
+ }
209
+ if (build.completed_at) {
210
+ output.hint(`Completed ${new Date(build.completed_at).toLocaleString()}`);
211
+ }
212
+
213
+ // Show comparisons if included
214
+ if (build.comparisons && build.comparisons.length > 0) {
215
+ output.blank();
216
+ output.labelValue('Comparisons', '');
217
+ for (let comp of build.comparisons.slice(0, verbose ? 50 : 10)) {
218
+ let statusIcon = getComparisonStatusIcon(colors, comp.status);
219
+ output.print(` ${statusIcon} ${comp.name}`);
220
+ }
221
+ if (build.comparisons.length > (verbose ? 50 : 10)) {
222
+ output.hint(` ... and ${build.comparisons.length - (verbose ? 50 : 10)} more`);
223
+ }
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Get color function for build status
229
+ */
230
+ function getStatusColor(colors, status) {
231
+ switch (status) {
232
+ case 'completed':
233
+ return colors.brand.success;
234
+ case 'failed':
235
+ return colors.brand.error;
236
+ case 'processing':
237
+ case 'pending':
238
+ return colors.brand.warning;
239
+ default:
240
+ return colors.dim;
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Get icon for comparison status
246
+ */
247
+ function getComparisonStatusIcon(colors, status) {
248
+ switch (status) {
249
+ case 'identical':
250
+ return colors.brand.success('✓');
251
+ case 'new':
252
+ return colors.brand.info('●');
253
+ case 'changed':
254
+ return colors.brand.warning('◐');
255
+ case 'failed':
256
+ return colors.brand.error('✗');
257
+ default:
258
+ return colors.dim('○');
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Validate builds command options
264
+ */
265
+ export function validateBuildsOptions(options = {}) {
266
+ let errors = [];
267
+ if (options.limit && (Number.isNaN(options.limit) || options.limit < 1 || options.limit > 250)) {
268
+ errors.push('--limit must be a number between 1 and 250');
269
+ }
270
+ if (options.offset && (Number.isNaN(options.offset) || options.offset < 0)) {
271
+ errors.push('--offset must be a non-negative number');
272
+ }
273
+ return errors;
274
+ }