@vizzly-testing/cli 0.20.1-beta.1 → 0.20.1

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,306 @@
1
+ /**
2
+ * Config Service
3
+ * Manages configuration for the TDD dashboard settings page
4
+ *
5
+ * Provides read/write access to:
6
+ * - Merged config (read-only combination of all sources)
7
+ * - Project config (vizzly.config.js in working directory)
8
+ * - Global config (~/.vizzly/config.json)
9
+ */
10
+
11
+ import { existsSync } from 'node:fs';
12
+ import { writeFile } from 'node:fs/promises';
13
+ import { join } from 'node:path';
14
+ import { cosmiconfigSync } from 'cosmiconfig';
15
+ import { loadGlobalConfig, saveGlobalConfig } from '../utils/global-config.js';
16
+ import * as output from '../utils/output.js';
17
+
18
+ /**
19
+ * Default configuration values
20
+ */
21
+ let DEFAULT_CONFIG = {
22
+ comparison: {
23
+ threshold: 2.0
24
+ },
25
+ server: {
26
+ port: 47392,
27
+ timeout: 30000
28
+ },
29
+ build: {
30
+ name: 'Build {timestamp}',
31
+ environment: 'test'
32
+ },
33
+ tdd: {
34
+ openReport: false
35
+ }
36
+ };
37
+
38
+ /**
39
+ * Create a config service instance
40
+ * @param {Object} options
41
+ * @param {string} options.workingDir - Working directory for project config
42
+ * @returns {Object} Config service with getConfig, updateConfig, validateConfig methods
43
+ */
44
+ export function createConfigService({
45
+ workingDir
46
+ }) {
47
+ let projectConfigPath = null;
48
+ let projectConfigFormat = 'js'; // 'js' or 'json'
49
+
50
+ // Find project config file
51
+ let explorer = cosmiconfigSync('vizzly');
52
+ let searchResult = explorer.search(workingDir);
53
+ if (searchResult?.filepath) {
54
+ projectConfigPath = searchResult.filepath;
55
+ projectConfigFormat = searchResult.filepath.endsWith('.json') ? 'json' : 'js';
56
+ }
57
+
58
+ /**
59
+ * Get configuration by type
60
+ * @param {'merged'|'project'|'global'} type
61
+ * @returns {Promise<Object>}
62
+ */
63
+ async function getConfig(type) {
64
+ if (type === 'merged') {
65
+ return getMergedConfig();
66
+ } else if (type === 'project') {
67
+ return getProjectConfig();
68
+ } else if (type === 'global') {
69
+ return getGlobalConfigData();
70
+ }
71
+ throw new Error(`Unknown config type: ${type}`);
72
+ }
73
+
74
+ /**
75
+ * Get merged configuration with source tracking
76
+ */
77
+ async function getMergedConfig() {
78
+ let config = {
79
+ ...DEFAULT_CONFIG
80
+ };
81
+ let sources = {};
82
+
83
+ // Layer 1: Global config
84
+ let globalConfig = await loadGlobalConfig();
85
+ if (globalConfig.settings) {
86
+ mergeWithTracking(config, globalConfig.settings, sources, 'global');
87
+ }
88
+
89
+ // Layer 2: Project config
90
+ if (projectConfigPath && existsSync(projectConfigPath)) {
91
+ try {
92
+ let result = explorer.load(projectConfigPath);
93
+ let projectConfig = result?.config?.default || result?.config || {};
94
+ mergeWithTracking(config, projectConfig, sources, 'project');
95
+ } catch (error) {
96
+ output.debug('config-service', `Error loading project config: ${error.message}`);
97
+ }
98
+ }
99
+
100
+ // Layer 3: Environment variables
101
+ if (process.env.VIZZLY_THRESHOLD) {
102
+ config.comparison.threshold = parseFloat(process.env.VIZZLY_THRESHOLD);
103
+ sources.comparison = 'env';
104
+ }
105
+ if (process.env.VIZZLY_PORT) {
106
+ config.server.port = parseInt(process.env.VIZZLY_PORT, 10);
107
+ sources.server = 'env';
108
+ }
109
+ return {
110
+ config,
111
+ sources
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Get project-level configuration only
117
+ */
118
+ async function getProjectConfig() {
119
+ if (!projectConfigPath || !existsSync(projectConfigPath)) {
120
+ return {
121
+ config: {},
122
+ path: null
123
+ };
124
+ }
125
+ try {
126
+ let result = explorer.load(projectConfigPath);
127
+ let config = result?.config?.default || result?.config || {};
128
+ return {
129
+ config,
130
+ path: projectConfigPath
131
+ };
132
+ } catch (error) {
133
+ output.debug('config-service', `Error loading project config: ${error.message}`);
134
+ return {
135
+ config: {},
136
+ path: projectConfigPath,
137
+ error: error.message
138
+ };
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Get global configuration only
144
+ */
145
+ async function getGlobalConfigData() {
146
+ let globalConfig = await loadGlobalConfig();
147
+ return {
148
+ config: globalConfig.settings || {},
149
+ path: join(process.env.VIZZLY_HOME || join(process.env.HOME || '', '.vizzly'), 'config.json')
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Update configuration by type
155
+ * @param {'project'|'global'} type
156
+ * @param {Object} updates - Config updates to apply
157
+ * @returns {Promise<Object>}
158
+ */
159
+ async function updateConfig(type, updates) {
160
+ if (type === 'project') {
161
+ return updateProjectConfig(updates);
162
+ } else if (type === 'global') {
163
+ return updateGlobalConfig(updates);
164
+ }
165
+ throw new Error(`Cannot update config type: ${type}`);
166
+ }
167
+
168
+ /**
169
+ * Update project configuration (vizzly.config.js)
170
+ */
171
+ async function updateProjectConfig(updates) {
172
+ // If no project config exists, create one
173
+ if (!projectConfigPath) {
174
+ projectConfigPath = join(workingDir, 'vizzly.config.js');
175
+ projectConfigFormat = 'js';
176
+ }
177
+
178
+ // Read existing config
179
+ let existingConfig = {};
180
+ if (existsSync(projectConfigPath)) {
181
+ try {
182
+ let result = explorer.load(projectConfigPath);
183
+ existingConfig = result?.config?.default || result?.config || {};
184
+ } catch {
185
+ // Start fresh if corrupted
186
+ }
187
+ }
188
+
189
+ // Merge updates
190
+ let newConfig = mergeDeep(existingConfig, updates);
191
+
192
+ // Write based on format
193
+ if (projectConfigFormat === 'json') {
194
+ await writeFile(projectConfigPath, JSON.stringify(newConfig, null, 2));
195
+ } else {
196
+ // Write as ES module
197
+ let content = `import { defineConfig } from '@vizzly-testing/cli/config';
198
+
199
+ export default defineConfig(${JSON.stringify(newConfig, null, 2)});
200
+ `;
201
+ await writeFile(projectConfigPath, content);
202
+ }
203
+
204
+ // Clear cosmiconfig cache so next read gets fresh data
205
+ explorer.clearCaches();
206
+ return {
207
+ success: true,
208
+ path: projectConfigPath
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Update global configuration (~/.vizzly/config.json)
214
+ */
215
+ async function updateGlobalConfig(updates) {
216
+ let globalConfig = await loadGlobalConfig();
217
+ if (!globalConfig.settings) {
218
+ globalConfig.settings = {};
219
+ }
220
+ globalConfig.settings = mergeDeep(globalConfig.settings, updates);
221
+ await saveGlobalConfig(globalConfig);
222
+ return {
223
+ success: true
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Validate configuration
229
+ * @param {Object} config - Config to validate
230
+ * @returns {Promise<Object>}
231
+ */
232
+ async function validateConfig(config) {
233
+ let errors = [];
234
+ let warnings = [];
235
+
236
+ // Validate threshold
237
+ if (config.comparison?.threshold !== undefined) {
238
+ let threshold = config.comparison.threshold;
239
+ if (typeof threshold !== 'number' || threshold < 0) {
240
+ errors.push('comparison.threshold must be a non-negative number');
241
+ } else if (threshold > 100) {
242
+ warnings.push('comparison.threshold above 100 may cause all comparisons to pass');
243
+ }
244
+ }
245
+
246
+ // Validate port
247
+ if (config.server?.port !== undefined) {
248
+ let port = config.server.port;
249
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
250
+ errors.push('server.port must be an integer between 1 and 65535');
251
+ } else if (port < 1024) {
252
+ warnings.push('server.port below 1024 may require elevated privileges');
253
+ }
254
+ }
255
+
256
+ // Validate timeout
257
+ if (config.server?.timeout !== undefined) {
258
+ let timeout = config.server.timeout;
259
+ if (!Number.isInteger(timeout) || timeout < 0) {
260
+ errors.push('server.timeout must be a non-negative integer');
261
+ }
262
+ }
263
+ return {
264
+ valid: errors.length === 0,
265
+ errors,
266
+ warnings
267
+ };
268
+ }
269
+ return {
270
+ getConfig,
271
+ updateConfig,
272
+ validateConfig
273
+ };
274
+ }
275
+
276
+ /**
277
+ * Deep merge two objects
278
+ */
279
+ function mergeDeep(target, source) {
280
+ let result = {
281
+ ...target
282
+ };
283
+ for (let key in source) {
284
+ if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
285
+ result[key] = mergeDeep(result[key] || {}, source[key]);
286
+ } else {
287
+ result[key] = source[key];
288
+ }
289
+ }
290
+ return result;
291
+ }
292
+
293
+ /**
294
+ * Merge config with source tracking
295
+ */
296
+ function mergeWithTracking(target, source, sources, sourceName) {
297
+ for (let key in source) {
298
+ if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
299
+ if (!target[key]) target[key] = {};
300
+ mergeWithTracking(target[key], source[key], sources, sourceName);
301
+ } else {
302
+ target[key] = source[key];
303
+ sources[key] = sourceName;
304
+ }
305
+ }
306
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizzly-testing/cli",
3
- "version": "0.20.1-beta.1",
3
+ "version": "0.20.1",
4
4
  "description": "Visual review platform for UI developers and designers",
5
5
  "keywords": [
6
6
  "visual-testing",
@@ -119,6 +119,7 @@
119
119
  "@tanstack/react-query": "^5.90.11",
120
120
  "@types/node": "^25.0.2",
121
121
  "@vitejs/plugin-react": "^5.0.3",
122
+ "@vizzly-testing/observatory": "^0.2.1",
122
123
  "autoprefixer": "^10.4.21",
123
124
  "babel-plugin-transform-remove-console": "^6.9.4",
124
125
  "postcss": "^8.5.6",