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

@@ -1,4 +1,4 @@
# Phase 10A AI-Powered Development: Dishant's Progress # Phase 10A AI-Powered Development: Dishant Progress
**Developer:** Dishant **Developer:** Dishant
**Branch:** `cline-dev-dishant` **Branch:** `cline-dev-dishant`
@@ -8,173 +8,151 @@
## Task Status ## Task Status
| Task ID | Title | Status | Notes | | Task ID | Title | Status | Notes |
|------------|------------------------------|-------------|--------------------------------------------| |------------|------------------------------|-------------|--------------------------------------------------------------|
| STRUCT-001 | JSON Schema Definition | Complete | 8 schemas + validator + tests (33/33 pass) | | STRUCT-001 | JSON Schema Definition | COMPLETE | 8 schemas + validator + tests (33/33 pass) |
| STRUCT-002 | Export Engine Core | Complete | ProjectExporter + 50 unit tests | | STRUCT-002 | Export Engine Core | COMPLETE | ProjectExporter + 50 unit tests |
| STRUCT-003 | Import Engine Core | COMPLETE | ProjectImporter + 55 unit tests incl. round-trip validation |
| STRUCT-004 | Editor Format Detection | TODO | Unblocked by STRUCT-003 |
| STRUCT-005 | Lazy Loading | TODO | Unblocked by STRUCT-004 |
| STRUCT-006 | Save Logic | TODO | Unblocked by STRUCT-005 |
| STRUCT-007 | Migration Wizard UI | TODO | Unblocked by STRUCT-006 |
| STRUCT-008 | Testing and Validation | TODO | Unblocked by STRUCT-007 |
| STRUCT-009 | Documentation | TODO | Unblocked by STRUCT-008 |
--- ---
## STRUCT-001 JSON Schema Definition ## STRUCT-001 JSON Schema Definition COMPLETE
**Completed:** 2026-02-18 **Completed:** 2026-02-18
**Scope:** Define JSON schemas for the v2 multi-file project format
### What was built ### What was built
All files live under `packages/noodl-editor/src/editor/src/schemas/`: All files under `packages/noodl-editor/src/editor/src/schemas/`:
#### Schema files (8 total)
| File | Schema ID | Describes | | File | Schema ID | Describes |
|------|-----------|-----------| |------|-----------|-----------|
| `project-v2.schema.json` | `https://opennoodl.dev/schemas/project-v2.json` | Root project metadata (`nodegx.project.json`) | | `project-v2.schema.json` | `https://opennoodl.dev/schemas/project-v2.json` | Root project metadata |
| `component.schema.json` | `https://opennoodl.dev/schemas/component-v2.json` | Component metadata (`component.json`) | | `component.schema.json` | `https://opennoodl.dev/schemas/component-v2.json` | Component metadata |
| `nodes.schema.json` | `https://opennoodl.dev/schemas/nodes-v2.json` | Node graph definitions (`nodes.json`) | | `nodes.schema.json` | `https://opennoodl.dev/schemas/nodes-v2.json` | Node graph definitions |
| `connections.schema.json` | `https://opennoodl.dev/schemas/connections-v2.json` | Connection/wire definitions (`connections.json`) | | `connections.schema.json` | `https://opennoodl.dev/schemas/connections-v2.json` | Connection/wire definitions |
| `registry.schema.json` | `https://opennoodl.dev/schemas/registry-v2.json` | Component index (`_registry.json`) | | `registry.schema.json` | `https://opennoodl.dev/schemas/registry-v2.json` | Component index |
| `routes.schema.json` | `https://opennoodl.dev/schemas/routes-v2.json` | URL route definitions (`nodegx.routes.json`) | | `routes.schema.json` | `https://opennoodl.dev/schemas/routes-v2.json` | URL route definitions |
| `styles.schema.json` | `https://opennoodl.dev/schemas/styles-v2.json` | Global styles + variants (`nodegx.styles.json`) | | `styles.schema.json` | `https://opennoodl.dev/schemas/styles-v2.json` | Global styles + variants |
| `model.schema.json` | `https://opennoodl.dev/schemas/model-v2.json` | Backend data model definitions (`models/<Name>.json`) | | `model.schema.json` | `https://opennoodl.dev/schemas/model-v2.json` | Backend data model definitions |
#### Validator (`validator.ts`) - `validator.ts` SchemaValidator singleton, per-schema convenience methods, Ajv v8 + ajv-formats
- `index.ts` re-exports all schemas, validator, TypeScript interfaces
- `tests/schemas/schema-validator.test.ts` 33 test cases, all passing
- `SchemaValidator` singleton class compiles all 8 schemas once, reuses validators ### Key decisions
- `SCHEMA_IDS` const typed schema ID map
- `validateSchema()` convenience function
- `validateOrThrow()` throws with context on failure
- Per-schema convenience methods: `validateProject()`, `validateComponent()`, etc.
- `formatValidationErrors()` human-readable error formatting
- Ajv v8 with `ajv-formats` for `date-time` format validation
- `allErrors: true` collects all errors, not just first
#### Index (`index.ts`) - `additionalProperties: true` on nodes/connections open-ended by design
- Port type is `oneOf [string, object]` Noodl uses both formats
- Re-exports all schemas, validator, and TypeScript interfaces - `strict: false` on Ajv schemas use `description` in `definitions`
- Full TS interfaces for all 8 file types: `ProjectV2File`, `ComponentV2File`, `NodesV2File`, `ConnectionsV2File`, `RegistryV2File`, `RoutesV2File`, `StylesV2File`, `ModelV2File` - `require()` for `ajv-formats` avoids TS type conflict
#### Tests (`tests/schemas/schema-validator.test.ts`)
- 33 test cases covering all 8 schemas
- Valid minimal fixtures, full fixtures with all optional fields
- Invalid cases: missing required fields, wrong enum values, invalid formats
- Edge cases: legacy component refs (`/#Header`), complex port type objects, deeply nested metadata
- Registered in `tests/index.ts` `tests/schemas/index.ts`
### Dependencies added
```json
"ajv": "^8.x",
"ajv-formats": "^2.x"
```
Added to `packages/noodl-editor/package.json` dependencies.
### Key design decisions
1. **`additionalProperties: true` on nodes/connections** node parameters and connection metadata are open-ended by design; the schema validates structure, not content
2. **Port type is `oneOf [string, object]`** Noodl uses both `"string"` and `{ name: "stringlist", ... }` type formats
3. **`strict: false` on Ajv** schemas use `description` in `definitions` which Ajv strict mode rejects
4. **`require()` for `ajv-formats`** avoids TS type conflict between root-level `ajv-formats` (which bundles its own Ajv) and the package-local Ajv v8
### Verification
```
33/33 smoke tests passed (node smoke-test-schemas.js)
0 TypeScript errors
```
--- ---
## STRUCT-002 Export Engine Core ## STRUCT-002 Export Engine Core COMPLETE
**Completed:** 2026-02-19 **Completed:** 2026-02-19
**Scope:** Build the engine that converts the legacy `project.json` format into the v2 multi-file directory structure
### What was built ### What was built
All files:
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `packages/noodl-editor/src/editor/src/io/ProjectExporter.ts` | Core exporter class + legacy type definitions + helper functions | | `packages/noodl-editor/src/editor/src/io/ProjectExporter.ts` | Core exporter class + legacy types + helpers |
| `packages/noodl-editor/tests/io/ProjectExporter.test.ts` | 50 unit tests | | `packages/noodl-editor/tests/io/ProjectExporter.test.ts` | 50 unit tests |
| `packages/noodl-editor/tests/io/index.ts` | Test barrel export | | `packages/noodl-editor/tests/io/index.ts` | Test barrel export |
### Architecture ### Architecture
`ProjectExporter` is a **pure class** it does not touch the filesystem. It takes a `LegacyProject` object (the parsed `project.json`) and returns an `ExportResult` containing all files to write. `ProjectExporter` is pure no filesystem access. Takes `LegacyProject`, returns `ExportResult { files[], stats }`.
Output layout:
``` ```
ProjectExporter.export(legacyProject) nodegx.project.json
ExportResult { nodegx.routes.json (conditional)
files: ExportFile[], // { relativePath, content }[] nodegx.styles.json (conditional)
stats: { totalComponents, totalNodes, totalConnections }
}
```
Output file layout:
```
nodegx.project.json project metadata
nodegx.routes.json route definitions (if any in metadata)
nodegx.styles.json global styles + variants (if any)
components/ components/
_registry.json component index _registry.json
Header/ Header/
component.json component metadata
nodes.json flat node list (tree flattened)
connections.json connection list
Pages/Home/
component.json component.json
nodes.json nodes.json
connections.json connections.json
... ```
### Key decisions
1. Pure/filesystem-agnostic caller writes files
2. Node tree flattening recursive tree to flat array with parent/children IDs
3. Styles/routes files conditional only produced when content exists
4. Legacy `stateParamaters` typo normalised to `stateParameters`
5. Metadata split styles/routes extracted, rest stays in project file
6. Original legacy path preserved in `component.json` for round-trip fidelity
7. Empty `parameters: {}` omitted from output
---
## STRUCT-003 Import Engine Core COMPLETE
**Completed:** 2026-02-19
### What was built
| File | Purpose |
|------|---------|
| `packages/noodl-editor/src/editor/src/io/ProjectImporter.ts` | Core importer class + helpers |
| `packages/noodl-editor/tests/io/ProjectImporter.test.ts` | 55 unit tests incl. round-trip |
### Architecture
`ProjectImporter` is pure no filesystem access. Takes `ImportInput` (all file contents), returns `ImportResult { project, warnings }`.
```
ImportInput {
project: ProjectV2File (nodegx.project.json)
registry: RegistryV2File (components/_registry.json)
routes?: RoutesV2File (nodegx.routes.json)
styles?: StylesV2File (nodegx.styles.json)
components: Record<path, { component, nodes, connections }>
}
``` ```
### Key functions exported ### Key functions exported
| Function | Purpose | | Function | Purpose |
|----------|---------| |----------|---------|
| `legacyNameToPath(name)` | Converts `/#Header` `Header`, `/Pages/Home` `Pages/Home` | | `unflattenNodes(flat)` | Reconstructs recursive legacy node tree from flat NodeV2 array |
| `inferComponentType(name)` | Heuristic: root/page/cloud/visual from legacy name | | `toLegacyName(componentFile, registryPath)` | Converts v2 path back to legacy name (uses preserved `path` field) |
| `flattenNodes(roots)` | Flattens recursive legacy node tree flat `NodeV2[]` with parent/children IDs |
| `countNodes(roots)` | Counts all nodes including nested children |
### Key design decisions ### Key decisions
1. **Pure / filesystem-agnostic** caller writes files; exporter just produces the data. Easy to test, easy to use in different contexts (Electron, Node, tests). 1. Pure/filesystem-agnostic symmetric with ProjectExporter
2. **Node tree flattening** legacy format stores nodes as a recursive tree (children embedded). v2 format stores a flat array with `parent` and `children` (as ID arrays). `flattenNodes()` handles this. 2. `unflattenNodes` two-pass: build map, then wire children in order
3. **Styles file is conditional** only produced when `metadata.styles` has content OR variants exist. Same for routes. 3. `toLegacyName` prefers `component.path` (preserved original) for perfect round-trip; falls back to reconstruction
4. **Legacy `stateParamaters` typo normalised** VariantModel.toJSON() uses `stateParamaters` (typo). The exporter maps this to `stateParameters` in the v2 format. 4. `stateParameters` `stateParamaters` reversal restores legacy typo
5. **Metadata split** `styles` and `routes` are extracted from `project.metadata` into their own files; remaining metadata keys stay in `nodegx.project.json`. 5. Metadata merge routes/styles merged back into `project.metadata`
6. **Original legacy path preserved** `component.json` stores `path: "/#Header"` for round-trip fidelity (STRUCT-003 import engine will need this). 6. Non-fatal warnings component failures collected, not thrown
7. **Empty fields omitted** nodes with empty `parameters: {}` don't get a `parameters` key in the output, keeping files lean. 7. Round-trip validated 20 round-trip tests covering all data types
### Tests ### Test coverage (55 tests)
50 unit tests in `tests/io/ProjectExporter.test.ts` covering: - `unflattenNodes` 11 cases (empty, single, parent-child, deep nesting, order, field restoration)
- `toLegacyName` 4 cases (path field, fallback, root, cloud)
- `ProjectImporter.import()` 20 cases (basic, metadata, variants, components, nodes)
- Round-trip (export import) 20 cases (all data types, edge cases)
- `legacyNameToPath` 6 cases (slash stripping, hash stripping, cloud paths, nested paths) ### Cross-branch note for Richard
- `inferComponentType` 6 cases (root, cloud, page, case-insensitive, visual)
- `countNodes` 4 cases (empty, single, recursive, multiple roots)
- `flattenNodes` 11 cases (empty, single, nested, parent/child IDs, field omission, metadata)
- `ProjectExporter.export()` 23 cases across:
- Basic structure (always-present files, conditional files, stats)
- Project file (name, version, runtimeVersion, settings, $schema, metadata stripping)
- Routes file (conditional, content, non-array guard)
- Styles file (conditional, colors, variant typo normalisation)
- Component files (3 files per component, paths, IDs, type inference, node flattening, connections)
- Registry (all components listed, path/type/nodeCount/connectionCount, stats, version)
- ExportResult stats
- Edge cases (no graph, empty graph, multiple components, cloud type, variant preservation)
### Registered in No shared dependencies with Phase 9/6 work. STRUCT-003 is self-contained in `packages/noodl-editor/src/editor/src/io/` and `packages/noodl-editor/tests/io/`. No cherry-pick needed.
- `tests/io/index.ts` `tests/index.ts`
--- ---
## Next: STRUCT-003 Import Engine ## Decisions and Learnings
**Unblocked by:** STRUCT-002 - **[2026-02-18]** Ajv v8 + ajv-formats: use `require()` for ajv-formats to avoid TS type conflict with root-level ajv-formats package
**Goal:** Build the engine that converts the v2 multi-file format back to the legacy `project.json` format, with round-trip validation. - **[2026-02-19]** Legacy `stateParamaters` typo (missing 'e') is real must be preserved in round-trip. Exporter normalises to `stateParameters`, importer reverses it.
- **[2026-02-19]** `component.path` field is the key to perfect round-trip fidelity always preserve the original legacy name in the v2 component file
- **[2026-02-19]** `unflattenNodes` needs two passes first build the node map, then wire children in order using the children ID array

View 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;
}
}

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 './ProjectExporter.test';
export * from './ProjectImporter.test';