mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-11 23:02:56 +01:00
Added custom json edit to config tab
This commit is contained in:
226
packages/noodl-runtime/src/config/config-manager.test.ts
Normal file
226
packages/noodl-runtime/src/config/config-manager.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Tests for ConfigManager
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from '@jest/globals';
|
||||
|
||||
import { ConfigManager } from './config-manager';
|
||||
import { DEFAULT_APP_CONFIG } from './types';
|
||||
|
||||
describe('ConfigManager', () => {
|
||||
let manager: ConfigManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = ConfigManager.getInstance();
|
||||
manager.reset(); // Reset state between tests
|
||||
});
|
||||
|
||||
describe('singleton pattern', () => {
|
||||
it('should return the same instance', () => {
|
||||
const instance1 = ConfigManager.getInstance();
|
||||
const instance2 = ConfigManager.getInstance();
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
it('should initialize with default config', () => {
|
||||
manager.initialize({});
|
||||
const config = manager.getRawConfig();
|
||||
expect(config.identity.appName).toBe(DEFAULT_APP_CONFIG.identity.appName);
|
||||
});
|
||||
|
||||
it('should merge partial config with defaults', () => {
|
||||
manager.initialize({
|
||||
identity: {
|
||||
appName: 'My App',
|
||||
description: 'Test'
|
||||
}
|
||||
});
|
||||
const config = manager.getRawConfig();
|
||||
expect(config.identity.appName).toBe('My App');
|
||||
expect(config.variables).toEqual([]);
|
||||
});
|
||||
|
||||
it('should store custom variables', () => {
|
||||
manager.initialize({
|
||||
identity: DEFAULT_APP_CONFIG.identity,
|
||||
seo: {},
|
||||
variables: [{ key: 'apiKey', type: 'string', value: 'secret123' }]
|
||||
});
|
||||
const config = manager.getRawConfig();
|
||||
expect(config.variables.length).toBe(1);
|
||||
expect(config.variables[0].key).toBe('apiKey');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConfig', () => {
|
||||
it('should return flattened config object', () => {
|
||||
manager.initialize({
|
||||
identity: {
|
||||
appName: 'Test App',
|
||||
description: 'Test Description'
|
||||
},
|
||||
seo: {},
|
||||
variables: [
|
||||
{ key: 'apiKey', type: 'string', value: 'key123' },
|
||||
{ key: 'maxRetries', type: 'number', value: 3 }
|
||||
]
|
||||
});
|
||||
|
||||
const config = manager.getConfig();
|
||||
expect(config.appName).toBe('Test App');
|
||||
expect(config.description).toBe('Test Description');
|
||||
expect(config.apiKey).toBe('key123');
|
||||
expect(config.maxRetries).toBe(3);
|
||||
});
|
||||
|
||||
it('should apply smart defaults for SEO', () => {
|
||||
manager.initialize({
|
||||
identity: {
|
||||
appName: 'Test App',
|
||||
description: 'Test Description',
|
||||
coverImage: 'cover.jpg'
|
||||
},
|
||||
seo: {}, // Empty SEO
|
||||
variables: []
|
||||
});
|
||||
|
||||
const config = manager.getConfig();
|
||||
// Should default to identity values
|
||||
expect(config.ogTitle).toBe('Test App');
|
||||
expect(config.ogDescription).toBe('Test Description');
|
||||
expect(config.ogImage).toBe('cover.jpg');
|
||||
});
|
||||
|
||||
it('should use explicit SEO values when provided', () => {
|
||||
manager.initialize({
|
||||
identity: {
|
||||
appName: 'Test App',
|
||||
description: 'Test Description'
|
||||
},
|
||||
seo: {
|
||||
ogTitle: 'Custom OG Title',
|
||||
ogDescription: 'Custom OG Description'
|
||||
},
|
||||
variables: []
|
||||
});
|
||||
|
||||
const config = manager.getConfig();
|
||||
expect(config.ogTitle).toBe('Custom OG Title');
|
||||
expect(config.ogDescription).toBe('Custom OG Description');
|
||||
});
|
||||
|
||||
it('should return frozen object', () => {
|
||||
manager.initialize({
|
||||
identity: DEFAULT_APP_CONFIG.identity,
|
||||
seo: {},
|
||||
variables: []
|
||||
});
|
||||
|
||||
const config = manager.getConfig();
|
||||
expect(Object.isFrozen(config)).toBe(true);
|
||||
|
||||
// Attempt to modify should fail silently (or throw in strict mode)
|
||||
expect(() => {
|
||||
(config as any).appName = 'Modified';
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVariable', () => {
|
||||
beforeEach(() => {
|
||||
manager.initialize({
|
||||
identity: DEFAULT_APP_CONFIG.identity,
|
||||
seo: {},
|
||||
variables: [
|
||||
{ key: 'apiKey', type: 'string', value: 'key123', description: 'API Key' },
|
||||
{ key: 'maxRetries', type: 'number', value: 3 }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('should return variable by key', () => {
|
||||
const variable = manager.getVariable('apiKey');
|
||||
expect(variable).toBeDefined();
|
||||
expect(variable?.value).toBe('key123');
|
||||
expect(variable?.description).toBe('API Key');
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent key', () => {
|
||||
const variable = manager.getVariable('nonExistent');
|
||||
expect(variable).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVariableKeys', () => {
|
||||
it('should return all variable keys', () => {
|
||||
manager.initialize({
|
||||
identity: DEFAULT_APP_CONFIG.identity,
|
||||
seo: {},
|
||||
variables: [
|
||||
{ key: 'apiKey', type: 'string', value: 'key123' },
|
||||
{ key: 'maxRetries', type: 'number', value: 3 },
|
||||
{ key: 'enabled', type: 'boolean', value: true }
|
||||
]
|
||||
});
|
||||
|
||||
const keys = manager.getVariableKeys();
|
||||
expect(keys).toEqual(['apiKey', 'maxRetries', 'enabled']);
|
||||
});
|
||||
|
||||
it('should return empty array when no variables', () => {
|
||||
manager.initialize({
|
||||
identity: DEFAULT_APP_CONFIG.identity,
|
||||
seo: {},
|
||||
variables: []
|
||||
});
|
||||
|
||||
const keys = manager.getVariableKeys();
|
||||
expect(keys).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('immutability', () => {
|
||||
it('should not allow mutation of returned config', () => {
|
||||
manager.initialize({
|
||||
identity: {
|
||||
appName: 'Original',
|
||||
description: ''
|
||||
},
|
||||
seo: {},
|
||||
variables: [{ key: 'apiKey', type: 'string', value: 'original' }]
|
||||
});
|
||||
|
||||
const config1 = manager.getConfig();
|
||||
|
||||
// Attempt mutations should fail
|
||||
expect(() => {
|
||||
(config1 as any).apiKey = 'modified';
|
||||
}).toThrow();
|
||||
|
||||
// Getting config again should still have original value
|
||||
const config2 = manager.getConfig();
|
||||
expect(config2.apiKey).toBe('original');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should reset to default state', () => {
|
||||
manager.initialize({
|
||||
identity: {
|
||||
appName: 'Custom App',
|
||||
description: 'Custom'
|
||||
},
|
||||
seo: {},
|
||||
variables: [{ key: 'test', type: 'string', value: 'value' }]
|
||||
});
|
||||
|
||||
manager.reset();
|
||||
|
||||
const config = manager.getRawConfig();
|
||||
expect(config.identity.appName).toBe(DEFAULT_APP_CONFIG.identity.appName);
|
||||
expect(config.variables).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
167
packages/noodl-runtime/src/config/config-manager.ts
Normal file
167
packages/noodl-runtime/src/config/config-manager.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Configuration Manager
|
||||
*
|
||||
* Central manager for app configuration. Initializes config at app startup
|
||||
* and provides immutable access to configuration values via Noodl.Config.
|
||||
*
|
||||
* @module config/config-manager
|
||||
*/
|
||||
|
||||
import { AppConfig, ConfigVariable, DEFAULT_APP_CONFIG } from './types';
|
||||
|
||||
/**
|
||||
* Deep freezes an object recursively to prevent any mutation.
|
||||
*/
|
||||
function deepFreeze<T extends object>(obj: T): Readonly<T> {
|
||||
Object.keys(obj).forEach((key) => {
|
||||
const value = (obj as Record<string, unknown>)[key];
|
||||
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
||||
deepFreeze(value as object);
|
||||
}
|
||||
});
|
||||
return Object.freeze(obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* ConfigManager is a singleton that manages the app configuration.
|
||||
* It is initialized once at app startup with config from project metadata.
|
||||
*/
|
||||
class ConfigManager {
|
||||
private static instance: ConfigManager;
|
||||
private config: AppConfig = DEFAULT_APP_CONFIG;
|
||||
private frozenConfig: Readonly<Record<string, unknown>> | null = null;
|
||||
|
||||
private constructor() {
|
||||
// Private constructor for singleton
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the singleton instance.
|
||||
*/
|
||||
static getInstance(): ConfigManager {
|
||||
if (!ConfigManager.instance) {
|
||||
ConfigManager.instance = new ConfigManager();
|
||||
}
|
||||
return ConfigManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the configuration from project metadata.
|
||||
* This should be called once at app startup.
|
||||
*
|
||||
* @param config - Partial app config from project.json metadata
|
||||
*/
|
||||
initialize(config: Partial<AppConfig>): void {
|
||||
// Merge with defaults
|
||||
this.config = {
|
||||
...DEFAULT_APP_CONFIG,
|
||||
...config,
|
||||
identity: {
|
||||
...DEFAULT_APP_CONFIG.identity,
|
||||
...config.identity
|
||||
},
|
||||
seo: {
|
||||
...DEFAULT_APP_CONFIG.seo,
|
||||
...config.seo
|
||||
},
|
||||
variables: config.variables || []
|
||||
};
|
||||
|
||||
// Build and freeze the public config object
|
||||
this.frozenConfig = this.buildFrozenConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the immutable public configuration object.
|
||||
* This is what's exposed as Noodl.Config.
|
||||
*
|
||||
* @returns Frozen config object with all values
|
||||
*/
|
||||
getConfig(): Readonly<Record<string, unknown>> {
|
||||
if (!this.frozenConfig) {
|
||||
this.frozenConfig = this.buildFrozenConfig();
|
||||
}
|
||||
return this.frozenConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the raw configuration object (for editor use).
|
||||
* This includes the full structure with identity, seo, pwa, and variables.
|
||||
*
|
||||
* @returns The full app config object
|
||||
*/
|
||||
getRawConfig(): AppConfig {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a specific variable definition by key.
|
||||
*
|
||||
* @param key - The variable key
|
||||
* @returns The variable definition or undefined if not found
|
||||
*/
|
||||
getVariable(key: string): ConfigVariable | undefined {
|
||||
return this.config.variables.find((v) => v.key === key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all variable keys (for autocomplete/suggestions).
|
||||
*
|
||||
* @returns Array of all custom variable keys
|
||||
*/
|
||||
getVariableKeys(): string[] {
|
||||
return this.config.variables.map((v) => v.key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the flat, frozen config object exposed as Noodl.Config.
|
||||
* This flattens identity, SEO, PWA, and custom variables into a single object.
|
||||
*
|
||||
* @returns The frozen config object
|
||||
*/
|
||||
private buildFrozenConfig(): Readonly<Record<string, unknown>> {
|
||||
const config: Record<string, unknown> = {
|
||||
// Identity fields
|
||||
appName: this.config.identity.appName,
|
||||
description: this.config.identity.description,
|
||||
coverImage: this.config.identity.coverImage,
|
||||
|
||||
// SEO fields (with smart defaults)
|
||||
ogTitle: this.config.seo.ogTitle || this.config.identity.appName,
|
||||
ogDescription: this.config.seo.ogDescription || this.config.identity.description,
|
||||
ogImage: this.config.seo.ogImage || this.config.identity.coverImage,
|
||||
favicon: this.config.seo.favicon,
|
||||
themeColor: this.config.seo.themeColor,
|
||||
|
||||
// PWA fields
|
||||
pwaEnabled: this.config.pwa?.enabled ?? false,
|
||||
pwaShortName: this.config.pwa?.shortName,
|
||||
pwaDisplay: this.config.pwa?.display,
|
||||
pwaStartUrl: this.config.pwa?.startUrl,
|
||||
pwaBackgroundColor: this.config.pwa?.backgroundColor
|
||||
};
|
||||
|
||||
// Add custom variables
|
||||
for (const variable of this.config.variables) {
|
||||
config[variable.key] = variable.value;
|
||||
}
|
||||
|
||||
// Deep freeze to prevent any mutation
|
||||
return deepFreeze(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the config manager (primarily for testing).
|
||||
* @internal
|
||||
*/
|
||||
reset(): void {
|
||||
this.config = DEFAULT_APP_CONFIG;
|
||||
this.frozenConfig = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const configManager = ConfigManager.getInstance();
|
||||
|
||||
// Export class for testing
|
||||
export { ConfigManager };
|
||||
11
packages/noodl-runtime/src/config/index.ts
Normal file
11
packages/noodl-runtime/src/config/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* App Configuration Module
|
||||
*
|
||||
* Provides app-wide configuration accessible via Noodl.Config namespace.
|
||||
*
|
||||
* @module config
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
export * from './validation';
|
||||
export * from './config-manager';
|
||||
91
packages/noodl-runtime/src/config/types.ts
Normal file
91
packages/noodl-runtime/src/config/types.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* App Configuration Types
|
||||
*
|
||||
* Defines the structure for app-wide configuration values accessible via Noodl.Config.
|
||||
* Config values are static and immutable at runtime.
|
||||
*
|
||||
* @module config/types
|
||||
*/
|
||||
|
||||
export type ConfigType = 'string' | 'number' | 'boolean' | 'color' | 'array' | 'object';
|
||||
|
||||
export interface ConfigValidation {
|
||||
required?: boolean;
|
||||
pattern?: string; // Regex for strings
|
||||
min?: number; // For numbers
|
||||
max?: number; // For numbers
|
||||
}
|
||||
|
||||
export interface ConfigVariable {
|
||||
key: string;
|
||||
type: ConfigType;
|
||||
value: unknown;
|
||||
description?: string;
|
||||
category?: string;
|
||||
validation?: ConfigValidation;
|
||||
}
|
||||
|
||||
export interface AppIdentity {
|
||||
appName: string;
|
||||
description: string;
|
||||
coverImage?: string;
|
||||
}
|
||||
|
||||
export interface AppSEO {
|
||||
ogTitle?: string;
|
||||
ogDescription?: string;
|
||||
ogImage?: string;
|
||||
favicon?: string;
|
||||
themeColor?: string;
|
||||
}
|
||||
|
||||
export interface AppPWA {
|
||||
enabled: boolean;
|
||||
shortName?: string;
|
||||
startUrl: string;
|
||||
display: 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser';
|
||||
backgroundColor?: string;
|
||||
sourceIcon?: string;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
identity: AppIdentity;
|
||||
seo: AppSEO;
|
||||
pwa?: AppPWA;
|
||||
variables: ConfigVariable[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Default app configuration used when no config is defined.
|
||||
*/
|
||||
export const DEFAULT_APP_CONFIG: AppConfig = {
|
||||
identity: {
|
||||
appName: 'My Noodl App',
|
||||
description: ''
|
||||
},
|
||||
seo: {},
|
||||
variables: []
|
||||
};
|
||||
|
||||
/**
|
||||
* Reserved configuration keys that cannot be used for custom variables.
|
||||
* These are populated from identity, SEO, and PWA settings.
|
||||
*/
|
||||
export const RESERVED_CONFIG_KEYS = [
|
||||
// Identity
|
||||
'appName',
|
||||
'description',
|
||||
'coverImage',
|
||||
// SEO
|
||||
'ogTitle',
|
||||
'ogDescription',
|
||||
'ogImage',
|
||||
'favicon',
|
||||
'themeColor',
|
||||
// PWA
|
||||
'pwaEnabled',
|
||||
'pwaShortName',
|
||||
'pwaDisplay',
|
||||
'pwaStartUrl',
|
||||
'pwaBackgroundColor'
|
||||
] as const;
|
||||
197
packages/noodl-runtime/src/config/validation.test.ts
Normal file
197
packages/noodl-runtime/src/config/validation.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Tests for Config Validation
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
|
||||
import { DEFAULT_APP_CONFIG } from './types';
|
||||
import { validateConfigKey, validateConfigValue, validateAppConfig } from './validation';
|
||||
|
||||
describe('validateConfigKey', () => {
|
||||
it('should accept valid JavaScript identifiers', () => {
|
||||
expect(validateConfigKey('apiKey').valid).toBe(true);
|
||||
expect(validateConfigKey('API_KEY').valid).toBe(true);
|
||||
expect(validateConfigKey('_privateKey').valid).toBe(true);
|
||||
expect(validateConfigKey('$special').valid).toBe(true);
|
||||
expect(validateConfigKey('key123').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject empty keys', () => {
|
||||
const result = validateConfigKey('');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors[0]).toContain('cannot be empty');
|
||||
});
|
||||
|
||||
it('should reject invalid identifiers', () => {
|
||||
expect(validateConfigKey('123key').valid).toBe(false);
|
||||
expect(validateConfigKey('my-key').valid).toBe(false);
|
||||
expect(validateConfigKey('my key').valid).toBe(false);
|
||||
expect(validateConfigKey('my.key').valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject reserved keys', () => {
|
||||
const result = validateConfigKey('appName');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors[0]).toContain('reserved');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateConfigValue', () => {
|
||||
describe('number type', () => {
|
||||
it('should accept valid numbers', () => {
|
||||
expect(validateConfigValue(42, 'number').valid).toBe(true);
|
||||
expect(validateConfigValue(0, 'number').valid).toBe(true);
|
||||
expect(validateConfigValue(-10.5, 'number').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject non-numbers', () => {
|
||||
expect(validateConfigValue('42', 'number').valid).toBe(false);
|
||||
expect(validateConfigValue(NaN, 'number').valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should enforce min/max validation', () => {
|
||||
expect(validateConfigValue(5, 'number', { min: 0, max: 10 }).valid).toBe(true);
|
||||
expect(validateConfigValue(-1, 'number', { min: 0 }).valid).toBe(false);
|
||||
expect(validateConfigValue(11, 'number', { max: 10 }).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('string type', () => {
|
||||
it('should accept valid strings', () => {
|
||||
expect(validateConfigValue('hello', 'string').valid).toBe(true);
|
||||
expect(validateConfigValue('', 'string').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject non-strings', () => {
|
||||
expect(validateConfigValue(42, 'string').valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should enforce pattern validation', () => {
|
||||
const result = validateConfigValue('abc', 'string', { pattern: '^[0-9]+$' });
|
||||
expect(result.valid).toBe(false);
|
||||
|
||||
const validResult = validateConfigValue('123', 'string', { pattern: '^[0-9]+$' });
|
||||
expect(validResult.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('boolean type', () => {
|
||||
it('should accept booleans', () => {
|
||||
expect(validateConfigValue(true, 'boolean').valid).toBe(true);
|
||||
expect(validateConfigValue(false, 'boolean').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject non-booleans', () => {
|
||||
expect(validateConfigValue('true', 'boolean').valid).toBe(false);
|
||||
expect(validateConfigValue(1, 'boolean').valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('color type', () => {
|
||||
it('should accept valid hex colors', () => {
|
||||
expect(validateConfigValue('#ff0000', 'color').valid).toBe(true);
|
||||
expect(validateConfigValue('#FF0000', 'color').valid).toBe(true);
|
||||
expect(validateConfigValue('#ff0000ff', 'color').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid colors', () => {
|
||||
expect(validateConfigValue('red', 'color').valid).toBe(false);
|
||||
expect(validateConfigValue('#ff00', 'color').valid).toBe(false);
|
||||
expect(validateConfigValue('ff0000', 'color').valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('array type', () => {
|
||||
it('should accept arrays', () => {
|
||||
expect(validateConfigValue([], 'array').valid).toBe(true);
|
||||
expect(validateConfigValue([1, 2, 3], 'array').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject non-arrays', () => {
|
||||
expect(validateConfigValue('[]', 'array').valid).toBe(false);
|
||||
expect(validateConfigValue({}, 'array').valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('object type', () => {
|
||||
it('should accept objects', () => {
|
||||
expect(validateConfigValue({}, 'object').valid).toBe(true);
|
||||
expect(validateConfigValue({ key: 'value' }, 'object').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject non-objects', () => {
|
||||
expect(validateConfigValue([], 'object').valid).toBe(false);
|
||||
expect(validateConfigValue(null, 'object').valid).toBe(false);
|
||||
expect(validateConfigValue('{}', 'object').valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('required validation', () => {
|
||||
it('should require non-empty values', () => {
|
||||
expect(validateConfigValue('', 'string', { required: true }).valid).toBe(false);
|
||||
expect(validateConfigValue(null, 'string', { required: true }).valid).toBe(false);
|
||||
expect(validateConfigValue(undefined, 'string', { required: true }).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow empty values when not required', () => {
|
||||
expect(validateConfigValue('', 'string').valid).toBe(true);
|
||||
expect(validateConfigValue(null, 'string').valid).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAppConfig', () => {
|
||||
it('should accept valid config', () => {
|
||||
const config = {
|
||||
...DEFAULT_APP_CONFIG,
|
||||
identity: {
|
||||
appName: 'Test App',
|
||||
description: 'Test Description'
|
||||
}
|
||||
};
|
||||
expect(validateAppConfig(config).valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should require app name', () => {
|
||||
const config = {
|
||||
...DEFAULT_APP_CONFIG,
|
||||
identity: {
|
||||
appName: '',
|
||||
description: ''
|
||||
}
|
||||
};
|
||||
const result = validateAppConfig(config);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors[0]).toContain('App name');
|
||||
});
|
||||
|
||||
it('should detect duplicate variable keys', () => {
|
||||
const config = {
|
||||
...DEFAULT_APP_CONFIG,
|
||||
identity: { appName: 'Test', description: '' },
|
||||
variables: [
|
||||
{ key: 'apiKey', type: 'string' as const, value: 'key1' },
|
||||
{ key: 'apiKey', type: 'string' as const, value: 'key2' }
|
||||
]
|
||||
};
|
||||
const result = validateAppConfig(config);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes('Duplicate'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate individual variables', () => {
|
||||
const config = {
|
||||
...DEFAULT_APP_CONFIG,
|
||||
identity: { appName: 'Test', description: '' },
|
||||
variables: [{ key: 'my-invalid-key', type: 'string' as const, value: 'test' }]
|
||||
};
|
||||
const result = validateAppConfig(config);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject non-object configs', () => {
|
||||
const result = validateAppConfig(null);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors[0]).toContain('must be an object');
|
||||
});
|
||||
});
|
||||
179
packages/noodl-runtime/src/config/validation.ts
Normal file
179
packages/noodl-runtime/src/config/validation.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* App Configuration Validation
|
||||
*
|
||||
* Provides validation utilities for config keys and values.
|
||||
*
|
||||
* @module config/validation
|
||||
*/
|
||||
|
||||
import { ConfigType, ConfigValidation, RESERVED_CONFIG_KEYS } from './types';
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a configuration variable key.
|
||||
* Keys must be valid JavaScript identifiers and not reserved.
|
||||
*
|
||||
* @param key - The key to validate
|
||||
* @returns Validation result with errors if invalid
|
||||
*/
|
||||
export function validateConfigKey(key: string): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!key || key.trim() === '') {
|
||||
errors.push('Key cannot be empty');
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
// Must be valid JS identifier
|
||||
if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)) {
|
||||
errors.push('Key must be a valid JavaScript identifier (letters, numbers, _, $ only; cannot start with a number)');
|
||||
}
|
||||
|
||||
// Check reserved keys
|
||||
if ((RESERVED_CONFIG_KEYS as readonly string[]).includes(key)) {
|
||||
errors.push(`"${key}" is a reserved configuration key`);
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a configuration value based on its type and validation rules.
|
||||
*
|
||||
* @param value - The value to validate
|
||||
* @param type - The expected type
|
||||
* @param validation - Optional validation rules
|
||||
* @returns Validation result with errors if invalid
|
||||
*/
|
||||
export function validateConfigValue(value: unknown, type: ConfigType, validation?: ConfigValidation): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Required check
|
||||
if (validation?.required && (value === undefined || value === null || value === '')) {
|
||||
errors.push('This field is required');
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
// If value is empty and not required, skip type validation
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return { valid: true, errors: [] };
|
||||
}
|
||||
|
||||
// Type-specific validation
|
||||
switch (type) {
|
||||
case 'number':
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
errors.push('Value must be a number');
|
||||
} else {
|
||||
if (validation?.min !== undefined && value < validation.min) {
|
||||
errors.push(`Value must be at least ${validation.min}`);
|
||||
}
|
||||
if (validation?.max !== undefined && value > validation.max) {
|
||||
errors.push(`Value must be at most ${validation.max}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'string':
|
||||
if (typeof value !== 'string') {
|
||||
errors.push('Value must be a string');
|
||||
} else if (validation?.pattern) {
|
||||
try {
|
||||
const regex = new RegExp(validation.pattern);
|
||||
if (!regex.test(value)) {
|
||||
errors.push('Value does not match required pattern');
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push('Invalid validation pattern');
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'boolean':
|
||||
if (typeof value !== 'boolean') {
|
||||
errors.push('Value must be true or false');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'color':
|
||||
if (typeof value !== 'string') {
|
||||
errors.push('Color value must be a string');
|
||||
} else if (!/^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/.test(value)) {
|
||||
errors.push('Value must be a valid hex color (e.g., #ff0000 or #ff0000ff)');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'array':
|
||||
if (!Array.isArray(value)) {
|
||||
errors.push('Value must be an array');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'object':
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
errors.push('Value must be an object');
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
errors.push(`Unknown type: ${type}`);
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an entire app config object.
|
||||
*
|
||||
* @param config - The app config to validate
|
||||
* @returns Validation result with all errors found
|
||||
*/
|
||||
export function validateAppConfig(config: unknown): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Type guard for config object
|
||||
if (!config || typeof config !== 'object') {
|
||||
errors.push('Config must be an object');
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const cfg = config as Record<string, any>;
|
||||
|
||||
// Check required identity fields
|
||||
if (!cfg.identity?.appName || cfg.identity.appName.trim() === '') {
|
||||
errors.push('App name is required');
|
||||
}
|
||||
|
||||
// Validate custom variables
|
||||
if (cfg.variables && Array.isArray(cfg.variables)) {
|
||||
const seenKeys = new Set<string>();
|
||||
|
||||
for (const variable of cfg.variables) {
|
||||
// Check for duplicate keys
|
||||
if (seenKeys.has(variable.key)) {
|
||||
errors.push(`Duplicate variable key: ${variable.key}`);
|
||||
continue;
|
||||
}
|
||||
seenKeys.add(variable.key);
|
||||
|
||||
// Validate key
|
||||
const keyValidation = validateConfigKey(variable.key);
|
||||
if (!keyValidation.valid) {
|
||||
errors.push(...keyValidation.errors.map((e) => `Variable "${variable.key}": ${e}`));
|
||||
}
|
||||
|
||||
// Validate value
|
||||
const valueValidation = validateConfigValue(variable.value, variable.type, variable.validation);
|
||||
if (!valueValidation.valid) {
|
||||
errors.push(...valueValidation.errors.map((e) => `Variable "${variable.key}": ${e}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
Reference in New Issue
Block a user