mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-08 01:53:30 +01:00
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:
362
packages/noodl-editor/src/editor/src/io/ProjectImporter.ts
Normal file
362
packages/noodl-editor/src/editor/src/io/ProjectImporter.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* STRUCT-003 Import Engine Core
|
||||
*
|
||||
* Converts the v2 multi-file directory structure back into the legacy
|
||||
* monolithic project.json format. Designed for round-trip fidelity with
|
||||
* ProjectExporter export import should produce an identical project.
|
||||
*
|
||||
* This class is pure it does not touch the filesystem. The caller provides
|
||||
* all file contents as an ImportInput object, and receives a LegacyProject.
|
||||
*
|
||||
* @module noodl-editor/io/ProjectImporter
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
ProjectV2File,
|
||||
ComponentV2File,
|
||||
NodesV2File,
|
||||
ConnectionsV2File,
|
||||
RegistryV2File,
|
||||
RoutesV2File,
|
||||
StylesV2File,
|
||||
NodeV2
|
||||
} from '../schemas';
|
||||
|
||||
import type {
|
||||
LegacyProject,
|
||||
LegacyComponent,
|
||||
LegacyNode,
|
||||
LegacyGraph,
|
||||
LegacyConnection,
|
||||
LegacyVariant
|
||||
} from './ProjectExporter';
|
||||
|
||||
// Import input types
|
||||
|
||||
/**
|
||||
* All files needed to reconstruct a project.
|
||||
* The caller reads these from disk and passes them in.
|
||||
*/
|
||||
export interface ImportInput {
|
||||
/** Contents of nodegx.project.json */
|
||||
project: ProjectV2File;
|
||||
/** Contents of components/_registry.json */
|
||||
registry: RegistryV2File;
|
||||
/** Contents of nodegx.routes.json (optional) */
|
||||
routes?: RoutesV2File;
|
||||
/** Contents of nodegx.styles.json (optional) */
|
||||
styles?: StylesV2File;
|
||||
/** Per-component files, keyed by registry path (e.g. "Header", "Pages/Home") */
|
||||
components: Record<
|
||||
string,
|
||||
{
|
||||
component: ComponentV2File;
|
||||
nodes: NodesV2File;
|
||||
connections: ConnectionsV2File;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a ProjectImporter.import() call.
|
||||
*/
|
||||
export interface ImportResult {
|
||||
/** The reconstructed legacy project object */
|
||||
project: LegacyProject;
|
||||
/** Any non-fatal warnings encountered during import */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
/**
|
||||
* Reconstructs a recursive legacy node tree from a flat NodeV2 array.
|
||||
*
|
||||
* The flat array uses `parent` and `children` (as ID arrays) to encode the
|
||||
* tree structure. We rebuild the recursive tree that legacy format expects.
|
||||
*
|
||||
* @param flatNodes - Flat array of NodeV2 objects from nodes.json
|
||||
* @returns Array of root LegacyNode objects with children embedded recursively
|
||||
*/
|
||||
export function unflattenNodes(flatNodes: NodeV2[]): LegacyNode[] {
|
||||
if (flatNodes.length === 0) return [];
|
||||
|
||||
// Build a map for O(1) lookup
|
||||
const nodeMap = new Map<string, LegacyNode>();
|
||||
|
||||
// First pass: create all LegacyNode objects (without children populated yet)
|
||||
for (const v2Node of flatNodes) {
|
||||
const legacyNode: LegacyNode = {
|
||||
id: v2Node.id,
|
||||
type: v2Node.type,
|
||||
children: []
|
||||
};
|
||||
|
||||
// Restore optional fields
|
||||
if (v2Node.label !== undefined) legacyNode.label = v2Node.label;
|
||||
if (v2Node.x !== undefined) legacyNode.x = v2Node.x;
|
||||
if (v2Node.y !== undefined) legacyNode.y = v2Node.y;
|
||||
if (v2Node.variant !== undefined) legacyNode.variant = v2Node.variant;
|
||||
if (v2Node.version !== undefined) legacyNode.version = v2Node.version;
|
||||
if (v2Node.parameters !== undefined) legacyNode.parameters = v2Node.parameters;
|
||||
if (v2Node.stateParameters !== undefined) legacyNode.stateParameters = v2Node.stateParameters;
|
||||
if (v2Node.stateTransitions !== undefined) legacyNode.stateTransitions = v2Node.stateTransitions;
|
||||
if (v2Node.defaultStateTransitions !== undefined) {
|
||||
legacyNode.defaultStateTransitions = v2Node.defaultStateTransitions;
|
||||
}
|
||||
if (v2Node.ports !== undefined) legacyNode.ports = v2Node.ports as unknown[];
|
||||
if (v2Node.dynamicports !== undefined) legacyNode.dynamicports = v2Node.dynamicports as unknown[];
|
||||
if (v2Node.conflicts !== undefined) legacyNode.conflicts = v2Node.conflicts as unknown[];
|
||||
if (v2Node.metadata !== undefined) legacyNode.metadata = v2Node.metadata;
|
||||
|
||||
nodeMap.set(v2Node.id, legacyNode);
|
||||
}
|
||||
|
||||
// Second pass: wire up children arrays using the children ID lists
|
||||
const roots: LegacyNode[] = [];
|
||||
|
||||
for (const v2Node of flatNodes) {
|
||||
const legacyNode = nodeMap.get(v2Node.id)!;
|
||||
|
||||
if (v2Node.parent === undefined) {
|
||||
// No parent = root node
|
||||
roots.push(legacyNode);
|
||||
}
|
||||
|
||||
// Populate children in order (children array contains IDs in order)
|
||||
if (v2Node.children && v2Node.children.length > 0) {
|
||||
legacyNode.children = v2Node.children
|
||||
.map((childId) => nodeMap.get(childId))
|
||||
.filter((n): n is LegacyNode => n !== undefined);
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a v2 component path back to the legacy name format.
|
||||
*
|
||||
* If the component.json has a `path` field (the original legacy name),
|
||||
* use that directly for perfect round-trip fidelity.
|
||||
* Otherwise, reconstruct from the registry path.
|
||||
*
|
||||
* @param componentFile - The component.json content
|
||||
* @param registryPath - The registry key (e.g. "Header", "Pages/Home")
|
||||
* @returns The legacy component name (e.g. "/#Header", "/Pages/Home")
|
||||
*/
|
||||
export function toLegacyName(componentFile: ComponentV2File, registryPath: string): string {
|
||||
// Prefer the preserved original path for perfect round-trip
|
||||
if (componentFile.path) {
|
||||
return componentFile.path;
|
||||
}
|
||||
|
||||
// Reconstruct from registry path
|
||||
if (registryPath.startsWith('%rootcomponent')) {
|
||||
return `/${registryPath}`;
|
||||
}
|
||||
if (registryPath.startsWith('__cloud__/')) {
|
||||
return `/#${registryPath}`;
|
||||
}
|
||||
// Default: add leading slash
|
||||
return `/${registryPath}`;
|
||||
}
|
||||
|
||||
// ProjectImporter
|
||||
|
||||
/**
|
||||
* Converts v2 multi-file project format back to the legacy project.json format.
|
||||
*
|
||||
* This class is pure it does not touch the filesystem. The caller provides
|
||||
* all file contents and receives a LegacyProject object.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const importer = new ProjectImporter();
|
||||
* const result = importer.import({
|
||||
* project: JSON.parse(await fs.readFile('nodegx.project.json', 'utf-8')),
|
||||
* registry: JSON.parse(await fs.readFile('components/_registry.json', 'utf-8')),
|
||||
* routes: JSON.parse(await fs.readFile('nodegx.routes.json', 'utf-8')),
|
||||
* styles: JSON.parse(await fs.readFile('nodegx.styles.json', 'utf-8')),
|
||||
* components: {
|
||||
* 'Header': {
|
||||
* component: JSON.parse(await fs.readFile('components/Header/component.json', 'utf-8')),
|
||||
* nodes: JSON.parse(await fs.readFile('components/Header/nodes.json', 'utf-8')),
|
||||
* connections: JSON.parse(await fs.readFile('components/Header/connections.json', 'utf-8')),
|
||||
* }
|
||||
* }
|
||||
* });
|
||||
* const legacyProject = result.project;
|
||||
* ```
|
||||
*/
|
||||
export class ProjectImporter {
|
||||
/**
|
||||
* Imports a v2 multi-file project into the legacy project.json format.
|
||||
*
|
||||
* @param input - All file contents needed to reconstruct the project
|
||||
* @returns ImportResult with the reconstructed project and any warnings
|
||||
*/
|
||||
import(input: ImportInput): ImportResult {
|
||||
const warnings: string[] = [];
|
||||
|
||||
// 1. Reconstruct metadata (merge routes + styles back in)
|
||||
const metadata = this.reconstructMetadata(input);
|
||||
|
||||
// 2. Reconstruct variants from styles file
|
||||
const variants = this.reconstructVariants(input.styles);
|
||||
|
||||
// 3. Reconstruct components
|
||||
const components: LegacyComponent[] = [];
|
||||
|
||||
for (const [registryPath, componentFiles] of Object.entries(input.components)) {
|
||||
try {
|
||||
const legacyComponent = this.reconstructComponent(
|
||||
registryPath,
|
||||
componentFiles.component,
|
||||
componentFiles.nodes,
|
||||
componentFiles.connections
|
||||
);
|
||||
components.push(legacyComponent);
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`Failed to reconstruct component "${registryPath}": ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Reconstruct project
|
||||
const project: LegacyProject = {
|
||||
name: input.project.name,
|
||||
version: input.project.version,
|
||||
components
|
||||
};
|
||||
|
||||
if (input.project.runtimeVersion) {
|
||||
project.runtimeVersion = input.project.runtimeVersion;
|
||||
}
|
||||
|
||||
if (input.project.settings && Object.keys(input.project.settings).length > 0) {
|
||||
project.settings = input.project.settings as Record<string, unknown>;
|
||||
}
|
||||
|
||||
if (Object.keys(metadata).length > 0) {
|
||||
project.metadata = metadata;
|
||||
}
|
||||
|
||||
if (variants.length > 0) {
|
||||
project.variants = variants;
|
||||
}
|
||||
|
||||
return { project, warnings };
|
||||
}
|
||||
|
||||
// Private helpers
|
||||
|
||||
/**
|
||||
* Reconstructs the legacy project.metadata by merging:
|
||||
* - project.json metadata (non-styles, non-routes keys)
|
||||
* - routes from nodegx.routes.json
|
||||
* - styles from nodegx.styles.json
|
||||
*/
|
||||
private reconstructMetadata(input: ImportInput): Record<string, unknown> {
|
||||
const metadata: Record<string, unknown> = {};
|
||||
|
||||
// Restore non-styles/non-routes metadata from project file
|
||||
if (input.project.metadata) {
|
||||
Object.assign(metadata, input.project.metadata);
|
||||
}
|
||||
|
||||
// Restore routes
|
||||
if (input.routes?.routes) {
|
||||
metadata.routes = input.routes.routes;
|
||||
}
|
||||
|
||||
// Restore styles (colors + textStyles)
|
||||
if (input.styles) {
|
||||
const styles: Record<string, unknown> = {};
|
||||
if (input.styles.colors) styles.colors = input.styles.colors;
|
||||
if (input.styles.textStyles) styles.textStyles = input.styles.textStyles;
|
||||
if (Object.keys(styles).length > 0) {
|
||||
metadata.styles = styles;
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstructs legacy variants from the styles file.
|
||||
* Reverses the stateParameters stateParamaters normalisation.
|
||||
*/
|
||||
private reconstructVariants(styles?: StylesV2File): LegacyVariant[] {
|
||||
if (!styles?.variants) return [];
|
||||
|
||||
return styles.variants.map((v) => {
|
||||
const variant: LegacyVariant = {
|
||||
name: v.name,
|
||||
typename: v.typename
|
||||
};
|
||||
|
||||
if (v.parameters !== undefined) variant.parameters = v.parameters;
|
||||
// Reverse the typo normalisation: stateParameters stateParamaters
|
||||
if (v.stateParameters !== undefined) {
|
||||
variant.stateParamaters = v.stateParameters as Record<string, Record<string, unknown>>;
|
||||
}
|
||||
if (v.stateTransitions !== undefined) {
|
||||
variant.stateTransitions = v.stateTransitions as Record<string, Record<string, unknown>>;
|
||||
}
|
||||
if (v.defaultStateTransitions !== undefined) {
|
||||
variant.defaultStateTransitions = v.defaultStateTransitions as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return variant;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstructs a single LegacyComponent from its three v2 files.
|
||||
*/
|
||||
private reconstructComponent(
|
||||
registryPath: string,
|
||||
componentFile: ComponentV2File,
|
||||
nodesFile: NodesV2File,
|
||||
connectionsFile: ConnectionsV2File
|
||||
): LegacyComponent {
|
||||
// Reconstruct the legacy name
|
||||
const legacyName = toLegacyName(componentFile, registryPath);
|
||||
|
||||
// Reconstruct the node tree
|
||||
const roots = unflattenNodes(nodesFile.nodes ?? []);
|
||||
|
||||
// Reconstruct connections
|
||||
const connections: LegacyConnection[] = (connectionsFile.connections ?? []).map((c) => {
|
||||
const conn: LegacyConnection = {
|
||||
fromId: c.fromId,
|
||||
fromProperty: c.fromProperty,
|
||||
toId: c.toId,
|
||||
toProperty: c.toProperty
|
||||
};
|
||||
if (c.annotation) conn.annotation = c.annotation;
|
||||
return conn;
|
||||
});
|
||||
|
||||
const graph: LegacyGraph = {
|
||||
roots,
|
||||
connections
|
||||
};
|
||||
|
||||
const component: LegacyComponent = {
|
||||
name: legacyName,
|
||||
id: componentFile.id,
|
||||
graph
|
||||
};
|
||||
|
||||
// Restore component metadata
|
||||
if (componentFile.metadata && Object.keys(componentFile.metadata).length > 0) {
|
||||
component.metadata = componentFile.metadata as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return component;
|
||||
}
|
||||
}
|
||||
526
packages/noodl-editor/tests/io/ProjectImporter.test.ts
Normal file
526
packages/noodl-editor/tests/io/ProjectImporter.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -1 +1,2 @@
|
||||
export * from './ProjectExporter.test';
|
||||
export * from './ProjectImporter.test';
|
||||
|
||||
Reference in New Issue
Block a user