@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.
- package/dist/cli.js +250 -5
- package/dist/commands/api.js +190 -0
- package/dist/commands/baselines.js +265 -0
- package/dist/commands/builds.js +274 -0
- package/dist/commands/comparisons.js +345 -0
- package/dist/commands/config-cmd.js +184 -0
- package/dist/commands/init.js +54 -12
- package/dist/commands/orgs.js +79 -0
- package/dist/commands/preview.js +13 -3
- package/dist/commands/project.js +69 -15
- package/dist/commands/projects.js +96 -0
- package/dist/commands/review.js +319 -0
- package/dist/commands/run.js +142 -2
- package/dist/commands/tdd-daemon.js +78 -12
- package/dist/commands/tdd.js +61 -2
- package/dist/commands/upload.js +94 -2
- package/dist/utils/config-loader.js +13 -1
- package/dist/utils/output.js +107 -4
- package/package.json +2 -1
|
@@ -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
|
+
}
|