@vizzly-testing/cli
Version:
Visual review platform for UI developers and designers
438 lines (404 loc) • 12.1 kB
JavaScript
/**
* Config Core - Pure functions for configuration logic
*
* No I/O, no side effects - just data transformations.
*/
import { VizzlyError } from '../errors/vizzly-error.js';
// ============================================================================
// Default Configuration
// ============================================================================
/**
* Default configuration values
*/
export const CONFIG_DEFAULTS = {
apiUrl: 'https://app.vizzly.dev',
server: {
port: 47392,
timeout: 30000
},
build: {
name: 'Build {timestamp}',
environment: 'test'
},
upload: {
screenshotsDir: './screenshots',
batchSize: 10,
timeout: 30000
},
comparison: {
threshold: 2.0
},
tdd: {
openReport: false
},
plugins: []
};
/**
* Valid config scopes for reading
*/
export const READ_SCOPES = ['project', 'global', 'merged'];
/**
* Valid config scopes for writing
*/
export const WRITE_SCOPES = ['project', 'global'];
// ============================================================================
// Scope Validation
// ============================================================================
/**
* Validate that a scope is valid for reading
* @param {string} scope - Scope to validate
* @returns {{ valid: boolean, error: Error|null }}
*/
export function validateReadScope(scope) {
if (!READ_SCOPES.includes(scope)) {
return {
valid: false,
error: new VizzlyError(`Invalid config scope: ${scope}. Must be 'project', 'global', or 'merged'`, 'INVALID_CONFIG_SCOPE')
};
}
return {
valid: true,
error: null
};
}
/**
* Validate that a scope is valid for writing
* @param {string} scope - Scope to validate
* @returns {{ valid: boolean, error: Error|null }}
*/
export function validateWriteScope(scope) {
if (!WRITE_SCOPES.includes(scope)) {
return {
valid: false,
error: new VizzlyError(`Invalid config scope for update: ${scope}. Must be 'project' or 'global'`, 'INVALID_CONFIG_SCOPE')
};
}
return {
valid: true,
error: null
};
}
// ============================================================================
// Deep Merge
// ============================================================================
/**
* Deep merge two objects
* @param {Object} target - Target object
* @param {Object} source - Source object
* @returns {Object} Merged object (new object, inputs not mutated)
*/
export function deepMerge(target, source) {
let output = {
...target
};
for (let key of Object.keys(source)) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
if (target[key] && typeof target[key] === 'object' && !Array.isArray(target[key])) {
output[key] = deepMerge(target[key], source[key]);
} else {
output[key] = source[key];
}
} else {
output[key] = source[key];
}
}
return output;
}
// ============================================================================
// Config Merging with Source Tracking
// ============================================================================
/**
* Ensure value is a plain object, return empty object otherwise
* @param {*} value - Value to check
* @returns {Object} The value if it's an object, empty object otherwise
*/
function ensureObject(value) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value;
}
return {};
}
/**
* Build merged config from layers with source tracking
* @param {Object} options - Config layers
* @param {Object} options.projectConfig - Project config (from vizzly.config.js)
* @param {Object} options.globalConfig - Global config (from ~/.vizzly/config.json)
* @param {Object} [options.envOverrides] - Environment variable overrides
* @returns {{ config: Object, sources: Object }}
*/
export function buildMergedConfig({
projectConfig = {},
globalConfig = {},
envOverrides = {}
} = {}) {
// Ensure all inputs are plain objects
let safeProjectConfig = ensureObject(projectConfig);
let safeGlobalConfig = ensureObject(globalConfig);
let safeEnvOverrides = ensureObject(envOverrides);
let mergedConfig = {};
let sources = {};
// Layer 1: Defaults
for (let key of Object.keys(CONFIG_DEFAULTS)) {
mergedConfig[key] = CONFIG_DEFAULTS[key];
sources[key] = 'default';
}
// Layer 2: Global config (auth, project mappings, user preferences)
if (safeGlobalConfig.auth) {
mergedConfig.auth = safeGlobalConfig.auth;
sources.auth = 'global';
}
if (safeGlobalConfig.projects) {
mergedConfig.projects = safeGlobalConfig.projects;
sources.projects = 'global';
}
// Layer 3: Project config file
for (let key of Object.keys(safeProjectConfig)) {
mergedConfig[key] = safeProjectConfig[key];
sources[key] = 'project';
}
// Layer 4: Environment variables
for (let key of Object.keys(safeEnvOverrides)) {
mergedConfig[key] = safeEnvOverrides[key];
sources[key] = 'env';
}
return {
config: mergedConfig,
sources
};
}
/**
* Extract environment variable overrides
* @param {Object} env - Environment variables object (defaults to process.env)
* @returns {Object} Overrides from environment
*/
export function extractEnvOverrides(env = process.env) {
let overrides = {};
if (env.VIZZLY_TOKEN) {
overrides.apiKey = env.VIZZLY_TOKEN;
}
if (env.VIZZLY_API_URL) {
overrides.apiUrl = env.VIZZLY_API_URL;
}
return overrides;
}
// ============================================================================
// Config Result Building
// ============================================================================
/**
* Build a project config result object
* @param {Object|null} config - Config object or null if not found
* @param {string|null} filepath - Path to config file or null
* @returns {{ config: Object, filepath: string|null, isEmpty: boolean }}
*/
export function buildProjectConfigResult(config, filepath) {
if (!config) {
return {
config: {},
filepath: null,
isEmpty: true
};
}
return {
config,
filepath,
isEmpty: Object.keys(config).length === 0
};
}
/**
* Build a global config result object
* @param {Object} config - Global config object
* @param {string} filepath - Path to global config file
* @returns {{ config: Object, filepath: string, isEmpty: boolean }}
*/
export function buildGlobalConfigResult(config, filepath) {
return {
config,
filepath,
isEmpty: Object.keys(config).length === 0
};
}
/**
* Build a merged config result object
* @param {Object} options - Build options
* @returns {{ config: Object, sources: Object, projectFilepath: string|null, globalFilepath: string }}
*/
export function buildMergedConfigResult({
projectConfig,
globalConfig,
envOverrides,
projectFilepath,
globalFilepath
}) {
let {
config,
sources
} = buildMergedConfig({
projectConfig,
globalConfig,
envOverrides
});
return {
config,
sources,
projectFilepath,
globalFilepath
};
}
// ============================================================================
// Config Serialization
// ============================================================================
/**
* Stringify a value with proper indentation for JavaScript output
* @param {*} value - Value to stringify
* @param {number} depth - Current depth for indentation
* @returns {string} JavaScript representation of value
*/
export function stringifyWithIndent(value, depth = 0) {
let indent = ' '.repeat(depth);
let prevIndent = depth > 0 ? ' '.repeat(depth - 1) : '';
if (value === null || value === undefined) {
return String(value);
}
if (typeof value === 'string') {
return `'${value.replace(/'/g, "\\'")}'`;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
if (Array.isArray(value)) {
if (value.length === 0) return '[]';
let items = value.map(item => `${indent}${stringifyWithIndent(item, depth + 1)}`);
return `[\n${items.join(',\n')}\n${prevIndent}]`;
}
if (typeof value === 'object') {
let keys = Object.keys(value);
if (keys.length === 0) return '{}';
let items = keys.map(key => {
let val = stringifyWithIndent(value[key], depth + 1);
return `${indent}${key}: ${val}`;
});
return `{\n${items.join(',\n')}\n${prevIndent}}`;
}
return String(value);
}
/**
* Serialize config to JavaScript module format
* @param {Object} config - Config object to serialize
* @returns {string} JavaScript source code
*/
export function serializeToJavaScript(config) {
let lines = ['/**', ' * Vizzly Configuration', ' * @see https://docs.vizzly.dev/cli/configuration', ' */', '', "import { defineConfig } from '@vizzly-testing/cli/config';", '', 'export default defineConfig(', stringifyWithIndent(config, 1), ');', ''];
return lines.join('\n');
}
/**
* Serialize config to JSON format
* @param {Object} config - Config object to serialize
* @returns {string} JSON string with 2-space indentation
*/
export function serializeToJson(config) {
return JSON.stringify(config, null, 2);
}
/**
* Determine the serialization format based on filepath
* @param {string} filepath - Path to config file
* @returns {'javascript'|'json'|'package'|'unknown'} Format type
*/
export function getConfigFormat(filepath) {
if (filepath.endsWith('.js') || filepath.endsWith('.mjs')) {
return 'javascript';
}
if (filepath.endsWith('.json') && !filepath.endsWith('package.json')) {
return 'json';
}
if (filepath.endsWith('package.json')) {
return 'package';
}
return 'unknown';
}
/**
* Serialize config for writing to file
* @param {Object} config - Config object to serialize
* @param {string} filepath - Target file path
* @returns {{ content: string|null, format: string, error: Error|null }}
*/
export function serializeConfig(config, filepath) {
let format = getConfigFormat(filepath);
if (format === 'javascript') {
return {
content: serializeToJavaScript(config),
format,
error: null
};
}
if (format === 'json') {
return {
content: serializeToJson(config),
format,
error: null
};
}
if (format === 'package') {
// Can't serialize standalone, need existing package.json
return {
content: null,
format,
error: null
};
}
return {
content: null,
format,
error: new VizzlyError(`Unsupported config file format: ${filepath}`, 'UNSUPPORTED_CONFIG_FORMAT')
};
}
// ============================================================================
// Config Extraction
// ============================================================================
/**
* Extract config from cosmiconfig result (handles .default exports)
* @param {Object|null} result - Cosmiconfig result
* @returns {{ config: Object|null, filepath: string|null }}
*/
export function extractCosmiconfigResult(result) {
if (!result || !result.config) {
return {
config: null,
filepath: null
};
}
// Handle both `export default` and `module.exports`
let config = result.config.default || result.config;
return {
config,
filepath: result.filepath
};
}
// ============================================================================
// Validation Result Building
// ============================================================================
/**
* Build a validation success result
* @param {Object} validatedConfig - Validated config
* @returns {{ valid: true, config: Object, errors: [] }}
*/
export function buildValidationSuccess(validatedConfig) {
return {
valid: true,
config: validatedConfig,
errors: []
};
}
/**
* Build a validation failure result
* @param {Error} error - Validation error
* @returns {{ valid: false, config: null, errors: Array }}
*/
export function buildValidationFailure(error) {
return {
valid: false,
config: null,
errors: error.errors || [{
message: error.message
}]
};
}