diff --git a/apps/web/src/lib/yaml-to-json.test.ts b/apps/web/src/lib/yaml-to-json.test.ts new file mode 100644 index 000000000..ef38a3baf --- /dev/null +++ b/apps/web/src/lib/yaml-to-json.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect } from 'vitest'; +import { yamlToJson, yamlToJsonSafe, YamlParseError, isJsonSerializable } from './yaml-to-json'; + +describe('yamlToJson', () => { + it('should parse simple YAML to JSON object', () => { + const yamlString = ` +name: "Test App" +version: 1.0 +enabled: true +`; + + interface Config { + name: string; + version: number; + enabled: boolean; + } + + const result = yamlToJson(yamlString); + + expect(result).toEqual({ + name: 'Test App', + version: 1.0, + enabled: true + }); + }); + + it('should handle nested objects', () => { + const yamlString = ` +database: + host: localhost + port: 5432 + credentials: + username: admin + password: secret +`; + + interface DatabaseConfig { + database: { + host: string; + port: number; + credentials: { + username: string; + password: string; + }; + }; + } + + const result = yamlToJson(yamlString); + + expect(result.database.host).toBe('localhost'); + expect(result.database.port).toBe(5432); + expect(result.database.credentials.username).toBe('admin'); + }); + + it('should handle arrays', () => { + const yamlString = ` +fruits: + - apple + - banana + - orange +numbers: + - 1 + - 2 + - 3 +`; + + interface ListConfig { + fruits: string[]; + numbers: number[]; + } + + const result = yamlToJson(yamlString); + + expect(result.fruits).toEqual(['apple', 'banana', 'orange']); + expect(result.numbers).toEqual([1, 2, 3]); + }); + + it('should throw YamlParseError for invalid YAML', () => { + const invalidYaml = ` +name: "Test +version: 1.0 + invalid indentation +`; + + expect(() => yamlToJson(invalidYaml)).toThrow(YamlParseError); + }); + + it('should throw YamlParseError for non-string input', () => { + expect(() => yamlToJson(123 as any)).toThrow(YamlParseError); + expect(() => yamlToJson(null as any)).toThrow(YamlParseError); + expect(() => yamlToJson(undefined as any)).toThrow(YamlParseError); + }); + + it('should throw YamlParseError for empty string', () => { + expect(() => yamlToJson('')).toThrow(YamlParseError); + expect(() => yamlToJson(' ')).toThrow(YamlParseError); + }); + + it('should handle options parameter', () => { + const yamlString = ` +name: "Test App" +version: 1.0 +`; + + const result = yamlToJson(yamlString, { + filename: 'test.yaml' + }); + + expect(result).toEqual({ + name: 'Test App', + version: 1.0 + }); + }); + + it('should throw YamlParseError for duplicate keys', () => { + const yamlWithDuplicates = ` +name: "First" +name: "Second" +`; + + // js-yaml always throws for duplicate keys + expect(() => yamlToJson(yamlWithDuplicates)).toThrow(YamlParseError); + }); +}); + +describe('isJsonSerializable', () => { + it('should return true for JSON-serializable values', () => { + expect(isJsonSerializable({})).toBe(true); + expect(isJsonSerializable([])).toBe(true); + expect(isJsonSerializable('string')).toBe(true); + expect(isJsonSerializable(123)).toBe(true); + expect(isJsonSerializable(true)).toBe(true); + expect(isJsonSerializable(null)).toBe(true); + expect(isJsonSerializable({ name: 'test', value: 42 })).toBe(true); + }); + + it('should return false for non-JSON-serializable values', () => { + expect(isJsonSerializable(undefined)).toBe(false); + expect(isJsonSerializable(() => {})).toBe(false); + expect(isJsonSerializable(Symbol('test'))).toBe(false); + + // Circular reference + const circular: any = {}; + circular.self = circular; + expect(isJsonSerializable(circular)).toBe(false); + }); +}); + +describe('yamlToJsonSafe', () => { + it('should parse valid YAML that is JSON-serializable', () => { + const yamlString = ` +name: "Test App" +version: 1.0 +settings: + debug: true + maxRetries: 3 +`; + + interface Config { + name: string; + version: number; + settings: { + debug: boolean; + maxRetries: number; + }; + } + + const result = yamlToJsonSafe(yamlString); + + expect(result).toEqual({ + name: 'Test App', + version: 1.0, + settings: { + debug: true, + maxRetries: 3 + } + }); + }); + + it('should throw YamlParseError for invalid YAML', () => { + const invalidYaml = ` +name: "Test +invalid: yaml +`; + + expect(() => yamlToJsonSafe(invalidYaml)).toThrow(YamlParseError); + }); +}); + +describe('YamlParseError', () => { + it('should store original error', () => { + const originalError = new Error('Original error message'); + const yamlError = new YamlParseError('YAML parse failed', originalError); + + expect(yamlError.message).toBe('YAML parse failed'); + expect(yamlError.name).toBe('YamlParseError'); + expect(yamlError.originalError).toBe(originalError); + }); + + it('should be instanceof Error and YamlParseError', () => { + const yamlError = new YamlParseError('Test error', new Error()); + + expect(yamlError instanceof Error).toBe(true); + expect(yamlError instanceof YamlParseError).toBe(true); + }); +}); + +// Type safety tests (these test TypeScript compilation) +describe('Type Safety', () => { + it('should provide proper type inference', () => { + interface TestConfig { + name: string; + count: number; + } + + const yaml = ` +name: "test" +count: 42 +`; + + const result = yamlToJson(yaml); + + // These should be properly typed + expect(typeof result.name).toBe('string'); + expect(typeof result.count).toBe('number'); + + // TypeScript should catch these at compile time + // result.name = 123; // This would be a TypeScript error + // result.invalidProperty; // This would be a TypeScript error + }); + + it('should work with unknown type as default', () => { + const yaml = ` +unknown: "structure" +dynamic: true +`; + + const result = yamlToJson(yaml); // No generic type specified + + // Result should be typed as unknown + expect(result).toBeDefined(); + expect(typeof result).toBe('object'); + }); +}); diff --git a/apps/web/src/lib/yaml-to-json.ts b/apps/web/src/lib/yaml-to-json.ts new file mode 100644 index 000000000..32dff8b6d --- /dev/null +++ b/apps/web/src/lib/yaml-to-json.ts @@ -0,0 +1,126 @@ +import { load, type LoadOptions } from 'js-yaml'; + +/** + * Error thrown when YAML parsing fails + */ +export class YamlParseError extends Error { + constructor( + message: string, + public readonly originalError: unknown + ) { + super(message); + this.name = 'YamlParseError'; + } +} + +/** + * Options for YAML to JSON conversion + */ +export interface YamlToJsonOptions { + /** Custom schema to use for parsing. Defaults to DEFAULT_SCHEMA */ + schema?: LoadOptions['schema']; + /** Custom file name for error messages */ + filename?: string; +} + +/** + * Converts a YAML string to a JSON object with type safety + * + * @template T - The expected type of the parsed JSON object + * @param yamlString - The YAML string to parse + * @param options - Optional parsing configuration + * @returns The parsed object typed as T + * @throws {YamlParseError} When YAML parsing fails + * + * @example + * ```typescript + * interface Config { + * name: string; + * version: number; + * } + * + * const yaml = ` + * name: "My App" + * version: 1.0 + * `; + * + * const config = yamlToJson(yaml); + * // config is now typed as Config + * ``` + */ +export function yamlToJson(yamlString: string, options: YamlToJsonOptions = {}): T { + // Handle edge cases + if (typeof yamlString !== 'string') { + throw new YamlParseError('Input must be a string', new TypeError('Expected string input')); + } + + if (yamlString.trim() === '') { + throw new YamlParseError('YAML string cannot be empty', new Error('Empty input')); + } + + try { + const loadOptions: LoadOptions = { + schema: options.schema, + filename: options.filename + }; + + const result = load(yamlString, loadOptions); + + // Ensure we got a valid result + if (result === undefined) { + throw new YamlParseError('YAML parsing resulted in undefined', new Error('Undefined result')); + } + + return result as T; + } catch (error) { + if (error instanceof YamlParseError) { + throw error; + } + + // Wrap js-yaml errors with our custom error + const errorMessage = error instanceof Error ? error.message : 'Unknown parsing error'; + throw new YamlParseError(`Failed to parse YAML: ${errorMessage}`, error); + } +} + +/** + * Type guard to check if a value is a valid JSON-serializable object + * Useful for runtime validation after YAML parsing + * + * @param value - The value to check + * @returns True if the value can be safely serialized to JSON + */ +export function isJsonSerializable(value: unknown): boolean { + try { + const result = JSON.stringify(value); + // JSON.stringify returns undefined for undefined values, functions, symbols + return result !== undefined; + } catch { + return false; + } +} + +/** + * Safely converts YAML to JSON with additional validation + * + * @template T - The expected type of the parsed JSON object + * @param yamlString - The YAML string to parse + * @param options - Optional parsing configuration + * @returns The parsed and validated object typed as T + * @throws {YamlParseError} When YAML parsing or validation fails + */ +export function yamlToJsonSafe( + yamlString: string, + options: YamlToJsonOptions = {} +): T { + const result = yamlToJson(yamlString, options); + + if (!isJsonSerializable(result)) { + throw new YamlParseError( + 'Parsed YAML contains non-JSON-serializable values', + new Error('Non-serializable content') + ); + } + + return result; +}