mirror of https://github.com/buster-so/buster.git
Merge branch 'staging' into big-nate-bus-1520-make-report-component
This commit is contained in:
commit
6840439a96
|
@ -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<Config>(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<DatabaseConfig>(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<ListConfig>(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<Config>(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<TestConfig>(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');
|
||||||
|
});
|
||||||
|
});
|
|
@ -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<Config>(yaml);
|
||||||
|
* // config is now typed as Config
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function yamlToJson<T = unknown>(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<T = unknown>(
|
||||||
|
yamlString: string,
|
||||||
|
options: YamlToJsonOptions = {}
|
||||||
|
): T {
|
||||||
|
const result = yamlToJson<T>(yamlString, options);
|
||||||
|
|
||||||
|
if (!isJsonSerializable(result)) {
|
||||||
|
throw new YamlParseError(
|
||||||
|
'Parsed YAML contains non-JSON-serializable values',
|
||||||
|
new Error('Non-serializable content')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
Loading…
Reference in New Issue