@vizzly-testing/cli
Version:
Visual review platform for UI developers and designers
321 lines (295 loc) • 9.02 kB
JavaScript
/**
* Config Service
* Manages configuration for the TDD dashboard settings page
*
* Provides read/write access to:
* - Merged config (read-only combination of all sources)
* - Project config (vizzly.config.js in working directory)
* - Global config (~/.vizzly/config.json)
*/
import { existsSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { cosmiconfigSync } from 'cosmiconfig';
import { loadGlobalConfig, saveGlobalConfig } from '../utils/global-config.js';
import * as output from '../utils/output.js';
/**
* Default configuration values
*/
let DEFAULT_CONFIG = {
comparison: {
threshold: 2.0,
minClusterSize: 2
},
server: {
port: 47392,
timeout: 30000
},
build: {
name: 'Build {timestamp}',
environment: 'test'
},
tdd: {
openReport: false
}
};
/**
* Create a config service instance
* @param {Object} options
* @param {string} options.workingDir - Working directory for project config
* @returns {Object} Config service with getConfig, updateConfig, validateConfig methods
*/
export function createConfigService({
workingDir
}) {
let projectConfigPath = null;
let projectConfigFormat = 'js'; // 'js' or 'json'
// Find project config file
let explorer = cosmiconfigSync('vizzly');
let searchResult = explorer.search(workingDir);
if (searchResult?.filepath) {
projectConfigPath = searchResult.filepath;
projectConfigFormat = searchResult.filepath.endsWith('.json') ? 'json' : 'js';
}
/**
* Get configuration by type
* @param {'merged'|'project'|'global'} type
* @returns {Promise<Object>}
*/
async function getConfig(type) {
if (type === 'merged') {
return getMergedConfig();
} else if (type === 'project') {
return getProjectConfig();
} else if (type === 'global') {
return getGlobalConfigData();
}
throw new Error(`Unknown config type: ${type}`);
}
/**
* Get merged configuration with source tracking
*/
async function getMergedConfig() {
let config = {
...DEFAULT_CONFIG
};
let sources = {};
// Layer 1: Global config
let globalConfig = await loadGlobalConfig();
if (globalConfig.settings) {
mergeWithTracking(config, globalConfig.settings, sources, 'global');
}
// Layer 2: Project config
if (projectConfigPath && existsSync(projectConfigPath)) {
try {
let result = explorer.load(projectConfigPath);
let projectConfig = result?.config?.default || result?.config || {};
mergeWithTracking(config, projectConfig, sources, 'project');
} catch (error) {
output.debug('config-service', `Error loading project config: ${error.message}`);
}
}
// Layer 3: Environment variables
if (process.env.VIZZLY_THRESHOLD) {
config.comparison.threshold = parseFloat(process.env.VIZZLY_THRESHOLD);
sources.comparison = 'env';
}
if (process.env.VIZZLY_MIN_CLUSTER_SIZE) {
config.comparison.minClusterSize = parseInt(process.env.VIZZLY_MIN_CLUSTER_SIZE, 10);
sources.comparison = 'env';
}
if (process.env.VIZZLY_PORT) {
config.server.port = parseInt(process.env.VIZZLY_PORT, 10);
sources.server = 'env';
}
return {
config,
sources
};
}
/**
* Get project-level configuration only
*/
async function getProjectConfig() {
if (!projectConfigPath || !existsSync(projectConfigPath)) {
return {
config: {},
path: null
};
}
try {
let result = explorer.load(projectConfigPath);
let config = result?.config?.default || result?.config || {};
return {
config,
path: projectConfigPath
};
} catch (error) {
output.debug('config-service', `Error loading project config: ${error.message}`);
return {
config: {},
path: projectConfigPath,
error: error.message
};
}
}
/**
* Get global configuration only
*/
async function getGlobalConfigData() {
let globalConfig = await loadGlobalConfig();
return {
config: globalConfig.settings || {},
path: join(process.env.VIZZLY_HOME || join(process.env.HOME || '', '.vizzly'), 'config.json')
};
}
/**
* Update configuration by type
* @param {'project'|'global'} type
* @param {Object} updates - Config updates to apply
* @returns {Promise<Object>}
*/
async function updateConfig(type, updates) {
if (type === 'project') {
return updateProjectConfig(updates);
} else if (type === 'global') {
return updateGlobalConfig(updates);
}
throw new Error(`Cannot update config type: ${type}`);
}
/**
* Update project configuration (vizzly.config.js)
*/
async function updateProjectConfig(updates) {
// If no project config exists, create one
if (!projectConfigPath) {
projectConfigPath = join(workingDir, 'vizzly.config.js');
projectConfigFormat = 'js';
}
// Read existing config
let existingConfig = {};
if (existsSync(projectConfigPath)) {
try {
let result = explorer.load(projectConfigPath);
existingConfig = result?.config?.default || result?.config || {};
} catch {
// Start fresh if corrupted
}
}
// Merge updates
let newConfig = mergeDeep(existingConfig, updates);
// Write based on format
if (projectConfigFormat === 'json') {
await writeFile(projectConfigPath, JSON.stringify(newConfig, null, 2));
} else {
// Write as ES module
let content = `import { defineConfig } from '@vizzly-testing/cli/config';
export default defineConfig(${JSON.stringify(newConfig, null, 2)});
`;
await writeFile(projectConfigPath, content);
}
// Clear cosmiconfig cache so next read gets fresh data
explorer.clearCaches();
return {
success: true,
path: projectConfigPath
};
}
/**
* Update global configuration (~/.vizzly/config.json)
*/
async function updateGlobalConfig(updates) {
let globalConfig = await loadGlobalConfig();
if (!globalConfig.settings) {
globalConfig.settings = {};
}
globalConfig.settings = mergeDeep(globalConfig.settings, updates);
await saveGlobalConfig(globalConfig);
return {
success: true
};
}
/**
* Validate configuration
* @param {Object} config - Config to validate
* @returns {Promise<Object>}
*/
async function validateConfig(config) {
let errors = [];
let warnings = [];
// Validate threshold
if (config.comparison?.threshold !== undefined) {
let threshold = config.comparison.threshold;
if (typeof threshold !== 'number' || threshold < 0) {
errors.push('comparison.threshold must be a non-negative number');
} else if (threshold > 100) {
warnings.push('comparison.threshold above 100 may cause all comparisons to pass');
}
}
// Validate minClusterSize
if (config.comparison?.minClusterSize !== undefined) {
let minClusterSize = config.comparison.minClusterSize;
if (!Number.isInteger(minClusterSize) || minClusterSize < 1) {
errors.push('comparison.minClusterSize must be a positive integer (1 or greater)');
} else if (minClusterSize > 100) {
warnings.push('comparison.minClusterSize above 100 may filter out most differences');
}
}
// Validate port
if (config.server?.port !== undefined) {
let port = config.server.port;
if (!Number.isInteger(port) || port < 1 || port > 65535) {
errors.push('server.port must be an integer between 1 and 65535');
} else if (port < 1024) {
warnings.push('server.port below 1024 may require elevated privileges');
}
}
// Validate timeout
if (config.server?.timeout !== undefined) {
let timeout = config.server.timeout;
if (!Number.isInteger(timeout) || timeout < 0) {
errors.push('server.timeout must be a non-negative integer');
}
}
return {
valid: errors.length === 0,
errors,
warnings
};
}
return {
getConfig,
updateConfig,
validateConfig
};
}
/**
* Deep merge two objects
*/
function mergeDeep(target, source) {
let result = {
...target
};
for (let key in source) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
result[key] = mergeDeep(result[key] || {}, source[key]);
} else {
result[key] = source[key];
}
}
return result;
}
/**
* Merge config with source tracking
*/
function mergeWithTracking(target, source, sources, sourceName) {
for (let key in source) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
if (!target[key]) target[key] = {};
mergeWithTracking(target[key], source[key], sources, sourceName);
} else {
target[key] = source[key];
sources[key] = sourceName;
}
}
}