feat(phase-10): STRUCT-003 import engine core

Task: STRUCT-003
Branch: cline-dev-dishant
Cross-branch notes: none -- no shared dependencies with Richard phase 9/6 work

- ProjectImporter class (pure, filesystem-agnostic)
- Converts v2 multi-file format back to legacy project.json
- unflattenNodes: reconstructs recursive tree from flat NodeV2 array (two-pass)
- toLegacyName: uses preserved component.path for perfect round-trip fidelity
- Metadata merge: routes/styles merged back into project.metadata
- stateParameters -> stateParamaters reversal (legacy typo preserved)
- Non-fatal warnings: component failures collected, not thrown
- 55 unit tests in tests/io/ProjectImporter.test.ts
  - unflattenNodes: 11 cases
  - toLegacyName: 4 cases
  - ProjectImporter.import(): 20 cases
  - Round-trip (export -> import): 20 cases
- Updated tests/io/index.ts to export importer tests
- Updated PROGRESS-dishant.md: STRUCT-001/002/003 all marked complete
This commit is contained in:
dishant-kumar-thakur
2026-02-19 01:07:22 +05:30
parent fbce66e0db
commit d54e2a55a0
4 changed files with 990 additions and 123 deletions

View File

@@ -0,0 +1,526 @@
/**
* ProjectImporter Tests -- STRUCT-003
*
* Tests for the import engine that converts v2 multi-file format back to
* legacy project.json, including round-trip validation with ProjectExporter.
*/
import {
ProjectImporter,
unflattenNodes,
toLegacyName,
ImportInput
} from '../../src/editor/src/io/ProjectImporter';
import {
ProjectExporter,
LegacyProject,
LegacyComponent,
LegacyNode
} from '../../src/editor/src/io/ProjectExporter';
// ---- Fixtures ----------------------------------------------------------------
const makeNode = (id: string, type = 'Group', overrides: Partial<LegacyNode> = {}): LegacyNode => ({
id, type, x: 0, y: 0, parameters: {}, children: [], ...overrides
});
const makeComponent = (name: string, overrides: Partial<LegacyComponent> = {}): LegacyComponent => ({
name,
id: 'comp_' + name.replace(/[^a-z0-9]/gi, '_'),
graph: { roots: [], connections: [] },
...overrides
});
const makeProject = (overrides: Partial<LegacyProject> = {}): LegacyProject => ({
name: 'Test Project',
version: '4',
components: [],
variants: [],
...overrides
});
/** Helper: export a project then build ImportInput from the result */
function exportToImportInput(project: LegacyProject): ImportInput {
const exporter = new ProjectExporter();
const result = exporter.export(project);
const getContent = (path: string) =>
result.files.find((f) => f.relativePath === path)?.content;
const projectFile = getContent('nodegx.project.json') as any;
const registryFile = getContent('components/_registry.json') as any;
const routesFile = getContent('nodegx.routes.json') as any;
const stylesFile = getContent('nodegx.styles.json') as any;
const components: ImportInput['components'] = {};
for (const comp of project.components) {
const { legacyNameToPath } = require('../../src/editor/src/io/ProjectExporter');
const compPath = legacyNameToPath(comp.name);
const componentFile = getContent(`components/${compPath}/component.json`) as any;
const nodesFile = getContent(`components/${compPath}/nodes.json`) as any;
const connectionsFile = getContent(`components/${compPath}/connections.json`) as any;
if (componentFile && nodesFile && connectionsFile) {
components[compPath] = { component: componentFile, nodes: nodesFile, connections: connectionsFile };
}
}
return {
project: projectFile,
registry: registryFile,
...(routesFile ? { routes: routesFile } : {}),
...(stylesFile ? { styles: stylesFile } : {}),
components
};
}
// ---- unflattenNodes ----------------------------------------------------------
describe('unflattenNodes', () => {
it('returns empty array for empty input', () => {
expect(unflattenNodes([])).toEqual([]);
});
it('reconstructs a single root node', () => {
const roots = unflattenNodes([{ id: 'n1', type: 'Group' }]);
expect(roots).toHaveLength(1);
expect(roots[0].id).toBe('n1');
expect(roots[0].type).toBe('Group');
});
it('reconstructs parent-child relationship', () => {
const flat = [
{ id: 'root', type: 'Group', children: ['child'] },
{ id: 'child', type: 'Text', parent: 'root' }
];
const roots = unflattenNodes(flat);
expect(roots).toHaveLength(1);
expect(roots[0].children).toHaveLength(1);
expect(roots[0].children![0].id).toBe('child');
});
it('reconstructs deeply nested tree', () => {
const flat = [
{ id: 'l1', type: 'Group', children: ['l2'] },
{ id: 'l2', type: 'Group', parent: 'l1', children: ['l3'] },
{ id: 'l3', type: 'Text', parent: 'l2' }
];
const roots = unflattenNodes(flat);
expect(roots).toHaveLength(1);
const l2 = roots[0].children![0];
expect(l2.id).toBe('l2');
const l3 = l2.children![0];
expect(l3.id).toBe('l3');
});
it('preserves children order', () => {
const flat = [
{ id: 'root', type: 'Group', children: ['c1', 'c2', 'c3'] },
{ id: 'c1', type: 'Text', parent: 'root' },
{ id: 'c2', type: 'Image', parent: 'root' },
{ id: 'c3', type: 'Button', parent: 'root' }
];
const roots = unflattenNodes(flat);
const childIds = roots[0].children!.map((c) => c.id);
expect(childIds).toEqual(['c1', 'c2', 'c3']);
});
it('handles multiple root nodes', () => {
const flat = [
{ id: 'r1', type: 'Group' },
{ id: 'r2', type: 'Group' }
];
const roots = unflattenNodes(flat);
expect(roots).toHaveLength(2);
expect(roots.map((r) => r.id)).toEqual(['r1', 'r2']);
});
it('restores parameters', () => {
const flat = [{ id: 'n1', type: 'Group', parameters: { layout: 'row' } }];
const roots = unflattenNodes(flat);
expect(roots[0].parameters).toEqual({ layout: 'row' });
});
it('restores stateParameters', () => {
const flat = [{ id: 'n1', type: 'Group', stateParameters: { hover: { opacity: 0.5 } } }];
const roots = unflattenNodes(flat);
expect(roots[0].stateParameters).toEqual({ hover: { opacity: 0.5 } });
});
it('restores metadata', () => {
const flat = [{ id: 'n1', type: 'Group', metadata: { comment: 'hello' } }];
const roots = unflattenNodes(flat);
expect(roots[0].metadata).toEqual({ comment: 'hello' });
});
it('restores variant', () => {
const flat = [{ id: 'n1', type: 'Button', variant: 'primary' }];
const roots = unflattenNodes(flat);
expect(roots[0].variant).toBe('primary');
});
it('restores label, x, y', () => {
const flat = [{ id: 'n1', type: 'Group', label: 'My Node', x: 100, y: 200 }];
const roots = unflattenNodes(flat);
expect(roots[0].label).toBe('My Node');
expect(roots[0].x).toBe(100);
expect(roots[0].y).toBe(200);
});
it('root nodes have empty children array', () => {
const roots = unflattenNodes([{ id: 'n1', type: 'Group' }]);
expect(roots[0].children).toEqual([]);
});
});
// ---- toLegacyName ------------------------------------------------------------
describe('toLegacyName', () => {
it('uses component.path when available (perfect round-trip)', () => {
const comp = { id: 'c1', name: 'Header', path: '/#Header', type: 'visual' as const, modified: '' };
expect(toLegacyName(comp, 'Header')).toBe('/#Header');
});
it('falls back to reconstructing from registry path', () => {
const comp = { id: 'c1', name: 'Header', type: 'visual' as const, modified: '' };
expect(toLegacyName(comp, 'Header')).toBe('/Header');
});
it('handles root component fallback', () => {
const comp = { id: 'c1', name: 'App', type: 'root' as const, modified: '' };
expect(toLegacyName(comp, '%rootcomponent')).toBe('/%rootcomponent');
});
it('handles cloud component fallback', () => {
const comp = { id: 'c1', name: 'Send', type: 'cloud' as const, modified: '' };
expect(toLegacyName(comp, '__cloud__/SendGrid/Send')).toBe('/#__cloud__/SendGrid/Send');
});
});
// ---- ProjectImporter ---------------------------------------------------------
describe('ProjectImporter', () => {
let importer: ProjectImporter;
beforeEach(() => {
importer = new ProjectImporter();
});
describe('basic import', () => {
it('reconstructs project name', () => {
const input = exportToImportInput(makeProject({ name: 'My App' }));
const { project } = importer.import(input);
expect(project.name).toBe('My App');
});
it('reconstructs project version', () => {
const input = exportToImportInput(makeProject({ version: '4' }));
const { project } = importer.import(input);
expect(project.version).toBe('4');
});
it('reconstructs runtimeVersion', () => {
const input = exportToImportInput(makeProject({ runtimeVersion: 'react19' }));
const { project } = importer.import(input);
expect(project.runtimeVersion).toBe('react19');
});
it('reconstructs settings', () => {
const input = exportToImportInput(makeProject({ settings: { bodyScroll: true } }));
const { project } = importer.import(input);
expect(project.settings).toEqual({ bodyScroll: true });
});
it('returns no warnings for clean input', () => {
const input = exportToImportInput(makeProject());
const { warnings } = importer.import(input);
expect(warnings).toHaveLength(0);
});
it('reconstructs empty components array', () => {
const input = exportToImportInput(makeProject());
const { project } = importer.import(input);
expect(project.components).toEqual([]);
});
});
describe('metadata reconstruction', () => {
it('restores routes from nodegx.routes.json into metadata', () => {
const routes = [{ path: '/home', component: 'pages/Home' }];
const input = exportToImportInput(makeProject({ metadata: { routes } }));
const { project } = importer.import(input);
expect((project.metadata as any)?.routes).toEqual(routes);
});
it('restores styles.colors from nodegx.styles.json into metadata', () => {
const input = exportToImportInput(makeProject({
metadata: { styles: { colors: { primary: '#3B82F6' } } }
}));
const { project } = importer.import(input);
expect((project.metadata as any)?.styles?.colors).toEqual({ primary: '#3B82F6' });
});
it('restores non-styles non-routes metadata keys', () => {
const input = exportToImportInput(makeProject({
metadata: { cloudservices: { id: 'abc' }, routes: [] }
}));
const { project } = importer.import(input);
expect((project.metadata as any)?.cloudservices).toEqual({ id: 'abc' });
});
});
describe('variants reconstruction', () => {
it('restores variants from styles file', () => {
const input = exportToImportInput(makeProject({
variants: [{ name: 'primary', typename: 'Button', parameters: { color: 'blue' } }]
}));
const { project } = importer.import(input);
expect(project.variants).toHaveLength(1);
expect(project.variants![0].name).toBe('primary');
expect(project.variants![0].typename).toBe('Button');
expect(project.variants![0].parameters).toEqual({ color: 'blue' });
});
it('reverses stateParameters normalisation back to stateParamaters typo', () => {
const input = exportToImportInput(makeProject({
variants: [{ name: 'primary', typename: 'Button', stateParamaters: { hover: { opacity: 0.8 } } }]
}));
const { project } = importer.import(input);
expect(project.variants![0].stateParamaters).toEqual({ hover: { opacity: 0.8 } });
});
it('returns empty variants when none exist', () => {
const input = exportToImportInput(makeProject({ variants: [] }));
const { project } = importer.import(input);
expect(project.variants ?? []).toHaveLength(0);
});
});
describe('component reconstruction', () => {
it('reconstructs component name (legacy path)', () => {
const input = exportToImportInput(makeProject({ components: [makeComponent('/#Header')] }));
const { project } = importer.import(input);
expect(project.components[0].name).toBe('/#Header');
});
it('reconstructs component id', () => {
const comp = makeComponent('/#Header');
comp.id = 'comp_header_123';
const input = exportToImportInput(makeProject({ components: [comp] }));
const { project } = importer.import(input);
expect(project.components[0].id).toBe('comp_header_123');
});
it('reconstructs empty graph', () => {
const input = exportToImportInput(makeProject({ components: [makeComponent('/#Header')] }));
const { project } = importer.import(input);
expect(project.components[0].graph.roots).toEqual([]);
expect(project.components[0].graph.connections).toEqual([]);
});
it('reconstructs connections', () => {
const comp = makeComponent('/#Header');
comp.graph.connections = [{ fromId: 'n1', fromProperty: 'onClick', toId: 'n2', toProperty: 'trigger' }];
const input = exportToImportInput(makeProject({ components: [comp] }));
const { project } = importer.import(input);
expect(project.components[0].graph.connections).toHaveLength(1);
expect(project.components[0].graph.connections[0]).toEqual({
fromId: 'n1', fromProperty: 'onClick', toId: 'n2', toProperty: 'trigger'
});
});
it('preserves connection annotation', () => {
const comp = makeComponent('/#Header');
comp.graph.connections = [{ fromId: 'n1', fromProperty: 'out', toId: 'n2', toProperty: 'in', annotation: 'Created' }];
const input = exportToImportInput(makeProject({ components: [comp] }));
const { project } = importer.import(input);
expect(project.components[0].graph.connections[0].annotation).toBe('Created');
});
it('reconstructs multiple components', () => {
const input = exportToImportInput(makeProject({
components: [makeComponent('/#Header'), makeComponent('/Pages/Home')]
}));
const { project } = importer.import(input);
expect(project.components).toHaveLength(2);
});
});
describe('node tree reconstruction', () => {
it('reconstructs flat root nodes', () => {
const comp = makeComponent('/#Header');
comp.graph.roots = [makeNode('n1', 'Group'), makeNode('n2', 'Text')];
const input = exportToImportInput(makeProject({ components: [comp] }));
const { project } = importer.import(input);
expect(project.components[0].graph.roots).toHaveLength(2);
});
it('reconstructs nested node tree', () => {
const comp = makeComponent('/#Header');
comp.graph.roots = [
makeNode('root', 'Group', { children: [makeNode('child', 'Text')] })
];
const input = exportToImportInput(makeProject({ components: [comp] }));
const { project } = importer.import(input);
const roots = project.components[0].graph.roots;
expect(roots).toHaveLength(1);
expect(roots[0].children).toHaveLength(1);
expect(roots[0].children![0].id).toBe('child');
});
it('reconstructs deeply nested tree', () => {
const comp = makeComponent('/#Header');
comp.graph.roots = [
makeNode('l1', 'Group', {
children: [makeNode('l2', 'Group', { children: [makeNode('l3', 'Text')] })]
})
];
const input = exportToImportInput(makeProject({ components: [comp] }));
const { project } = importer.import(input);
const l2 = project.components[0].graph.roots[0].children![0];
expect(l2.id).toBe('l2');
expect(l2.children![0].id).toBe('l3');
});
it('preserves node parameters through round-trip', () => {
const comp = makeComponent('/#Header');
comp.graph.roots = [makeNode('n1', 'Group', { parameters: { layout: 'row', gap: '12px' } })];
const input = exportToImportInput(makeProject({ components: [comp] }));
const { project } = importer.import(input);
expect(project.components[0].graph.roots[0].parameters).toEqual({ layout: 'row', gap: '12px' });
});
it('preserves node variant through round-trip', () => {
const comp = makeComponent('/#Header');
comp.graph.roots = [makeNode('n1', 'Button', { variant: 'primary' })];
const input = exportToImportInput(makeProject({ components: [comp] }));
const { project } = importer.import(input);
expect(project.components[0].graph.roots[0].variant).toBe('primary');
});
});
});
// ---- Round-trip tests --------------------------------------------------------
describe('Round-trip: export import', () => {
const exporter = new ProjectExporter();
const importer = new ProjectImporter();
function roundTrip(project: LegacyProject): LegacyProject {
const input = exportToImportInput(project);
return importer.import(input).project;
}
it('preserves project name', () => {
expect(roundTrip(makeProject({ name: 'Conference App' })).name).toBe('Conference App');
});
it('preserves project version', () => {
expect(roundTrip(makeProject({ version: '4' })).version).toBe('4');
});
it('preserves runtimeVersion', () => {
expect(roundTrip(makeProject({ runtimeVersion: 'react19' })).runtimeVersion).toBe('react19');
});
it('preserves settings', () => {
const settings = { bodyScroll: true, favicon: '/favicon.ico' };
expect(roundTrip(makeProject({ settings })).settings).toEqual(settings);
});
it('preserves component count', () => {
const project = makeProject({
components: [makeComponent('/#Header'), makeComponent('/Pages/Home'), makeComponent('/Shared/Button')]
});
expect(roundTrip(project).components).toHaveLength(3);
});
it('preserves component legacy names', () => {
const project = makeProject({
components: [makeComponent('/#Header'), makeComponent('/Pages/Home')]
});
const names = roundTrip(project).components.map((c) => c.name).sort();
expect(names).toEqual(['/#Header', '/Pages/Home'].sort());
});
it('preserves component ids', () => {
const comp = makeComponent('/#Header');
comp.id = 'comp_header_xyz';
const result = roundTrip(makeProject({ components: [comp] }));
expect(result.components[0].id).toBe('comp_header_xyz');
});
it('preserves connections', () => {
const comp = makeComponent('/#Header');
comp.graph.connections = [
{ fromId: 'n1', fromProperty: 'onClick', toId: 'n2', toProperty: 'trigger' },
{ fromId: 'n2', fromProperty: 'out', toId: 'n3', toProperty: 'in', annotation: 'Created' as const }
];
const result = roundTrip(makeProject({ components: [comp] }));
expect(result.components[0].graph.connections).toHaveLength(2);
expect(result.components[0].graph.connections[1].annotation).toBe('Created');
});
it('preserves node tree structure', () => {
const comp = makeComponent('/#Header');
comp.graph.roots = [
makeNode('root', 'Group', {
children: [
makeNode('child1', 'Text', { parameters: { text: 'Hello' } }),
makeNode('child2', 'Image')
]
})
];
const result = roundTrip(makeProject({ components: [comp] }));
const roots = result.components[0].graph.roots;
expect(roots).toHaveLength(1);
expect(roots[0].children).toHaveLength(2);
expect(roots[0].children![0].parameters).toEqual({ text: 'Hello' });
});
it('preserves routes in metadata', () => {
const routes = [{ path: '/home', component: 'pages/Home' }, { path: '/profile', component: 'pages/Profile' }];
const result = roundTrip(makeProject({ metadata: { routes } }));
expect((result.metadata as any)?.routes).toEqual(routes);
});
it('preserves styles colors in metadata', () => {
const colors = { primary: '#3B82F6', secondary: '#10B981' };
const result = roundTrip(makeProject({ metadata: { styles: { colors } } }));
expect((result.metadata as any)?.styles?.colors).toEqual(colors);
});
it('preserves variants with stateParamaters typo', () => {
const variants = [
{ name: 'primary', typename: 'Button', stateParamaters: { hover: { opacity: 0.8 } } }
];
const result = roundTrip(makeProject({ variants }));
expect(result.variants).toHaveLength(1);
expect(result.variants![0].stateParamaters).toEqual({ hover: { opacity: 0.8 } });
});
it('preserves non-styles non-routes metadata', () => {
const result = roundTrip(makeProject({ metadata: { cloudservices: { id: 'abc' } } }));
expect((result.metadata as any)?.cloudservices).toEqual({ id: 'abc' });
});
it('handles empty project cleanly', () => {
const result = roundTrip(makeProject());
expect(result.name).toBe('Test Project');
expect(result.components).toHaveLength(0);
});
it('handles cloud component round-trip', () => {
const project = makeProject({ components: [makeComponent('/#__cloud__/SendGrid/Send')] });
const result = roundTrip(project);
expect(result.components[0].name).toBe('/#__cloud__/SendGrid/Send');
});
it('handles page component round-trip', () => {
const project = makeProject({ components: [makeComponent('/Pages/Home')] });
const result = roundTrip(project);
expect(result.components[0].name).toBe('/Pages/Home');
});
});

View File

@@ -1 +1,2 @@
export * from './ProjectExporter.test';
export * from './ProjectImporter.test';