@vizzly-testing/cli 0.30.0 → 0.32.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/README.md +9 -9
- package/dist/api/client.js +6 -3
- package/dist/cli.js +58 -29
- package/dist/client/index.js +21 -29
- package/dist/commands/api.js +145 -90
- package/dist/commands/baselines.js +78 -47
- package/dist/commands/builds.js +9 -8
- package/dist/commands/comparisons.js +7 -8
- package/dist/commands/config-cmd.js +15 -7
- package/dist/commands/context.js +329 -18
- package/dist/commands/doctor.js +116 -81
- package/dist/commands/init.js +202 -201
- package/dist/commands/login.js +134 -65
- package/dist/commands/logout.js +17 -6
- package/dist/commands/preview.js +29 -57
- package/dist/commands/project.js +120 -0
- package/dist/commands/projects.js +9 -2
- package/dist/commands/review.js +236 -202
- package/dist/commands/run.js +75 -72
- package/dist/commands/status.js +228 -150
- package/dist/commands/tdd-daemon-process.js +98 -0
- package/dist/commands/tdd-daemon.js +318 -212
- package/dist/commands/tdd.js +23 -8
- package/dist/commands/upload.js +10 -9
- package/dist/commands/whoami.js +63 -25
- package/dist/config/core.js +36 -35
- package/dist/context/local-workspace-provider.js +132 -12
- package/dist/context/provider-resolver.js +5 -1
- package/dist/index.js +5 -4
- package/dist/plugin-loader.js +55 -49
- package/dist/project/core.js +2 -2
- package/dist/project/operations.js +6 -3
- package/dist/reporter/reporter-bundle.css +1 -1
- package/dist/reporter/reporter-bundle.iife.js +7 -7
- package/dist/sdk/index.js +162 -107
- package/dist/server/handlers/tdd-handler.js +30 -54
- package/dist/server/routers/baseline.js +195 -178
- package/dist/server/routers/events.js +212 -174
- package/dist/server/routers/projects.js +14 -5
- package/dist/server/routers/screenshot.js +1 -31
- package/dist/services/build-manager.js +19 -97
- package/dist/services/config-service.js +26 -34
- package/dist/services/index.js +2 -4
- package/dist/services/static-report-generator.js +11 -12
- package/dist/services/test-runner.js +21 -27
- package/dist/tdd/core/region-coverage.js +109 -21
- package/dist/tdd/index.js +1 -1
- package/dist/tdd/metadata/region-metadata.js +0 -37
- package/dist/tdd/runtime-context.js +78 -0
- package/dist/tdd/server-registry.js +38 -17
- package/dist/tdd/services/baseline-manager.js +23 -0
- package/dist/tdd/services/comparison-service.js +10 -1
- package/dist/tdd/services/hotspot-service.js +0 -13
- package/dist/tdd/tdd-service.js +144 -138
- package/dist/types/client.d.ts +36 -1
- package/dist/types/index.d.ts +114 -22
- package/dist/types/sdk.d.ts +40 -0
- package/dist/uploader/core.js +5 -4
- package/dist/uploader/index.js +79 -2
- package/dist/uploader/operations.js +40 -5
- package/dist/utils/api-url.js +14 -0
- package/dist/utils/async-utils.js +26 -0
- package/dist/utils/ci-env.js +19 -12
- package/dist/utils/config-loader.js +39 -65
- package/dist/utils/config-schema.js +19 -55
- package/dist/utils/fetch-utils.js +12 -5
- package/dist/utils/git.js +35 -72
- package/dist/utils/global-config.js +20 -1
- package/dist/utils/output.js +19 -7
- package/dist/utils/patterns.js +14 -0
- package/dist/utils/project-link-store.js +105 -0
- package/dist/utils/screenshot-options.js +28 -0
- package/dist/utils/secret-store.js +65 -0
- package/package.json +14 -11
- package/dist/services/screenshot-server.js +0 -65
- package/dist/services/server-manager.js +0 -81
- package/dist/services/uploader.js +0 -100
- package/dist/tdd/services/region-service.js +0 -75
- package/dist/utils/build-history.js +0 -103
package/dist/commands/api.js
CHANGED
|
@@ -5,6 +5,114 @@
|
|
|
5
5
|
import { createApiClient as defaultCreateApiClient } from '../api/index.js';
|
|
6
6
|
import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
|
|
7
7
|
import * as defaultOutput from '../utils/output.js';
|
|
8
|
+
let ALLOWED_POST_ENDPOINTS = [/^\/api\/sdk\/comparisons\/[^/]+\/approve$/, /^\/api\/sdk\/comparisons\/[^/]+\/reject$/, /^\/api\/sdk\/builds\/[^/]+\/comments$/];
|
|
9
|
+
function createApiCommandDeps(deps = {}) {
|
|
10
|
+
return {
|
|
11
|
+
loadConfig: deps.loadConfig || defaultLoadConfig,
|
|
12
|
+
createApiClient: deps.createApiClient || defaultCreateApiClient,
|
|
13
|
+
output: deps.output || defaultOutput,
|
|
14
|
+
exit: deps.exit || (code => process.exit(code))
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function configureOutput(output, globalOptions) {
|
|
18
|
+
output.configure({
|
|
19
|
+
json: globalOptions.json,
|
|
20
|
+
verbose: globalOptions.verbose,
|
|
21
|
+
color: !globalOptions.noColor
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
export function normalizeApiEndpoint(endpoint) {
|
|
25
|
+
let normalizedEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
|
26
|
+
if (!normalizedEndpoint.startsWith('/api/')) {
|
|
27
|
+
normalizedEndpoint = `/api${normalizedEndpoint}`;
|
|
28
|
+
}
|
|
29
|
+
return normalizedEndpoint;
|
|
30
|
+
}
|
|
31
|
+
export function normalizeApiMethod(method = 'GET') {
|
|
32
|
+
return method.toUpperCase();
|
|
33
|
+
}
|
|
34
|
+
export function isAllowedPostEndpoint(endpoint) {
|
|
35
|
+
return ALLOWED_POST_ENDPOINTS.some(pattern => pattern.test(endpoint));
|
|
36
|
+
}
|
|
37
|
+
export function parseApiHeaders(headerOption) {
|
|
38
|
+
let headers = {};
|
|
39
|
+
let headerList = Array.isArray(headerOption) ? headerOption : [headerOption];
|
|
40
|
+
for (let header of headerList.filter(Boolean)) {
|
|
41
|
+
let [key, ...valueParts] = header.split(':');
|
|
42
|
+
if (key && valueParts.length > 0) {
|
|
43
|
+
headers[key.trim()] = valueParts.join(':').trim();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return headers;
|
|
47
|
+
}
|
|
48
|
+
export function appendApiQuery(endpoint, queryOption) {
|
|
49
|
+
if (!queryOption) {
|
|
50
|
+
return endpoint;
|
|
51
|
+
}
|
|
52
|
+
let params = new URLSearchParams();
|
|
53
|
+
let queryList = Array.isArray(queryOption) ? queryOption : [queryOption];
|
|
54
|
+
for (let query of queryList) {
|
|
55
|
+
let [key, ...valueParts] = query.split('=');
|
|
56
|
+
if (key && valueParts.length > 0) {
|
|
57
|
+
params.append(key.trim(), valueParts.join('=').trim());
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
let queryString = params.toString();
|
|
61
|
+
if (!queryString) {
|
|
62
|
+
return endpoint;
|
|
63
|
+
}
|
|
64
|
+
return endpoint + (endpoint.includes('?') ? '&' : '?') + queryString;
|
|
65
|
+
}
|
|
66
|
+
export function validateApiRequest({
|
|
67
|
+
endpoint,
|
|
68
|
+
method
|
|
69
|
+
}) {
|
|
70
|
+
let errors = [];
|
|
71
|
+
if (method !== 'GET' && method !== 'POST') {
|
|
72
|
+
errors.push(`Method ${method} not allowed. Use GET for queries or POST for approve/reject/comment.`);
|
|
73
|
+
return errors;
|
|
74
|
+
}
|
|
75
|
+
if (method === 'POST' && !isAllowedPostEndpoint(endpoint)) {
|
|
76
|
+
errors.push(`POST not allowed for ${endpoint}. Only approve, reject, and comment endpoints support POST.`);
|
|
77
|
+
}
|
|
78
|
+
return errors;
|
|
79
|
+
}
|
|
80
|
+
export function buildApiRequest({
|
|
81
|
+
endpoint,
|
|
82
|
+
options = {}
|
|
83
|
+
}) {
|
|
84
|
+
let normalizedEndpoint = normalizeApiEndpoint(endpoint);
|
|
85
|
+
let method = normalizeApiMethod(options.method || 'GET');
|
|
86
|
+
let errors = validateApiRequest({
|
|
87
|
+
endpoint: normalizedEndpoint,
|
|
88
|
+
method
|
|
89
|
+
});
|
|
90
|
+
if (errors.length > 0) {
|
|
91
|
+
return {
|
|
92
|
+
errors,
|
|
93
|
+
method,
|
|
94
|
+
normalizedEndpoint,
|
|
95
|
+
requestOptions: null
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
let headers = parseApiHeaders(options.header);
|
|
99
|
+
let requestOptions = {
|
|
100
|
+
method
|
|
101
|
+
};
|
|
102
|
+
if (options.data && method === 'POST') {
|
|
103
|
+
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
|
|
104
|
+
requestOptions.body = options.data;
|
|
105
|
+
}
|
|
106
|
+
if (Object.keys(headers).length > 0) {
|
|
107
|
+
requestOptions.headers = headers;
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
errors: [],
|
|
111
|
+
method,
|
|
112
|
+
normalizedEndpoint: appendApiQuery(normalizedEndpoint, options.query),
|
|
113
|
+
requestOptions
|
|
114
|
+
};
|
|
115
|
+
}
|
|
8
116
|
|
|
9
117
|
/**
|
|
10
118
|
* API command - make raw API requests
|
|
@@ -15,16 +123,14 @@ import * as defaultOutput from '../utils/output.js';
|
|
|
15
123
|
*/
|
|
16
124
|
export async function apiCommand(endpoint, options = {}, globalOptions = {}, deps = {}) {
|
|
17
125
|
let {
|
|
18
|
-
loadConfig
|
|
19
|
-
createApiClient
|
|
20
|
-
output
|
|
21
|
-
exit
|
|
22
|
-
} = deps;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
color: !globalOptions.noColor
|
|
27
|
-
});
|
|
126
|
+
loadConfig,
|
|
127
|
+
createApiClient,
|
|
128
|
+
output,
|
|
129
|
+
exit
|
|
130
|
+
} = createApiCommandDeps(deps);
|
|
131
|
+
let displayEndpoint = normalizeApiEndpoint(endpoint);
|
|
132
|
+
let displayMethod = normalizeApiMethod(options.method || 'GET');
|
|
133
|
+
configureOutput(output, globalOptions);
|
|
28
134
|
try {
|
|
29
135
|
// Load configuration
|
|
30
136
|
let allOptions = {
|
|
@@ -36,71 +142,31 @@ export async function apiCommand(endpoint, options = {}, globalOptions = {}, dep
|
|
|
36
142
|
// Validate API token
|
|
37
143
|
if (!config.apiKey) {
|
|
38
144
|
output.error('API token required. Use --token or set VIZZLY_TOKEN environment variable');
|
|
145
|
+
output.cleanup();
|
|
39
146
|
exit(1);
|
|
40
147
|
return;
|
|
41
148
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (
|
|
54
|
-
output.error(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
output.error(`Method ${method} not allowed. Use GET for queries.`);
|
|
149
|
+
let {
|
|
150
|
+
errors,
|
|
151
|
+
method,
|
|
152
|
+
normalizedEndpoint,
|
|
153
|
+
requestOptions
|
|
154
|
+
} = buildApiRequest({
|
|
155
|
+
endpoint,
|
|
156
|
+
options
|
|
157
|
+
});
|
|
158
|
+
displayEndpoint = normalizedEndpoint;
|
|
159
|
+
displayMethod = method;
|
|
160
|
+
if (errors.length > 0) {
|
|
161
|
+
output.error(errors[0]);
|
|
162
|
+
if (method === 'POST') {
|
|
163
|
+
output.hint('Use GET for queries, or use dedicated commands (vizzly approve, vizzly reject, vizzly comment)');
|
|
164
|
+
}
|
|
165
|
+
output.hint('Most raw API use should stay read-only; prefer dedicated commands for mutations.');
|
|
166
|
+
output.cleanup();
|
|
61
167
|
exit(1);
|
|
62
168
|
return;
|
|
63
169
|
}
|
|
64
|
-
let requestOptions = {
|
|
65
|
-
method
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
// Add headers
|
|
69
|
-
let headers = {};
|
|
70
|
-
if (options.header) {
|
|
71
|
-
let headerList = Array.isArray(options.header) ? options.header : [options.header];
|
|
72
|
-
for (let h of headerList) {
|
|
73
|
-
let [key, ...valueParts] = h.split(':');
|
|
74
|
-
if (key && valueParts.length > 0) {
|
|
75
|
-
headers[key.trim()] = valueParts.join(':').trim();
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Add body for POST/PUT/PATCH
|
|
81
|
-
if (options.data && ['POST', 'PUT', 'PATCH'].includes(method)) {
|
|
82
|
-
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
|
|
83
|
-
requestOptions.body = options.data;
|
|
84
|
-
}
|
|
85
|
-
if (Object.keys(headers).length > 0) {
|
|
86
|
-
requestOptions.headers = headers;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Add query parameters
|
|
90
|
-
if (options.query) {
|
|
91
|
-
let params = new URLSearchParams();
|
|
92
|
-
let queryList = Array.isArray(options.query) ? options.query : [options.query];
|
|
93
|
-
for (let q of queryList) {
|
|
94
|
-
let [key, ...valueParts] = q.split('=');
|
|
95
|
-
if (key && valueParts.length > 0) {
|
|
96
|
-
params.append(key.trim(), valueParts.join('=').trim());
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
let queryString = params.toString();
|
|
100
|
-
if (queryString) {
|
|
101
|
-
normalizedEndpoint += (normalizedEndpoint.includes('?') ? '&' : '?') + queryString;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
170
|
|
|
105
171
|
// Make the request
|
|
106
172
|
output.startSpinner(`${method} ${normalizedEndpoint}`);
|
|
@@ -140,8 +206,8 @@ export async function apiCommand(endpoint, options = {}, globalOptions = {}, dep
|
|
|
140
206
|
output.stopSpinner();
|
|
141
207
|
if (globalOptions.json) {
|
|
142
208
|
output.data({
|
|
143
|
-
endpoint,
|
|
144
|
-
method:
|
|
209
|
+
endpoint: displayEndpoint,
|
|
210
|
+
method: displayMethod,
|
|
145
211
|
error: {
|
|
146
212
|
message: error.message,
|
|
147
213
|
code: error.code,
|
|
@@ -158,19 +224,6 @@ export async function apiCommand(endpoint, options = {}, globalOptions = {}, dep
|
|
|
158
224
|
}
|
|
159
225
|
}
|
|
160
226
|
|
|
161
|
-
/**
|
|
162
|
-
* Allowed POST endpoints (whitelist for mutations)
|
|
163
|
-
* Most mutations should use dedicated commands, but these are allowed for raw API access
|
|
164
|
-
*/
|
|
165
|
-
const ALLOWED_POST_ENDPOINTS = [/^\/api\/sdk\/comparisons\/[^/]+\/approve$/, /^\/api\/sdk\/comparisons\/[^/]+\/reject$/, /^\/api\/sdk\/builds\/[^/]+\/comments$/];
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Check if a POST endpoint is allowed
|
|
169
|
-
*/
|
|
170
|
-
function isAllowedPostEndpoint(endpoint) {
|
|
171
|
-
return ALLOWED_POST_ENDPOINTS.some(pattern => pattern.test(endpoint));
|
|
172
|
-
}
|
|
173
|
-
|
|
174
227
|
/**
|
|
175
228
|
* Validate API command options
|
|
176
229
|
*/
|
|
@@ -179,12 +232,14 @@ export function validateApiOptions(endpoint, options = {}) {
|
|
|
179
232
|
if (!endpoint || endpoint.trim() === '') {
|
|
180
233
|
errors.push('Endpoint is required');
|
|
181
234
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
// Only GET is allowed by default
|
|
185
|
-
// POST is allowed only for whitelisted endpoints
|
|
186
|
-
if (method !== 'GET' && method !== 'POST') {
|
|
187
|
-
errors.push(`Method ${method} not allowed. Use GET for queries or POST for approve/reject/comment.`);
|
|
235
|
+
if (!endpoint || endpoint.trim() === '') {
|
|
236
|
+
return errors;
|
|
188
237
|
}
|
|
238
|
+
let normalizedEndpoint = normalizeApiEndpoint(endpoint);
|
|
239
|
+
let method = normalizeApiMethod(options.method || 'GET');
|
|
240
|
+
errors.push(...validateApiRequest({
|
|
241
|
+
endpoint: normalizedEndpoint,
|
|
242
|
+
method
|
|
243
|
+
}));
|
|
189
244
|
return errors;
|
|
190
245
|
}
|
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
* Baselines command - query local TDD baselines
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
5
|
+
import { existsSync as defaultExistsSync, readdirSync as defaultReaddirSync, statSync as defaultStatSync } from 'node:fs';
|
|
6
6
|
import { basename, join, resolve } from 'node:path';
|
|
7
|
-
import { loadBaselineMetadata } from '../tdd/metadata/baseline-metadata.js';
|
|
7
|
+
import { loadBaselineMetadata as defaultLoadBaselineMetadata } from '../tdd/metadata/baseline-metadata.js';
|
|
8
8
|
import * as defaultOutput from '../utils/output.js';
|
|
9
|
+
import { createWildcardMatcher } from '../utils/patterns.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Extract filename from a path
|
|
12
13
|
*/
|
|
13
|
-
function getFilename(screenshot) {
|
|
14
|
+
export function getFilename(screenshot) {
|
|
14
15
|
if (screenshot.filename) return screenshot.filename;
|
|
15
16
|
if (screenshot.path) return basename(screenshot.path);
|
|
16
17
|
return null;
|
|
@@ -19,7 +20,7 @@ function getFilename(screenshot) {
|
|
|
19
20
|
/**
|
|
20
21
|
* Extract viewport from screenshot properties
|
|
21
22
|
*/
|
|
22
|
-
function getViewport(screenshot) {
|
|
23
|
+
export function getViewport(screenshot) {
|
|
23
24
|
if (screenshot.viewport) return screenshot.viewport;
|
|
24
25
|
if (screenshot.properties?.viewport_width && screenshot.properties?.viewport_height) {
|
|
25
26
|
return {
|
|
@@ -29,6 +30,59 @@ function getViewport(screenshot) {
|
|
|
29
30
|
}
|
|
30
31
|
return null;
|
|
31
32
|
}
|
|
33
|
+
export function createBaselineNameMatcher(pattern) {
|
|
34
|
+
return createWildcardMatcher(pattern);
|
|
35
|
+
}
|
|
36
|
+
export function getBaselineFiles(baselinesDir, fs = {}) {
|
|
37
|
+
let {
|
|
38
|
+
existsSync = defaultExistsSync,
|
|
39
|
+
readdirSync = defaultReaddirSync,
|
|
40
|
+
statSync = defaultStatSync
|
|
41
|
+
} = fs;
|
|
42
|
+
if (!existsSync(baselinesDir)) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
return readdirSync(baselinesDir).filter(filename => filename.endsWith('.png')).map(filename => {
|
|
46
|
+
let filePath = join(baselinesDir, filename);
|
|
47
|
+
try {
|
|
48
|
+
let stat = statSync(filePath);
|
|
49
|
+
return {
|
|
50
|
+
filename,
|
|
51
|
+
path: filePath,
|
|
52
|
+
size: stat.size,
|
|
53
|
+
modifiedAt: stat.mtime.toISOString()
|
|
54
|
+
};
|
|
55
|
+
} catch {
|
|
56
|
+
// File was deleted between readdir and stat, skip it.
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}).filter(Boolean);
|
|
60
|
+
}
|
|
61
|
+
export function createBaselineList({
|
|
62
|
+
baselinesDir,
|
|
63
|
+
baselineFiles,
|
|
64
|
+
screenshots
|
|
65
|
+
}) {
|
|
66
|
+
return screenshots.map(screenshot => {
|
|
67
|
+
let filename = getFilename(screenshot);
|
|
68
|
+
let viewport = getViewport(screenshot);
|
|
69
|
+
let file = baselineFiles.find(candidate => candidate.filename === filename);
|
|
70
|
+
return {
|
|
71
|
+
name: screenshot.name,
|
|
72
|
+
signature: screenshot.signature,
|
|
73
|
+
filename,
|
|
74
|
+
path: screenshot.path || file?.path || getFallbackPath(baselinesDir, filename),
|
|
75
|
+
sha256: screenshot.sha256,
|
|
76
|
+
viewport,
|
|
77
|
+
browser: screenshot.browser || null,
|
|
78
|
+
createdAt: screenshot.createdAt,
|
|
79
|
+
fileSize: file?.size
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function getFallbackPath(baselinesDir, filename) {
|
|
84
|
+
return filename ? join(baselinesDir, filename) : null;
|
|
85
|
+
}
|
|
32
86
|
|
|
33
87
|
/**
|
|
34
88
|
* Baselines command - list and query local TDD baselines
|
|
@@ -40,7 +94,11 @@ export async function baselinesCommand(options = {}, globalOptions = {}, deps =
|
|
|
40
94
|
let {
|
|
41
95
|
output = defaultOutput,
|
|
42
96
|
exit = code => process.exit(code),
|
|
43
|
-
cwd = process.cwd
|
|
97
|
+
cwd = process.cwd,
|
|
98
|
+
existsSync = defaultExistsSync,
|
|
99
|
+
loadBaselineMetadata = defaultLoadBaselineMetadata,
|
|
100
|
+
readdirSync = defaultReaddirSync,
|
|
101
|
+
statSync = defaultStatSync
|
|
44
102
|
} = deps;
|
|
45
103
|
output.configure({
|
|
46
104
|
json: globalOptions.json,
|
|
@@ -71,33 +129,17 @@ export async function baselinesCommand(options = {}, globalOptions = {}, deps =
|
|
|
71
129
|
// Load metadata
|
|
72
130
|
let metadata = loadBaselineMetadata(baselinesDir);
|
|
73
131
|
let screenshots = metadata?.screenshots || [];
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
}
|
|
132
|
+
let baselineFiles = getBaselineFiles(baselinesDir, {
|
|
133
|
+
existsSync,
|
|
134
|
+
readdirSync,
|
|
135
|
+
statSync
|
|
136
|
+
});
|
|
94
137
|
|
|
95
138
|
// Filter by name if provided
|
|
96
139
|
if (options.name) {
|
|
97
|
-
let
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
baselineFiles = baselineFiles.filter(f => regex.test(f.filename));
|
|
140
|
+
let matchesName = createBaselineNameMatcher(options.name);
|
|
141
|
+
screenshots = screenshots.filter(s => matchesName(s.name));
|
|
142
|
+
baselineFiles = baselineFiles.filter(f => matchesName(f.filename));
|
|
101
143
|
}
|
|
102
144
|
|
|
103
145
|
// Get specific baseline info
|
|
@@ -125,7 +167,7 @@ export async function baselinesCommand(options = {}, globalOptions = {}, deps =
|
|
|
125
167
|
name: screenshot.name,
|
|
126
168
|
signature: screenshot.signature,
|
|
127
169
|
filename,
|
|
128
|
-
path: screenshot.path || file?.path ||
|
|
170
|
+
path: screenshot.path || file?.path || getFallbackPath(baselinesDir, filename),
|
|
129
171
|
sha256: screenshot.sha256,
|
|
130
172
|
viewport,
|
|
131
173
|
browser: screenshot.browser || null,
|
|
@@ -162,21 +204,10 @@ export async function baselinesCommand(options = {}, globalOptions = {}, deps =
|
|
|
162
204
|
|
|
163
205
|
// JSON output for list
|
|
164
206
|
if (globalOptions.json) {
|
|
165
|
-
let baselines =
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
};
|
|
207
|
+
let baselines = createBaselineList({
|
|
208
|
+
baselinesDir,
|
|
209
|
+
baselineFiles,
|
|
210
|
+
screenshots
|
|
180
211
|
});
|
|
181
212
|
output.data({
|
|
182
213
|
baselines,
|
|
@@ -208,7 +239,7 @@ export async function baselinesCommand(options = {}, globalOptions = {}, deps =
|
|
|
208
239
|
if (metadata.branch && metadata.branch !== 'local') {
|
|
209
240
|
output.labelValue('Branch', metadata.branch);
|
|
210
241
|
}
|
|
211
|
-
output.labelValue('Threshold', `${metadata.threshold
|
|
242
|
+
output.labelValue('Threshold', `${metadata.threshold ?? 2.0} CIEDE2000`);
|
|
212
243
|
output.blank();
|
|
213
244
|
}
|
|
214
245
|
output.labelValue('Count', String(screenshots.length));
|
|
@@ -251,7 +282,7 @@ export async function baselinesCommand(options = {}, globalOptions = {}, deps =
|
|
|
251
282
|
/**
|
|
252
283
|
* Format bytes to human readable
|
|
253
284
|
*/
|
|
254
|
-
function formatBytes(bytes) {
|
|
285
|
+
export function formatBytes(bytes) {
|
|
255
286
|
if (bytes < 1024) return `${bytes} B`;
|
|
256
287
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
257
288
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
package/dist/commands/builds.js
CHANGED
|
@@ -33,16 +33,17 @@ export async function buildsCommand(options = {}, globalOptions = {}, deps = {})
|
|
|
33
33
|
...options
|
|
34
34
|
};
|
|
35
35
|
let config = await loadConfig(globalOptions.config, allOptions);
|
|
36
|
+
let token = config.apiKey || config.userToken;
|
|
36
37
|
|
|
37
|
-
// Validate
|
|
38
|
-
if (!
|
|
39
|
-
output.error('
|
|
38
|
+
// Validate cloud auth
|
|
39
|
+
if (!token) {
|
|
40
|
+
output.error('Authentication required. Use --token, set VIZZLY_TOKEN, or run "vizzly login"');
|
|
40
41
|
exit(1);
|
|
41
42
|
return;
|
|
42
43
|
}
|
|
43
44
|
let client = createApiClient({
|
|
44
45
|
baseUrl: config.apiUrl,
|
|
45
|
-
token
|
|
46
|
+
token,
|
|
46
47
|
command: 'builds'
|
|
47
48
|
});
|
|
48
49
|
|
|
@@ -328,11 +329,11 @@ function getComparisonStatusIcon(colors, status) {
|
|
|
328
329
|
*/
|
|
329
330
|
export function validateBuildsOptions(options = {}) {
|
|
330
331
|
let errors = [];
|
|
331
|
-
if (options.limit && (Number.
|
|
332
|
-
errors.push('--limit must be
|
|
332
|
+
if (options.limit !== undefined && (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > 250)) {
|
|
333
|
+
errors.push('--limit must be an integer between 1 and 250');
|
|
333
334
|
}
|
|
334
|
-
if (options.offset && (Number.
|
|
335
|
-
errors.push('--offset must be a non-negative
|
|
335
|
+
if (options.offset !== undefined && (!Number.isInteger(options.offset) || options.offset < 0)) {
|
|
336
|
+
errors.push('--offset must be a non-negative integer');
|
|
336
337
|
}
|
|
337
338
|
return errors;
|
|
338
339
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { createApiClient as defaultCreateApiClient, getBuild as defaultGetBuild, getComparison as defaultGetComparison, searchComparisons as defaultSearchComparisons } from '../api/index.js';
|
|
6
6
|
import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
|
|
7
7
|
import * as defaultOutput from '../utils/output.js';
|
|
8
|
+
import { createWildcardMatcher } from '../utils/patterns.js';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Comparisons command - query comparisons with various filters
|
|
@@ -77,9 +78,8 @@ export async function comparisonsCommand(options = {}, globalOptions = {}, deps
|
|
|
77
78
|
|
|
78
79
|
// Apply name filter if provided
|
|
79
80
|
if (options.name) {
|
|
80
|
-
let
|
|
81
|
-
|
|
82
|
-
comparisons = comparisons.filter(c => regex.test(c.name));
|
|
81
|
+
let matchesName = createWildcardMatcher(options.name);
|
|
82
|
+
comparisons = comparisons.filter(c => matchesName(c.name));
|
|
83
83
|
}
|
|
84
84
|
if (globalOptions.json) {
|
|
85
85
|
output.data({
|
|
@@ -202,7 +202,6 @@ function formatComparisonForJson(comparison) {
|
|
|
202
202
|
* Display a single comparison in detail
|
|
203
203
|
*/
|
|
204
204
|
function displayComparison(output, comparison, verbose) {
|
|
205
|
-
let _colors = output.getColors();
|
|
206
205
|
output.header('comparison', comparison.status);
|
|
207
206
|
output.keyValue({
|
|
208
207
|
Name: comparison.name,
|
|
@@ -388,11 +387,11 @@ function getStatusIcon(colors, status) {
|
|
|
388
387
|
*/
|
|
389
388
|
export function validateComparisonsOptions(options = {}) {
|
|
390
389
|
let errors = [];
|
|
391
|
-
if (options.limit && (Number.
|
|
392
|
-
errors.push('--limit must be
|
|
390
|
+
if (options.limit !== undefined && (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > 250)) {
|
|
391
|
+
errors.push('--limit must be an integer between 1 and 250');
|
|
393
392
|
}
|
|
394
|
-
if (options.offset && (Number.
|
|
395
|
-
errors.push('--offset must be a non-negative
|
|
393
|
+
if (options.offset !== undefined && (!Number.isInteger(options.offset) || options.offset < 0)) {
|
|
394
|
+
errors.push('--offset must be a non-negative integer');
|
|
396
395
|
}
|
|
397
396
|
return errors;
|
|
398
397
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Config command - query and display configuration
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { CONFIG_DEFAULTS } from '../config/core.js';
|
|
5
6
|
import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
|
|
6
7
|
import * as defaultOutput from '../utils/output.js';
|
|
7
8
|
|
|
@@ -33,15 +34,10 @@ export async function configCommand(key = null, options = {}, globalOptions = {}
|
|
|
33
34
|
|
|
34
35
|
// Build the config object to display
|
|
35
36
|
let displayConfig = {
|
|
36
|
-
server: config.server ||
|
|
37
|
-
port: 47392,
|
|
38
|
-
timeout: 30000
|
|
39
|
-
},
|
|
37
|
+
server: config.server || CONFIG_DEFAULTS.server,
|
|
40
38
|
build: config.build || {},
|
|
41
39
|
upload: config.upload || {},
|
|
42
|
-
comparison: config.comparison ||
|
|
43
|
-
threshold: 2.0
|
|
44
|
-
},
|
|
40
|
+
comparison: config.comparison || CONFIG_DEFAULTS.comparison,
|
|
45
41
|
tdd: config.tdd || {}
|
|
46
42
|
};
|
|
47
43
|
|
|
@@ -53,6 +49,15 @@ export async function configCommand(key = null, options = {}, globalOptions = {}
|
|
|
53
49
|
tokenPrefix: `${config.apiKey.substring(0, 8)}...`
|
|
54
50
|
};
|
|
55
51
|
}
|
|
52
|
+
if (config.linkedProject) {
|
|
53
|
+
displayConfig.linkedProject = {
|
|
54
|
+
organization: config.linkedProject.organizationSlug,
|
|
55
|
+
project: config.linkedProject.projectSlug,
|
|
56
|
+
tokenPrefix: config.linkedProject.tokenPrefix,
|
|
57
|
+
storage: config.linkedProject.storage,
|
|
58
|
+
expiresAt: config.linkedProject.expiresAt || null
|
|
59
|
+
};
|
|
60
|
+
}
|
|
56
61
|
|
|
57
62
|
// If a specific key is requested, extract it
|
|
58
63
|
if (key) {
|
|
@@ -110,6 +115,9 @@ export async function configCommand(key = null, options = {}, globalOptions = {}
|
|
|
110
115
|
if (displayConfig.api) {
|
|
111
116
|
displaySection(output, 'API', displayConfig.api);
|
|
112
117
|
}
|
|
118
|
+
if (displayConfig.linkedProject) {
|
|
119
|
+
displaySection(output, 'Linked Project', displayConfig.linkedProject);
|
|
120
|
+
}
|
|
113
121
|
} else {
|
|
114
122
|
output.hint('Use --verbose to see all configuration options');
|
|
115
123
|
}
|