diff --git a/packages/noodl-editor/src/editor/src/models/UBA/SchemaParser.ts b/packages/noodl-editor/src/editor/src/models/UBA/SchemaParser.ts new file mode 100644 index 0000000..a5873f3 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/models/UBA/SchemaParser.ts @@ -0,0 +1,369 @@ +/** + * UBA-002: SchemaParser + * + * Validates an unknown (already-parsed) object against the UBA schema v1.0 + * shape and returns a strongly-typed ParseResult. + * + * Design: accepts a pre-parsed object rather than a raw YAML/JSON string. + * The calling layer (BackendDiscovery, AddBackendDialog) is responsible for + * deserialising the text. This keeps the parser 100% dep-free and testable. + * + * Validation is intentional-but-not-exhaustive: + * - Required fields are checked; extra unknown keys are allowed (forward compat) + * - Field array entries are validated individually; partial errors are collected + * - Warnings are issued for deprecated or advisory patterns + */ + +import type { + AuthConfig, + BackendMetadata, + BaseField, + BooleanField, + Capabilities, + Condition, + DebugField, + DebugSchema, + Endpoints, + Field, + MultiSelectField, + NumberField, + ParseError, + ParseResult, + SecretField, + Section, + SelectField, + SelectOption, + StringField, + TextField, + UBASchema, + UrlField +} from './types'; + +// ─── Public API ─────────────────────────────────────────────────────────────── + +export class SchemaParser { + /** + * Validate a pre-parsed object as a UBASchema. + * + * @param data - Already-parsed JavaScript object (from JSON.parse or yaml.load) + */ + parse(data: unknown): ParseResult { + const errors: ParseError[] = []; + const warnings: string[] = []; + + if (!isObject(data)) { + return { success: false, errors: [{ path: '', message: 'Schema must be an object' }] }; + } + + // schema_version + if (!isString(data['schema_version'])) { + errors.push({ + path: 'schema_version', + message: 'Required string field "schema_version" is missing or not a string' + }); + } else { + const [major] = (data['schema_version'] as string).split('.'); + if (major !== '1') { + warnings.push( + `schema_version "${data['schema_version']}" — only v1.x is supported; proceeding with best-effort parsing` + ); + } + } + + // backend + const backendErrors: ParseError[] = []; + const backend = parseBackendMetadata(data['backend'], backendErrors); + errors.push(...backendErrors); + + // sections + const sectionsErrors: ParseError[] = []; + const sections = parseSections(data['sections'], sectionsErrors, warnings); + errors.push(...sectionsErrors); + + // debug (optional) + let debugSchema: DebugSchema | undefined; + if (data['debug'] !== undefined) { + const debugErrors: ParseError[] = []; + debugSchema = parseDebugSchema(data['debug'], debugErrors); + errors.push(...debugErrors); + } + + if (errors.length > 0) { + return { success: false, errors, ...(warnings.length > 0 ? { warnings } : {}) }; + } + + const schema: UBASchema = { + schema_version: data['schema_version'] as string, + backend: backend!, + sections: sections ?? [], + ...(debugSchema ? { debug: debugSchema } : {}) + }; + + return { + success: true, + data: schema, + ...(warnings.length > 0 ? { warnings } : {}) + }; + } +} + +// ─── Internal validators ────────────────────────────────────────────────────── + +function parseBackendMetadata(raw: unknown, errors: ParseError[]): BackendMetadata | undefined { + if (!isObject(raw)) { + errors.push({ path: 'backend', message: 'Required object "backend" is missing or not an object' }); + return undefined; + } + + const requiredStrings = ['id', 'name', 'version'] as const; + for (const key of requiredStrings) { + if (!isString(raw[key])) { + errors.push({ path: `backend.${key}`, message: `Required string "backend.${key}" is missing` }); + } + } + + const endpointErrors: ParseError[] = []; + const endpoints = parseEndpoints(raw['endpoints'], endpointErrors); + errors.push(...endpointErrors); + + let auth: AuthConfig | undefined; + if (raw['auth'] !== undefined) { + if (!isObject(raw['auth'])) { + errors.push({ path: 'backend.auth', message: '"backend.auth" must be an object' }); + } else { + const validTypes = ['none', 'bearer', 'api_key', 'basic']; + if (!validTypes.includes(raw['auth']['type'] as string)) { + errors.push({ + path: 'backend.auth.type', + message: `"backend.auth.type" must be one of: ${validTypes.join(', ')}` + }); + } else { + auth = { type: raw['auth']['type'] as AuthConfig['type'] }; + if (isString(raw['auth']['header'])) auth.header = raw['auth']['header'] as string; + } + } + } + + let capabilities: Capabilities | undefined; + if (isObject(raw['capabilities'])) { + capabilities = {}; + if (typeof raw['capabilities']['hot_reload'] === 'boolean') + capabilities.hot_reload = raw['capabilities']['hot_reload'] as boolean; + if (typeof raw['capabilities']['debug'] === 'boolean') capabilities.debug = raw['capabilities']['debug'] as boolean; + if (typeof raw['capabilities']['batch_config'] === 'boolean') + capabilities.batch_config = raw['capabilities']['batch_config'] as boolean; + } + + if (errors.some((e) => e.path.startsWith('backend'))) return undefined; + + return { + id: raw['id'] as string, + name: raw['name'] as string, + version: raw['version'] as string, + ...(isString(raw['description']) ? { description: raw['description'] as string } : {}), + ...(isString(raw['icon']) ? { icon: raw['icon'] as string } : {}), + ...(isString(raw['homepage']) ? { homepage: raw['homepage'] as string } : {}), + endpoints: endpoints!, + ...(auth ? { auth } : {}), + ...(capabilities ? { capabilities } : {}) + }; +} + +function parseEndpoints(raw: unknown, errors: ParseError[]): Endpoints | undefined { + if (!isObject(raw)) { + errors.push({ path: 'backend.endpoints', message: 'Required object "backend.endpoints" is missing' }); + return undefined; + } + if (!isString(raw['config'])) { + errors.push({ path: 'backend.endpoints.config', message: 'Required string "backend.endpoints.config" is missing' }); + return undefined; + } + return { + config: raw['config'] as string, + ...(isString(raw['health']) ? { health: raw['health'] as string } : {}), + ...(isString(raw['debug_stream']) ? { debug_stream: raw['debug_stream'] as string } : {}) + }; +} + +function parseSections(raw: unknown, errors: ParseError[], warnings: string[]): Section[] { + if (!Array.isArray(raw)) { + errors.push({ path: 'sections', message: 'Required array "sections" is missing or not an array' }); + return []; + } + + return raw + .map((item: unknown, i: number): Section | null => { + if (!isObject(item)) { + errors.push({ path: `sections[${i}]`, message: `Section at index ${i} must be an object` }); + return null; + } + if (!isString(item['id'])) { + errors.push({ path: `sections[${i}].id`, message: `sections[${i}].id is required and must be a string` }); + } + if (!isString(item['name'])) { + errors.push({ path: `sections[${i}].name`, message: `sections[${i}].name is required and must be a string` }); + } + if (errors.some((e) => e.path === `sections[${i}].id` || e.path === `sections[${i}].name`)) { + return null; + } + + const fieldErrors: ParseError[] = []; + const fields = parseFields(item['fields'], `sections[${i}]`, fieldErrors, warnings); + errors.push(...fieldErrors); + + return { + id: item['id'] as string, + name: item['name'] as string, + ...(isString(item['description']) ? { description: item['description'] as string } : {}), + ...(isString(item['icon']) ? { icon: item['icon'] as string } : {}), + ...(typeof item['collapsed'] === 'boolean' ? { collapsed: item['collapsed'] as boolean } : {}), + ...(item['visible_when'] ? { visible_when: item['visible_when'] as Condition } : {}), + fields + }; + }) + .filter((s): s is Section => s !== null); +} + +function parseFields(raw: unknown, path: string, errors: ParseError[], warnings: string[]): Field[] { + if (!Array.isArray(raw)) return []; + + return raw + .map((item: unknown, i: number): Field | null => { + if (!isObject(item)) { + errors.push({ path: `${path}.fields[${i}]`, message: `Field at index ${i} must be an object` }); + return null; + } + + const fieldPath = `${path}.fields[${i}]`; + if (!isString(item['id'])) { + errors.push({ path: `${fieldPath}.id`, message: `${fieldPath}.id is required` }); + return null; + } + if (!isString(item['name'])) { + errors.push({ path: `${fieldPath}.name`, message: `${fieldPath}.name is required` }); + return null; + } + + const base: BaseField = { + id: item['id'] as string, + name: item['name'] as string, + ...(isString(item['description']) ? { description: item['description'] as string } : {}), + ...(typeof item['required'] === 'boolean' ? { required: item['required'] as boolean } : {}), + ...(item['visible_when'] ? { visible_when: item['visible_when'] as Condition } : {}) + }; + + const type = item['type'] as string; + switch (type) { + case 'string': + return { + ...base, + type: 'string', + ...(isString(item['placeholder']) ? { placeholder: item['placeholder'] as string } : {}), + ...(isString(item['default']) ? { default: item['default'] as string } : {}) + } as StringField; + case 'text': + return { + ...base, + type: 'text', + ...(isString(item['placeholder']) ? { placeholder: item['placeholder'] as string } : {}), + ...(isString(item['default']) ? { default: item['default'] as string } : {}), + ...(typeof item['rows'] === 'number' ? { rows: item['rows'] as number } : {}) + } as TextField; + case 'number': + return { + ...base, + type: 'number', + ...(typeof item['default'] === 'number' ? { default: item['default'] as number } : {}), + ...(typeof item['min'] === 'number' ? { min: item['min'] as number } : {}), + ...(typeof item['max'] === 'number' ? { max: item['max'] as number } : {}) + } as NumberField; + case 'boolean': + return { + ...base, + type: 'boolean', + ...(typeof item['default'] === 'boolean' ? { default: item['default'] as boolean } : {}) + } as BooleanField; + case 'secret': + return { + ...base, + type: 'secret', + ...(isString(item['placeholder']) ? { placeholder: item['placeholder'] as string } : {}) + } as SecretField; + case 'url': + return { + ...base, + type: 'url', + ...(isString(item['placeholder']) ? { placeholder: item['placeholder'] as string } : {}), + ...(isString(item['default']) ? { default: item['default'] as string } : {}) + } as UrlField; + case 'select': { + if (!Array.isArray(item['options'])) { + errors.push({ + path: `${fieldPath}.options`, + message: `select field "${base.id}" requires an "options" array` + }); + return null; + } + const options = (item['options'] as unknown[]).map((o) => + isObject(o) + ? ({ value: String(o['value'] ?? ''), label: String(o['label'] ?? '') } as SelectOption) + : { value: '', label: '' } + ); + return { + ...base, + type: 'select', + options, + ...(isString(item['default']) ? { default: item['default'] as string } : {}) + } as SelectField; + } + case 'multi_select': { + if (!Array.isArray(item['options'])) { + errors.push({ + path: `${fieldPath}.options`, + message: `multi_select field "${base.id}" requires an "options" array` + }); + return null; + } + const options = (item['options'] as unknown[]).map((o) => + isObject(o) + ? ({ value: String(o['value'] ?? ''), label: String(o['label'] ?? '') } as SelectOption) + : { value: '', label: '' } + ); + return { ...base, type: 'multi_select', options } as MultiSelectField; + } + default: + warnings.push(`Unknown field type "${type}" at ${fieldPath} — skipping`); + return null; + } + }) + .filter((f): f is Field => f !== null); +} + +function parseDebugSchema(raw: unknown, errors: ParseError[]): DebugSchema | undefined { + if (!isObject(raw)) { + errors.push({ path: 'debug', message: '"debug" must be an object' }); + return undefined; + } + const enabled = typeof raw['enabled'] === 'boolean' ? (raw['enabled'] as boolean) : true; + const eventSchema: DebugField[] = Array.isArray(raw['event_schema']) + ? (raw['event_schema'] as unknown[]).filter(isObject).map((f) => ({ + id: String(f['id'] ?? ''), + name: String(f['name'] ?? ''), + type: (['string', 'number', 'boolean', 'json'].includes(f['type'] as string) + ? f['type'] + : 'string') as DebugField['type'], + ...(isString(f['description']) ? { description: f['description'] as string } : {}) + })) + : []; + return { enabled, ...(eventSchema.length > 0 ? { event_schema: eventSchema } : {}) }; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function isObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +function isString(v: unknown): v is string { + return typeof v === 'string' && v.length > 0; +} diff --git a/packages/noodl-editor/src/editor/src/models/UBA/index.ts b/packages/noodl-editor/src/editor/src/models/UBA/index.ts new file mode 100644 index 0000000..f3e7e57 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/models/UBA/index.ts @@ -0,0 +1,25 @@ +export { SchemaParser } from './SchemaParser'; +export type { + UBASchema, + BackendMetadata, + Section, + Field, + StringField, + TextField, + NumberField, + BooleanField, + SecretField, + UrlField, + SelectField, + MultiSelectField, + SelectOption, + BaseField, + Condition, + AuthConfig, + Endpoints, + Capabilities, + DebugSchema, + DebugField, + ParseResult, + ParseError +} from './types'; diff --git a/packages/noodl-editor/src/editor/src/models/UBA/types.ts b/packages/noodl-editor/src/editor/src/models/UBA/types.ts new file mode 100644 index 0000000..129de13 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/models/UBA/types.ts @@ -0,0 +1,207 @@ +/** + * UBA-001 / UBA-002: Universal Backend Adapter — TypeScript type definitions + * + * These types mirror the UBA schema specification v1.0. The SchemaParser + * validates an unknown object against these shapes and returns a typed result. + * + * Design notes: + * - Field types use a discriminated union on `type` so exhaustive switch() + * statements work correctly in renderers. + * - Optional fields are marked `?` — do NOT add runtime defaults here; + * defaults are handled by the UI layer (buildInitialValues in ConfigPanel). + * - All arrays that could be omitted in the schema default to `[]` in the + * parsed output (see SchemaParser.normalise). + */ + +// ─── Schema Root ───────────────────────────────────────────────────────────── + +export interface UBASchema { + schema_version: string; + backend: BackendMetadata; + sections: Section[]; + debug?: DebugSchema; +} + +// ─── Backend Metadata ──────────────────────────────────────────────────────── + +export interface BackendMetadata { + id: string; + name: string; + description?: string; + version: string; + icon?: string; + homepage?: string; + endpoints: Endpoints; + auth?: AuthConfig; + capabilities?: Capabilities; +} + +export interface Endpoints { + config: string; + health?: string; + debug_stream?: string; +} + +export interface AuthConfig { + type: 'none' | 'bearer' | 'api_key' | 'basic'; + header?: string; +} + +export interface Capabilities { + hot_reload?: boolean; + debug?: boolean; + batch_config?: boolean; +} + +// ─── Sections ──────────────────────────────────────────────────────────────── + +export interface Section { + id: string; + name: string; + description?: string; + icon?: string; + collapsed?: boolean; + visible_when?: Condition; + fields: Field[]; +} + +// ─── Conditions ────────────────────────────────────────────────────────────── + +export interface Condition { + /** e.g. "auth.type" */ + field: string; + /** e.g. "=" | "!=" | "in" | "not_in" */ + operator: '=' | '!=' | 'in' | 'not_in' | 'exists' | 'not_exists'; + value?: string | string[] | boolean | number; +} + +// ─── Field Discriminated Union ─────────────────────────────────────────────── + +export type Field = + | StringField + | TextField + | NumberField + | BooleanField + | SecretField + | UrlField + | SelectField + | MultiSelectField; + +/** Common base shared by all field types */ +export interface BaseField { + /** Unique identifier within the section */ + id: string; + /** Display label */ + name: string; + description?: string; + required?: boolean; + visible_when?: Condition; + ui?: UIHints; +} + +export interface UIHints { + help_link?: string; + placeholder?: string; + width?: 'full' | 'half' | 'third'; + monospace?: boolean; +} + +// ─── Concrete Field Types ──────────────────────────────────────────────────── + +export interface StringField extends BaseField { + type: 'string'; + placeholder?: string; + default?: string; + validation?: StringValidation; +} + +export interface StringValidation { + min_length?: number; + max_length?: number; + pattern?: string; + pattern_message?: string; +} + +export interface TextField extends BaseField { + type: 'text'; + placeholder?: string; + default?: string; + rows?: number; + validation?: StringValidation; +} + +export interface NumberField extends BaseField { + type: 'number'; + placeholder?: string; + default?: number; + min?: number; + max?: number; + step?: number; + integer?: boolean; +} + +export interface BooleanField extends BaseField { + type: 'boolean'; + default?: boolean; + /** Text shown next to the toggle */ + toggle_label?: string; +} + +export interface SecretField extends BaseField { + type: 'secret'; + placeholder?: string; + /** If true, disable copy-paste on the masked input */ + no_paste?: boolean; +} + +export interface UrlField extends BaseField { + type: 'url'; + placeholder?: string; + default?: string; + /** Restrict to specific protocols, e.g. ['https', 'wss'] */ + protocols?: string[]; +} + +export interface SelectOption { + value: string; + label: string; + description?: string; +} + +export interface SelectField extends BaseField { + type: 'select'; + options: SelectOption[]; + default?: string; +} + +export interface MultiSelectField extends BaseField { + type: 'multi_select'; + options: SelectOption[]; + default?: string[]; + max_selections?: number; +} + +// ─── Debug Schema ──────────────────────────────────────────────────────────── + +export interface DebugSchema { + enabled: boolean; + event_schema?: DebugField[]; +} + +export interface DebugField { + id: string; + name: string; + type: 'string' | 'number' | 'boolean' | 'json'; + description?: string; +} + +// ─── Parser Result Types ────────────────────────────────────────────────────── + +export type ParseResult = + | { success: true; data: T; warnings?: string[] } + | { success: false; errors: ParseError[]; warnings?: string[] }; + +export interface ParseError { + path: string; + message: string; +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/ElementStyleSectionHost/ElementStyleSectionHost.tsx b/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/ElementStyleSectionHost/ElementStyleSectionHost.tsx new file mode 100644 index 0000000..d10104c --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/ElementStyleSectionHost/ElementStyleSectionHost.tsx @@ -0,0 +1,68 @@ +/** + * STYLE-005: ElementStyleSectionHost + * + * Editor-side wrapper that combines ElementStyleSection (variant + size picker) + * with the SuggestionBanner. Lives in noodl-editor (not noodl-core-ui) so it + * can import editor-specific hooks and services. + * + * Keeps its own StyleTokensModel instance for suggestion actions. Multiple + * instances are safe — they sync via ProjectModel.metadataChanged events. + */ + +import { useStyleSuggestions } from '@noodl-hooks/useStyleSuggestions'; +import React, { useCallback, useEffect, useState } from 'react'; + +import { StyleTokensModel } from '@noodl-models/StyleTokensModel'; + +import { + ElementStyleSection, + ElementStyleSectionProps +} from '@noodl-core-ui/components/propertyeditor/ElementStyleSection'; +import { SuggestionBanner } from '@noodl-core-ui/components/StyleSuggestions'; + +import { executeSuggestionAction } from '../../../../../services/StyleAnalyzer/SuggestionActionHandler'; + +/** + * Drop-in replacement for ElementStyleSection in propertyeditor.ts. + * Adds an optional SuggestionBanner beneath the style controls when + * the StyleAnalyzer finds something worth suggesting. + */ +export function ElementStyleSectionHost(props: ElementStyleSectionProps) { + const [tokenModel] = useState(() => new StyleTokensModel()); + + // Dispose the model when the host unmounts to avoid listener leaks + useEffect(() => { + return () => tokenModel.dispose(); + }, [tokenModel]); + + const { activeSuggestion, dismissSession, dismissPermanent, refresh } = useStyleSuggestions(); + + const handleAccept = useCallback(() => { + if (!activeSuggestion) return; + executeSuggestionAction(activeSuggestion, { tokenModel, onComplete: refresh }); + }, [activeSuggestion, tokenModel, refresh]); + + const handleDismiss = useCallback(() => { + if (!activeSuggestion) return; + dismissSession(activeSuggestion.id); + }, [activeSuggestion, dismissSession]); + + const handleNeverShow = useCallback(() => { + if (!activeSuggestion) return; + dismissPermanent(activeSuggestion.id); + }, [activeSuggestion, dismissPermanent]); + + return ( + <> + + {activeSuggestion && ( + + )} + + ); +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/ElementStyleSectionHost/index.ts b/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/ElementStyleSectionHost/index.ts new file mode 100644 index 0000000..2f27ead --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/ElementStyleSectionHost/index.ts @@ -0,0 +1 @@ +export { ElementStyleSectionHost } from './ElementStyleSectionHost'; diff --git a/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/propertyeditor.ts b/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/propertyeditor.ts index 8459146..f0d907f 100644 --- a/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/propertyeditor.ts +++ b/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/propertyeditor.ts @@ -6,12 +6,11 @@ import { createRoot, Root } from 'react-dom/client'; import { NodeGraphNode } from '@noodl-models/nodegraphmodel'; import { UndoQueue, UndoActionGroup } from '@noodl-models/undo-queue-model'; -import { ElementStyleSection } from '@noodl-core-ui/components/propertyeditor/ElementStyleSection'; - import View from '../../../../../shared/view'; import { ElementConfigRegistry } from '../../../models/ElementConfigs/ElementConfigRegistry'; import { ProjectModel } from '../../../models/projectmodel'; import { ToastLayer } from '../../ToastLayer/ToastLayer'; +import { ElementStyleSectionHost } from './components/ElementStyleSectionHost'; import { VariantsEditor } from './components/VariantStates'; import { VisualStates } from './components/VisualStates'; import { Ports } from './DataTypes/Ports'; @@ -142,7 +141,7 @@ export class PropertyEditor extends View { if (!this.elementStyleRoot) { this.elementStyleRoot = createRoot(container); } - this.elementStyleRoot.render(React.createElement(ElementStyleSection, props)); + this.elementStyleRoot.render(React.createElement(ElementStyleSectionHost, props)); } /** diff --git a/packages/noodl-editor/tests/models/UBASchemaParser.test.ts b/packages/noodl-editor/tests/models/UBASchemaParser.test.ts new file mode 100644 index 0000000..796b8f9 --- /dev/null +++ b/packages/noodl-editor/tests/models/UBASchemaParser.test.ts @@ -0,0 +1,311 @@ +/** + * UBA-002: Unit tests for SchemaParser + * + * Tests run against pure JS objects (no YAML parsing needed). + * Covers: happy path, required field errors, optional fields, + * field type validation, warnings for unknown types/versions. + */ + +import { describe, it, expect, beforeEach } from '@jest/globals'; + +import { SchemaParser } from '../../src/editor/src/models/UBA/SchemaParser'; + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const minimalValid = { + schema_version: '1.0', + backend: { + id: 'test-backend', + name: 'Test Backend', + version: '1.0.0', + endpoints: { + config: 'https://example.com/config' + } + }, + sections: [] +}; + +function makeValid(overrides: Record = {}) { + return { ...minimalValid, ...overrides }; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('SchemaParser', () => { + let parser: SchemaParser; + + beforeEach(() => { + parser = new SchemaParser(); + }); + + // ─── Root validation ─────────────────────────────────────────────────────── + + describe('root validation', () => { + it('rejects null input', () => { + const result = parser.parse(null); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.errors[0].path).toBe(''); + } + }); + + it('rejects array input', () => { + const result = parser.parse([]); + expect(result.success).toBe(false); + }); + + it('rejects missing schema_version', () => { + const { schema_version: _sv, ...noVersion } = minimalValid; + const result = parser.parse(noVersion); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.errors.some((e) => e.path === 'schema_version')).toBe(true); + } + }); + + it('warns on unknown major version', () => { + const result = parser.parse(makeValid({ schema_version: '2.0' })); + // Should still succeed (best-effort) but include a warning + expect(result.success).toBe(true); + if (result.success) { + expect(result.warnings?.some((w) => w.includes('2.0'))).toBe(true); + } + }); + + it('accepts a minimal valid schema', () => { + const result = parser.parse(minimalValid); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.schema_version).toBe('1.0'); + expect(result.data.backend.id).toBe('test-backend'); + expect(result.data.sections).toEqual([]); + } + }); + }); + + // ─── Backend validation ──────────────────────────────────────────────────── + + describe('backend validation', () => { + it('errors when backend is missing', () => { + const { backend: _b, ...noBackend } = minimalValid; + const result = parser.parse(noBackend); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.errors.some((e) => e.path === 'backend')).toBe(true); + } + }); + + it('errors when backend.id is missing', () => { + const data = makeValid({ backend: { ...minimalValid.backend, id: undefined } }); + const result = parser.parse(data); + expect(result.success).toBe(false); + }); + + it('errors when backend.endpoints.config is missing', () => { + const data = makeValid({ + backend: { ...minimalValid.backend, endpoints: { health: '/health' } } + }); + const result = parser.parse(data); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.errors.some((e) => e.path === 'backend.endpoints.config')).toBe(true); + } + }); + + it('accepts optional backend fields', () => { + const data = makeValid({ + backend: { + ...minimalValid.backend, + description: 'My backend', + icon: 'https://example.com/icon.png', + homepage: 'https://example.com', + auth: { type: 'bearer' }, + capabilities: { hot_reload: true, debug: false } + } + }); + const result = parser.parse(data); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.backend.description).toBe('My backend'); + expect(result.data.backend.auth?.type).toBe('bearer'); + expect(result.data.backend.capabilities?.hot_reload).toBe(true); + } + }); + + it('errors on invalid auth type', () => { + const data = makeValid({ + backend: { ...minimalValid.backend, auth: { type: 'oauth2' } } + }); + const result = parser.parse(data); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.errors.some((e) => e.path === 'backend.auth.type')).toBe(true); + } + }); + }); + + // ─── Sections validation ─────────────────────────────────────────────────── + + describe('sections validation', () => { + it('errors when sections is not an array', () => { + const data = makeValid({ sections: 'not-an-array' }); + const result = parser.parse(data); + expect(result.success).toBe(false); + }); + + it('accepts a section with minimal fields', () => { + const data = makeValid({ + sections: [{ id: 'general', name: 'General', fields: [] }] + }); + const result = parser.parse(data); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.sections[0].id).toBe('general'); + expect(result.data.sections[0].fields).toEqual([]); + } + }); + + it('skips section without id and collects error', () => { + const data = makeValid({ + sections: [{ name: 'Missing ID', fields: [] }] + }); + const result = parser.parse(data); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.errors.some((e) => e.path.includes('id'))).toBe(true); + } + }); + }); + + // ─── Field type validation ───────────────────────────────────────────────── + + describe('field types', () => { + function sectionWith(fields: unknown[]) { + return makeValid({ sections: [{ id: 's', name: 'S', fields }] }); + } + + it('parses a string field', () => { + const result = parser.parse(sectionWith([{ id: 'host', name: 'Host', type: 'string', default: 'localhost' }])); + expect(result.success).toBe(true); + if (result.success) { + const field = result.data.sections[0].fields[0]; + expect(field.type).toBe('string'); + if (field.type === 'string') expect(field.default).toBe('localhost'); + } + }); + + it('parses a boolean field', () => { + const result = parser.parse(sectionWith([{ id: 'ssl', name: 'SSL', type: 'boolean', default: true }])); + expect(result.success).toBe(true); + if (result.success) { + const field = result.data.sections[0].fields[0]; + expect(field.type).toBe('boolean'); + if (field.type === 'boolean') expect(field.default).toBe(true); + } + }); + + it('parses a select field with options', () => { + const result = parser.parse( + sectionWith([ + { + id: 'region', + name: 'Region', + type: 'select', + options: [ + { value: 'eu-west-1', label: 'EU West' }, + { value: 'us-east-1', label: 'US East' } + ], + default: 'eu-west-1' + } + ]) + ); + expect(result.success).toBe(true); + if (result.success) { + const field = result.data.sections[0].fields[0]; + expect(field.type).toBe('select'); + if (field.type === 'select') { + expect(field.options).toHaveLength(2); + expect(field.default).toBe('eu-west-1'); + } + } + }); + + it('errors when select field has no options array', () => { + const result = parser.parse(sectionWith([{ id: 'x', name: 'X', type: 'select' }])); + expect(result.success).toBe(false); + }); + + it('warns on unknown field type and skips it', () => { + const result = parser.parse(sectionWith([{ id: 'x', name: 'X', type: 'color_picker' }])); + // Section-level parse succeeds (unknown field is skipped, not fatal) + if (result.success) { + expect(result.data.sections[0].fields).toHaveLength(0); + expect(result.warnings?.some((w) => w.includes('color_picker'))).toBe(true); + } + }); + + it('parses a number field with min/max', () => { + const result = parser.parse( + sectionWith([{ id: 'port', name: 'Port', type: 'number', default: 5432, min: 1, max: 65535 }]) + ); + expect(result.success).toBe(true); + if (result.success) { + const field = result.data.sections[0].fields[0]; + if (field.type === 'number') { + expect(field.default).toBe(5432); + expect(field.min).toBe(1); + expect(field.max).toBe(65535); + } + } + }); + + it('parses a secret field', () => { + const result = parser.parse(sectionWith([{ id: 'api_key', name: 'API Key', type: 'secret' }])); + expect(result.success).toBe(true); + if (result.success) { + const field = result.data.sections[0].fields[0]; + expect(field.type).toBe('secret'); + } + }); + + it('parses a multi_select field', () => { + const result = parser.parse( + sectionWith([ + { + id: 'roles', + name: 'Roles', + type: 'multi_select', + options: [ + { value: 'read', label: 'Read' }, + { value: 'write', label: 'Write' } + ] + } + ]) + ); + expect(result.success).toBe(true); + if (result.success) { + const field = result.data.sections[0].fields[0]; + expect(field.type).toBe('multi_select'); + } + }); + }); + + // ─── Debug schema ────────────────────────────────────────────────────────── + + describe('debug schema', () => { + it('parses an enabled debug block', () => { + const data = makeValid({ + debug: { + enabled: true, + event_schema: [{ id: 'query_time', name: 'Query Time', type: 'number' }] + } + }); + const result = parser.parse(data); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.debug?.enabled).toBe(true); + expect(result.data.debug?.event_schema).toHaveLength(1); + } + }); + }); +});