feat(schemas): STRUCT-001 - JSON Schema Definition for v2 project format

Add 8 JSON schemas, SchemaValidator with Ajv v8, TS interfaces, and 33 tests.
All smoke tests passing. Critical path for STRUCT-002 (Export Engine).
This commit is contained in:
dishant-kumar-thakur
2026-02-18 23:45:51 +05:30
parent 83278b4370
commit f8d59cce0b
15 changed files with 2195 additions and 13 deletions

View File

@@ -0,0 +1,745 @@
/**
* Schema Validator Tests — STRUCT-001
*
* Tests for the Ajv-based validation utilities covering all 8 v2 format schemas.
* Each schema is tested with a valid minimal fixture and key invalid cases.
*/
import { SchemaValidator, SCHEMA_IDS, validateSchema, formatValidationErrors } from '../../src/editor/src/schemas/validator';
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const validProject = {
name: 'My Conference App',
version: '2.0',
nodegxVersion: '1.1.0'
};
const validComponent = {
id: 'comp_header_abc123',
name: 'Header',
type: 'visual'
};
const validNodes = {
componentId: 'comp_header_abc123',
version: 1,
nodes: [
{
id: 'node_root_001',
type: 'Group',
label: 'Header Container',
x: 0,
y: 0,
parameters: { layout: 'row' }
}
]
};
const validConnections = {
componentId: 'comp_header_abc123',
version: 1,
connections: [
{
fromId: 'node_root_001',
fromProperty: 'onClick',
toId: 'node_root_002',
toProperty: 'trigger'
}
]
};
const validRegistry = {
version: 1,
lastUpdated: '2026-02-18T00:00:00.000Z',
components: {
Header: {
path: 'Header',
type: 'visual',
nodeCount: 10,
connectionCount: 5
}
},
stats: {
totalComponents: 1,
totalNodes: 10,
totalConnections: 5
}
};
const validRoutes = {
routes: [
{ path: '/home', component: 'pages/HomePage' },
{ path: '/profile', component: 'pages/ProfilePage', title: 'Profile' }
]
};
const validStyles = {
version: 1,
colors: {
primary: '#3B82F6',
background: '#ffffff'
},
textStyles: {
heading: { fontSize: '24px', fontWeight: '700' }
}
};
const validModel = {
name: 'User',
fields: [
{ name: 'username', type: 'String', required: true },
{ name: 'email', type: 'String', unique: true },
{ name: 'age', type: 'Number' }
]
};
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('SchemaValidator', () => {
beforeEach(() => {
// Reset singleton between tests to ensure clean state
SchemaValidator.reset();
});
describe('singleton', () => {
it('returns the same instance on repeated calls', () => {
const a = SchemaValidator.instance;
const b = SchemaValidator.instance;
expect(a).toBe(b);
});
it('creates a new instance after reset()', () => {
const a = SchemaValidator.instance;
SchemaValidator.reset();
const b = SchemaValidator.instance;
expect(a).not.toBe(b);
});
});
// ─── project-v2.schema.json ─────────────────────────────────────────────────
describe('validateProject', () => {
it('accepts a valid minimal project file', () => {
const result = SchemaValidator.instance.validateProject(validProject);
expect(result.valid).toBe(true);
expect(result.errors.length).toBe(0);
});
it('accepts a full project file with all optional fields', () => {
const result = SchemaValidator.instance.validateProject({
...validProject,
id: 'proj_abc123',
runtimeVersion: 'react19',
created: '2026-01-01T00:00:00.000Z',
modified: '2026-02-18T00:00:00.000Z',
settings: { rootComponent: 'App', defaultRoute: '/home' },
structure: { componentsDir: 'components', modelsDir: 'models', assetsDir: 'assets' },
metadata: { styles: {}, cloudservices: {} }
});
expect(result.valid).toBe(true);
});
it('rejects when name is missing', () => {
const result = SchemaValidator.instance.validateProject({ version: '2.0', nodegxVersion: '1.1.0' });
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.message.includes('name'))).toBe(true);
});
it('rejects when version is missing', () => {
const result = SchemaValidator.instance.validateProject({ name: 'Test', nodegxVersion: '1.1.0' });
expect(result.valid).toBe(false);
});
it('rejects when nodegxVersion is missing', () => {
const result = SchemaValidator.instance.validateProject({ name: 'Test', version: '2.0' });
expect(result.valid).toBe(false);
});
it('rejects invalid runtimeVersion enum value', () => {
const result = SchemaValidator.instance.validateProject({
...validProject,
runtimeVersion: 'react18'
});
expect(result.valid).toBe(false);
});
it('rejects invalid date-time format for created', () => {
const result = SchemaValidator.instance.validateProject({
...validProject,
created: 'not-a-date'
});
expect(result.valid).toBe(false);
});
it('rejects non-object input', () => {
expect(SchemaValidator.instance.validateProject(null).valid).toBe(false);
expect(SchemaValidator.instance.validateProject('string').valid).toBe(false);
expect(SchemaValidator.instance.validateProject(42).valid).toBe(false);
});
});
// ─── component.schema.json ──────────────────────────────────────────────────
describe('validateComponent', () => {
it('accepts a valid minimal component file', () => {
const result = SchemaValidator.instance.validateComponent(validComponent);
expect(result.valid).toBe(true);
});
it('accepts a full component with ports and dependencies', () => {
const result = SchemaValidator.instance.validateComponent({
...validComponent,
displayName: 'Site Header',
path: '/#Header',
description: 'Main navigation header',
category: 'layout',
tags: ['navigation', 'header'],
ports: {
inputs: [
{ name: 'userName', type: 'string', displayName: 'User Name' },
{ name: 'isLoggedIn', type: 'boolean', default: false }
],
outputs: [
{ name: 'onLogout', type: 'signal' }
]
},
dependencies: ['shared/Avatar', 'shared/Button'],
created: '2026-01-01T00:00:00.000Z',
modified: '2026-02-18T00:00:00.000Z'
});
expect(result.valid).toBe(true);
});
it('rejects when id is missing', () => {
const result = SchemaValidator.instance.validateComponent({ name: 'Header', type: 'visual' });
expect(result.valid).toBe(false);
});
it('rejects when name is missing', () => {
const result = SchemaValidator.instance.validateComponent({ id: 'comp_abc', type: 'visual' });
expect(result.valid).toBe(false);
});
it('rejects invalid type enum', () => {
const result = SchemaValidator.instance.validateComponent({ ...validComponent, type: 'widget' });
expect(result.valid).toBe(false);
});
it('accepts all valid type enum values', () => {
const types = ['root', 'page', 'visual', 'logic', 'cloud'] as const;
for (const type of types) {
const result = SchemaValidator.instance.validateComponent({ ...validComponent, type });
expect(result.valid).toBe(true);
}
});
it('rejects port with missing name', () => {
const result = SchemaValidator.instance.validateComponent({
...validComponent,
ports: { inputs: [{ type: 'string' }] }
});
expect(result.valid).toBe(false);
});
});
// ─── nodes.schema.json ──────────────────────────────────────────────────────
describe('validateNodes', () => {
it('accepts a valid nodes file', () => {
const result = SchemaValidator.instance.validateNodes(validNodes);
expect(result.valid).toBe(true);
});
it('accepts an empty nodes array', () => {
const result = SchemaValidator.instance.validateNodes({
componentId: 'comp_abc',
nodes: []
});
expect(result.valid).toBe(true);
});
it('accepts nodes with all optional fields', () => {
const result = SchemaValidator.instance.validateNodes({
componentId: 'comp_abc',
version: 1,
nodes: [
{
id: 'node_001',
type: 'Group',
label: 'Container',
x: 100,
y: 200,
variant: 'primary',
parameters: { layout: 'row', gap: '12px' },
stateParameters: { hover: { opacity: 0.8 } },
children: ['node_002'],
metadata: { comment: 'Main container' }
},
{
id: 'node_002',
type: '/#Header',
parent: 'node_001',
dynamicports: [{ name: 'item-0-label', type: 'string' }]
}
]
});
expect(result.valid).toBe(true);
});
it('rejects when componentId is missing', () => {
const result = SchemaValidator.instance.validateNodes({ nodes: [] });
expect(result.valid).toBe(false);
});
it('rejects when nodes is missing', () => {
const result = SchemaValidator.instance.validateNodes({ componentId: 'comp_abc' });
expect(result.valid).toBe(false);
});
it('rejects a node with missing id', () => {
const result = SchemaValidator.instance.validateNodes({
componentId: 'comp_abc',
nodes: [{ type: 'Group' }]
});
expect(result.valid).toBe(false);
});
it('rejects a node with missing type', () => {
const result = SchemaValidator.instance.validateNodes({
componentId: 'comp_abc',
nodes: [{ id: 'node_001' }]
});
expect(result.valid).toBe(false);
});
it('accepts valid annotation values', () => {
const annotations = ['Deleted', 'Created', 'Changed'] as const;
for (const annotation of annotations) {
const result = SchemaValidator.instance.validateNodes({
componentId: 'comp_abc',
nodes: [{ id: 'node_001', type: 'Group', annotation }]
});
expect(result.valid).toBe(true);
}
});
it('rejects invalid annotation value', () => {
const result = SchemaValidator.instance.validateNodes({
componentId: 'comp_abc',
nodes: [{ id: 'node_001', type: 'Group', annotation: 'Modified' }]
});
expect(result.valid).toBe(false);
});
});
// ─── connections.schema.json ────────────────────────────────────────────────
describe('validateConnections', () => {
it('accepts a valid connections file', () => {
const result = SchemaValidator.instance.validateConnections(validConnections);
expect(result.valid).toBe(true);
});
it('accepts an empty connections array', () => {
const result = SchemaValidator.instance.validateConnections({
componentId: 'comp_abc',
connections: []
});
expect(result.valid).toBe(true);
});
it('rejects when componentId is missing', () => {
const result = SchemaValidator.instance.validateConnections({ connections: [] });
expect(result.valid).toBe(false);
});
it('rejects when connections is missing', () => {
const result = SchemaValidator.instance.validateConnections({ componentId: 'comp_abc' });
expect(result.valid).toBe(false);
});
it('rejects a connection missing fromId', () => {
const result = SchemaValidator.instance.validateConnections({
componentId: 'comp_abc',
connections: [{ fromProperty: 'onClick', toId: 'node_002', toProperty: 'trigger' }]
});
expect(result.valid).toBe(false);
});
it('rejects a connection missing toProperty', () => {
const result = SchemaValidator.instance.validateConnections({
componentId: 'comp_abc',
connections: [{ fromId: 'node_001', fromProperty: 'onClick', toId: 'node_002' }]
});
expect(result.valid).toBe(false);
});
it('accepts connection with annotation', () => {
const result = SchemaValidator.instance.validateConnections({
componentId: 'comp_abc',
connections: [
{
fromId: 'node_001',
fromProperty: 'onClick',
toId: 'node_002',
toProperty: 'trigger',
annotation: 'Created'
}
]
});
expect(result.valid).toBe(true);
});
});
// ─── registry.schema.json ───────────────────────────────────────────────────
describe('validateRegistry', () => {
it('accepts a valid registry file', () => {
const result = SchemaValidator.instance.validateRegistry(validRegistry);
expect(result.valid).toBe(true);
});
it('accepts an empty components map', () => {
const result = SchemaValidator.instance.validateRegistry({ version: 1, components: {} });
expect(result.valid).toBe(true);
});
it('rejects when version is missing', () => {
const result = SchemaValidator.instance.validateRegistry({ components: {} });
expect(result.valid).toBe(false);
});
it('rejects when components is missing', () => {
const result = SchemaValidator.instance.validateRegistry({ version: 1 });
expect(result.valid).toBe(false);
});
it('rejects a component entry with invalid type', () => {
const result = SchemaValidator.instance.validateRegistry({
version: 1,
components: {
Header: { path: 'Header', type: 'widget' }
}
});
expect(result.valid).toBe(false);
});
it('rejects a component entry missing path', () => {
const result = SchemaValidator.instance.validateRegistry({
version: 1,
components: {
Header: { type: 'visual' }
}
});
expect(result.valid).toBe(false);
});
it('accepts all valid component type values', () => {
const types = ['root', 'page', 'visual', 'logic', 'cloud'] as const;
for (const type of types) {
const result = SchemaValidator.instance.validateRegistry({
version: 1,
components: { Test: { path: 'Test', type } }
});
expect(result.valid).toBe(true);
}
});
});
// ─── routes.schema.json ─────────────────────────────────────────────────────
describe('validateRoutes', () => {
it('accepts a valid routes file', () => {
const result = SchemaValidator.instance.validateRoutes(validRoutes);
expect(result.valid).toBe(true);
});
it('accepts an empty routes array', () => {
const result = SchemaValidator.instance.validateRoutes({ routes: [] });
expect(result.valid).toBe(true);
});
it('accepts routes with all optional fields', () => {
const result = SchemaValidator.instance.validateRoutes({
version: 1,
routes: [
{
path: '/home',
component: 'pages/HomePage',
title: 'Home',
exact: true,
metadata: { requiresAuth: false }
}
],
notFound: 'pages/NotFoundPage'
});
expect(result.valid).toBe(true);
});
it('rejects when routes is missing', () => {
const result = SchemaValidator.instance.validateRoutes({});
expect(result.valid).toBe(false);
});
it('rejects a route missing path', () => {
const result = SchemaValidator.instance.validateRoutes({
routes: [{ component: 'pages/HomePage' }]
});
expect(result.valid).toBe(false);
});
it('rejects a route missing component', () => {
const result = SchemaValidator.instance.validateRoutes({
routes: [{ path: '/home' }]
});
expect(result.valid).toBe(false);
});
});
// ─── styles.schema.json ─────────────────────────────────────────────────────
describe('validateStyles', () => {
it('accepts a valid styles file', () => {
const result = SchemaValidator.instance.validateStyles(validStyles);
expect(result.valid).toBe(true);
});
it('accepts an empty styles file', () => {
const result = SchemaValidator.instance.validateStyles({});
expect(result.valid).toBe(true);
});
it('accepts styles with variants', () => {
const result = SchemaValidator.instance.validateStyles({
variants: [
{
name: 'primary',
typename: 'Button',
parameters: { backgroundColor: '#3B82F6', color: '#ffffff' },
stateParameters: { hover: { backgroundColor: '#2563EB' } }
}
]
});
expect(result.valid).toBe(true);
});
it('rejects a variant missing name', () => {
const result = SchemaValidator.instance.validateStyles({
variants: [{ typename: 'Button' }]
});
expect(result.valid).toBe(false);
});
it('rejects a variant missing typename', () => {
const result = SchemaValidator.instance.validateStyles({
variants: [{ name: 'primary' }]
});
expect(result.valid).toBe(false);
});
});
// ─── model.schema.json ──────────────────────────────────────────────────────
describe('validateModel', () => {
it('accepts a valid model file', () => {
const result = SchemaValidator.instance.validateModel(validModel);
expect(result.valid).toBe(true);
});
it('accepts a model with all field types', () => {
const result = SchemaValidator.instance.validateModel({
name: 'Session',
fields: [
{ name: 'title', type: 'String' },
{ name: 'capacity', type: 'Number' },
{ name: 'isPublished', type: 'Boolean' },
{ name: 'startTime', type: 'Date' },
{ name: 'metadata', type: 'Object' },
{ name: 'tags', type: 'Array' },
{ name: 'speaker', type: 'Pointer', targetClass: 'User' },
{ name: 'attendees', type: 'Relation', targetClass: 'User' },
{ name: 'thumbnail', type: 'File' },
{ name: 'location', type: 'GeoPoint' }
]
});
expect(result.valid).toBe(true);
});
it('accepts a model with indexes', () => {
const result = SchemaValidator.instance.validateModel({
...validModel,
indexes: [
{ name: 'email_idx', fields: ['email'], unique: true },
{ fields: ['username'] }
]
});
expect(result.valid).toBe(true);
});
it('rejects when name is missing', () => {
const result = SchemaValidator.instance.validateModel({ fields: [] });
expect(result.valid).toBe(false);
});
it('rejects when fields is missing', () => {
const result = SchemaValidator.instance.validateModel({ name: 'User' });
expect(result.valid).toBe(false);
});
it('rejects a field with invalid type', () => {
const result = SchemaValidator.instance.validateModel({
name: 'User',
fields: [{ name: 'username', type: 'Text' }]
});
expect(result.valid).toBe(false);
});
it('rejects a field missing name', () => {
const result = SchemaValidator.instance.validateModel({
name: 'User',
fields: [{ type: 'String' }]
});
expect(result.valid).toBe(false);
});
it('rejects an index with empty fields array', () => {
const result = SchemaValidator.instance.validateModel({
...validModel,
indexes: [{ fields: [] }]
});
expect(result.valid).toBe(false);
});
});
// ─── validateSchema convenience function ────────────────────────────────────
describe('validateSchema (convenience function)', () => {
it('delegates to the singleton validator', () => {
const result = validateSchema(SCHEMA_IDS.COMPONENT, validComponent);
expect(result.valid).toBe(true);
});
it('returns errors for invalid data', () => {
const result = validateSchema(SCHEMA_IDS.PROJECT, {});
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
});
});
// ─── validateOrThrow ────────────────────────────────────────────────────────
describe('validateOrThrow', () => {
it('does not throw for valid data', () => {
expect(() => {
SchemaValidator.instance.validateOrThrow(SCHEMA_IDS.COMPONENT, validComponent);
}).not.toThrow();
});
it('throws for invalid data', () => {
expect(() => {
SchemaValidator.instance.validateOrThrow(SCHEMA_IDS.COMPONENT, { name: 'Header' });
}).toThrow('Schema validation failed');
});
it('includes context in the error message', () => {
expect(() => {
SchemaValidator.instance.validateOrThrow(SCHEMA_IDS.COMPONENT, {}, 'components/Header/component.json');
}).toThrow('[components/Header/component.json]');
});
});
// ─── formatValidationErrors ─────────────────────────────────────────────────
describe('formatValidationErrors', () => {
it('returns "No errors" for empty array', () => {
const { formatValidationErrors } = require('../../src/editor/src/schemas/validator');
expect(formatValidationErrors([])).toBe('No errors');
});
it('formats errors with path and message', () => {
const { formatValidationErrors } = require('../../src/editor/src/schemas/validator');
const errors = [
{ path: '/name', message: 'must be string', keyword: 'type', params: {} },
{ path: '/type', message: 'must be equal to one of the allowed values', keyword: 'enum', params: {} }
];
const formatted = formatValidationErrors(errors);
expect(formatted).toContain('/name');
expect(formatted).toContain('must be string');
expect(formatted).toContain('/type');
});
});
// ─── SCHEMA_IDS ─────────────────────────────────────────────────────────────
describe('SCHEMA_IDS', () => {
it('has all 8 schema IDs defined', () => {
expect(Object.keys(SCHEMA_IDS).length).toBe(8);
});
it('all IDs are valid opennoodl.dev URLs', () => {
for (const id of Object.values(SCHEMA_IDS)) {
expect(id).toMatch(/^https:\/\/opennoodl\.dev\/schemas\//);
}
});
});
// ─── Edge cases ──────────────────────────────────────────────────────────────
describe('edge cases', () => {
it('collects multiple errors when allErrors is true', () => {
// Missing name, version, AND nodegxVersion — should get 3 errors
const result = SchemaValidator.instance.validateProject({});
expect(result.errors.length).toBeGreaterThanOrEqual(3);
});
it('handles deeply nested valid node metadata', () => {
const result = SchemaValidator.instance.validateNodes({
componentId: 'comp_abc',
nodes: [
{
id: 'node_001',
type: 'JavaScriptNode',
metadata: {
merge: { soureCodePorts: ['script'] },
prompt: {
history: [
{ role: 'user', content: 'Create a function that...' },
{ role: 'assistant', content: 'Here is the code...' }
]
}
}
}
]
});
expect(result.valid).toBe(true);
});
it('handles component references in node type (legacy path format)', () => {
const result = SchemaValidator.instance.validateNodes({
componentId: 'comp_abc',
nodes: [
{ id: 'node_001', type: '/#Header' },
{ id: 'node_002', type: '/#__cloud__/SendGrid/SendEmail' }
]
});
expect(result.valid).toBe(true);
});
it('handles port type as object (complex type definition)', () => {
const result = SchemaValidator.instance.validateComponent({
...validComponent,
ports: {
inputs: [
{
name: 'items',
type: { name: 'stringlist', allowConnectionsOnly: true },
displayName: 'Items'
}
]
}
});
expect(result.valid).toBe(true);
});
});
});