diff --git a/dev-docs/tasks/phase-10-ai-powered-development/PROGRESS-dishant.md b/dev-docs/tasks/phase-10-ai-powered-development/PROGRESS-dishant.md index aa520ef..59156de 100644 --- a/dev-docs/tasks/phase-10-ai-powered-development/PROGRESS-dishant.md +++ b/dev-docs/tasks/phase-10-ai-powered-development/PROGRESS-dishant.md @@ -1,180 +1,158 @@ -# Phase 10A AI-Powered Development: Dishant's Progress +# Phase 10A AI-Powered Development: Dishant Progress -**Developer:** Dishant -**Branch:** `cline-dev-dishant` +**Developer:** Dishant +**Branch:** `cline-dev-dishant` **Last Updated:** 2026-02-19 --- ## Task Status -| Task ID | Title | Status | Notes | -|------------|------------------------------|-------------|--------------------------------------------| -| STRUCT-001 | JSON Schema Definition | Complete | 8 schemas + validator + tests (33/33 pass) | -| STRUCT-002 | Export Engine Core | Complete | ProjectExporter + 50 unit tests | +| Task ID | Title | Status | Notes | +|------------|------------------------------|-------------|--------------------------------------------------------------| +| STRUCT-001 | JSON Schema Definition | COMPLETE | 8 schemas + validator + tests (33/33 pass) | +| 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 -**Scope:** Define JSON schemas for the v2 multi-file project format +**Completed:** 2026-02-18 ### What was built -All files live under `packages/noodl-editor/src/editor/src/schemas/`: - -#### Schema files (8 total) +All files under `packages/noodl-editor/src/editor/src/schemas/`: | File | Schema ID | Describes | |------|-----------|-----------| -| `project-v2.schema.json` | `https://opennoodl.dev/schemas/project-v2.json` | Root project metadata (`nodegx.project.json`) | -| `component.schema.json` | `https://opennoodl.dev/schemas/component-v2.json` | Component metadata (`component.json`) | -| `nodes.schema.json` | `https://opennoodl.dev/schemas/nodes-v2.json` | Node graph definitions (`nodes.json`) | -| `connections.schema.json` | `https://opennoodl.dev/schemas/connections-v2.json` | Connection/wire definitions (`connections.json`) | -| `registry.schema.json` | `https://opennoodl.dev/schemas/registry-v2.json` | Component index (`_registry.json`) | -| `routes.schema.json` | `https://opennoodl.dev/schemas/routes-v2.json` | URL route definitions (`nodegx.routes.json`) | -| `styles.schema.json` | `https://opennoodl.dev/schemas/styles-v2.json` | Global styles + variants (`nodegx.styles.json`) | -| `model.schema.json` | `https://opennoodl.dev/schemas/model-v2.json` | Backend data model definitions (`models/.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 | +| `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 | +| `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 | +| `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 | -#### 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 -- `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 +### Key decisions -#### Index (`index.ts`) - -- Re-exports all schemas, validator, and TypeScript interfaces -- Full TS interfaces for all 8 file types: `ProjectV2File`, `ComponentV2File`, `NodesV2File`, `ConnectionsV2File`, `RegistryV2File`, `RoutesV2File`, `StylesV2File`, `ModelV2File` - -#### 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 -``` +- `additionalProperties: true` on nodes/connections open-ended by design +- Port type is `oneOf [string, object]` Noodl uses both formats +- `strict: false` on Ajv schemas use `description` in `definitions` +- `require()` for `ajv-formats` avoids TS type conflict --- -## STRUCT-002 Export Engine Core +## STRUCT-002 Export Engine Core COMPLETE -**Completed:** 2026-02-19 -**Scope:** Build the engine that converts the legacy `project.json` format into the v2 multi-file directory structure +**Completed:** 2026-02-19 ### What was built -All files: - | 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/index.ts` | Test barrel export | ### 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) - ExportResult { - files: ExportFile[], // { relativePath, content }[] - 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) +nodegx.project.json +nodegx.routes.json (conditional) +nodegx.styles.json (conditional) components/ - _registry.json component index + _registry.json Header/ - component.json component metadata - nodes.json flat node list (tree flattened) - connections.json connection list - Pages/Home/ component.json nodes.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 +} ``` ### Key functions exported | Function | Purpose | |----------|---------| -| `legacyNameToPath(name)` | Converts `/#Header` `Header`, `/Pages/Home` `Pages/Home` | -| `inferComponentType(name)` | Heuristic: root/page/cloud/visual from legacy name | -| `flattenNodes(roots)` | Flattens recursive legacy node tree flat `NodeV2[]` with parent/children IDs | -| `countNodes(roots)` | Counts all nodes including nested children | +| `unflattenNodes(flat)` | Reconstructs recursive legacy node tree from flat NodeV2 array | +| `toLegacyName(componentFile, registryPath)` | Converts v2 path back to legacy name (uses preserved `path` field) | -### 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). -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. -3. **Styles file is conditional** only produced when `metadata.styles` has content OR variants exist. Same for routes. -4. **Legacy `stateParamaters` typo normalised** VariantModel.toJSON() uses `stateParamaters` (typo). The exporter maps this to `stateParameters` in the v2 format. -5. **Metadata split** `styles` and `routes` are extracted from `project.metadata` into their own files; remaining metadata keys stay in `nodegx.project.json`. -6. **Original legacy path preserved** `component.json` stores `path: "/#Header"` for round-trip fidelity (STRUCT-003 import engine will need this). -7. **Empty fields omitted** nodes with empty `parameters: {}` don't get a `parameters` key in the output, keeping files lean. +1. Pure/filesystem-agnostic symmetric with ProjectExporter +2. `unflattenNodes` two-pass: build map, then wire children in order +3. `toLegacyName` prefers `component.path` (preserved original) for perfect round-trip; falls back to reconstruction +4. `stateParameters` `stateParamaters` reversal restores legacy typo +5. Metadata merge routes/styles merged back into `project.metadata` +6. Non-fatal warnings component failures collected, not thrown +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) -- `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) +### Cross-branch note for Richard -### Registered in - -- `tests/io/index.ts` `tests/index.ts` +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. --- -## Next: STRUCT-003 Import Engine +## Decisions and Learnings -**Unblocked by:** STRUCT-002 -**Goal:** Build the engine that converts the v2 multi-file format back to the legacy `project.json` format, with round-trip validation. +- **[2026-02-18]** Ajv v8 + ajv-formats: use `require()` for ajv-formats to avoid TS type conflict with root-level ajv-formats package +- **[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 diff --git a/packages/noodl-editor/src/editor/src/io/ProjectImporter.ts b/packages/noodl-editor/src/editor/src/io/ProjectImporter.ts new file mode 100644 index 0000000..6dfd62b --- /dev/null +++ b/packages/noodl-editor/src/editor/src/io/ProjectImporter.ts @@ -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(); + + // 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; + } + + 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 { + const metadata: Record = {}; + + // 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 = {}; + 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>; + } + if (v.stateTransitions !== undefined) { + variant.stateTransitions = v.stateTransitions as Record>; + } + if (v.defaultStateTransitions !== undefined) { + variant.defaultStateTransitions = v.defaultStateTransitions as Record; + } + + 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; + } + + return component; + } +} diff --git a/packages/noodl-editor/tests/io/ProjectImporter.test.ts b/packages/noodl-editor/tests/io/ProjectImporter.test.ts new file mode 100644 index 0000000..4b0842f --- /dev/null +++ b/packages/noodl-editor/tests/io/ProjectImporter.test.ts @@ -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 => ({ + id, type, x: 0, y: 0, parameters: {}, children: [], ...overrides +}); + +const makeComponent = (name: string, overrides: Partial = {}): LegacyComponent => ({ + name, + id: 'comp_' + name.replace(/[^a-z0-9]/gi, '_'), + graph: { roots: [], connections: [] }, + ...overrides +}); + +const makeProject = (overrides: Partial = {}): 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'); + }); +}); diff --git a/packages/noodl-editor/tests/io/index.ts b/packages/noodl-editor/tests/io/index.ts index 1ca7dac..7be08c7 100644 --- a/packages/noodl-editor/tests/io/index.ts +++ b/packages/noodl-editor/tests/io/index.ts @@ -1 +1,2 @@ export * from './ProjectExporter.test'; +export * from './ProjectImporter.test';