import { describe, test, expect } from 'vitest'; import type { NodeTree, Node } from '@craftjs/core'; import type { SerializedTreeNode } from '../types/sitesmith'; import { regenerateTreeIds, flattenTreeForCraft, sanitizeAiTree } from './craft-tree'; function makeNode(overrides: Partial): Node { return { id: 'placeholder', data: { props: {}, type: 'div', name: 'div', displayName: 'div', isCanvas: false, parent: null, linkedNodes: {}, nodes: [], hidden: false, }, info: {}, events: { selected: false, dragged: false, hovered: false }, dom: null, related: {}, rules: { canDrag: () => true, canDrop: () => true, canMoveIn: () => true, canMoveOut: () => true, }, _hydrationTimestamp: 0, ...overrides, } as Node; } function makeTree(): NodeTree { // root -> [childA, childB]; root also has a linkedNodes entry -> childB const root = makeNode({ id: 'root-1', data: { ...makeNode({}).data, parent: null, nodes: ['child-a-1'], linkedNodes: { slot: 'child-b-1' }, }, }); const childA = makeNode({ id: 'child-a-1', data: { ...makeNode({}).data, parent: 'root-1', nodes: [], linkedNodes: {}, }, }); const childB = makeNode({ id: 'child-b-1', data: { ...makeNode({}).data, parent: 'root-1', nodes: [], linkedNodes: {}, }, }); return { rootNodeId: 'root-1', nodes: { 'root-1': root, 'child-a-1': childA, 'child-b-1': childB, }, }; } describe('regenerateTreeIds', () => { test('produces an id set fully disjoint from the input', () => { const input = makeTree(); const inputIds = Object.keys(input.nodes); const output = regenerateTreeIds(input); const outputIds = Object.keys(output.nodes); for (const id of outputIds) { expect(inputIds).not.toContain(id); } }); test('rootNodeId is a key in nodes and equals that node id', () => { const output = regenerateTreeIds(makeTree()); expect(output.nodes[output.rootNodeId]).toBeDefined(); expect(output.nodes[output.rootNodeId].id).toBe(output.rootNodeId); }); test('every child data.parent points to a NEW id that exists in the output', () => { const output = regenerateTreeIds(makeTree()); for (const id of Object.keys(output.nodes)) { const node = output.nodes[id]; if (node.data.parent !== null) { expect(output.nodes[node.data.parent]).toBeDefined(); } } // specifically the two children under the (new) root const newRoot = output.nodes[output.rootNodeId]; expect(newRoot.data.nodes.length).toBe(1); const newChildAId = newRoot.data.nodes[0]; expect(output.nodes[newChildAId].data.parent).toBe(output.rootNodeId); }); test('linkedNodes values are remapped to existing new ids', () => { const output = regenerateTreeIds(makeTree()); const newRoot = output.nodes[output.rootNodeId]; const linkedId = newRoot.data.linkedNodes.slot; expect(linkedId).toBeDefined(); expect(output.nodes[linkedId]).toBeDefined(); // linked node's parent should still reference the new root expect(output.nodes[linkedId].data.parent).toBe(output.rootNodeId); }); test('structure/count is preserved', () => { const input = makeTree(); const output = regenerateTreeIds(input); expect(Object.keys(output.nodes).length).toBe(Object.keys(input.nodes).length); expect(output.nodes[output.rootNodeId].data.nodes.length).toBe( input.nodes[input.rootNodeId].data.nodes.length ); }); test('does not mutate the input tree', () => { const input = makeTree(); const snapshot = JSON.parse(JSON.stringify(input)); regenerateTreeIds(input); expect(JSON.parse(JSON.stringify(input))).toEqual(snapshot); }); test('output node data.props is a deep clone, not shared with the input node', () => { const input = makeTree(); input.nodes['root-1'].data.props = { alignment: 'left' }; const output = regenerateTreeIds(input); const outputRoot = output.nodes[output.rootNodeId]; // Clone must preserve content at the time of cloning. expect(outputRoot.data.props).toEqual({ alignment: 'left' }); // Mutating the output's props (simulating Craft.js's in-place setProp) // must NOT affect the input node's props object. (outputRoot.data.props as { alignment: string }).alignment = 'right'; expect(input.nodes['root-1'].data.props).toEqual({ alignment: 'left' }); }); // Regression test (found via real-browser Playwright touch verification, // Phase B): a LIVE Craft.js node's `data.type` is the actual component // function reference (not the `{resolvedName}` string wrapper used only // in SERIALIZED state) -- `query.node(id).toNodeTree()` returns nodes // shaped exactly like this. The previous implementation ran // `structuredClone(oldNode.data)` on the WHOLE data object, which throws // `DataCloneError` the instant `data.type` is a function, silently // breaking Duplicate (ContextMenu.tsx / useNodeActions.ts) and Ctrl+V // Paste (useKeyboardShortcuts.ts) for every real node in the app -- // masked by every other test in this suite using a plain string `type` // ('div'), and by unit tests elsewhere that mock `@craftjs/core` entirely // (so `toNodeTree()` never actually returns a function there). test('does not throw when data.type is a live component function reference', () => { const ButtonLikeComponent = (props: { text?: string }) => props.text; const input = makeTree(); input.nodes['root-1'].data.type = ButtonLikeComponent as unknown as Node['data']['type']; input.nodes['root-1'].data.props = { text: 'Click Me' }; expect(() => regenerateTreeIds(input)).not.toThrow(); const output = regenerateTreeIds(input); const outputRoot = output.nodes[output.rootNodeId]; // The component reference itself is preserved (shared, not cloned -- // it's a stable reference, not mutable data). expect(outputRoot.data.type).toBe(ButtonLikeComponent); // Props are still genuinely deep-cloned (the actual bug fix's point). expect(outputRoot.data.props).toEqual({ text: 'Click Me' }); (outputRoot.data.props as { text: string }).text = 'changed'; expect(input.nodes['root-1'].data.props).toEqual({ text: 'Click Me' }); }); }); describe('flattenTreeForCraft', () => { test('style: [] normalizes to {}', () => { const tree: SerializedTreeNode = { type: { resolvedName: 'Heading' }, props: { node_id: 'h1', style: [] as unknown as Record }, nodes: [], }; const { nodes } = flattenTreeForCraft(tree); expect(nodes['h1'].props.style).toEqual({}); }); test('a ColumnLayout with direct children flattens them into col-N linkedNodes', () => { const tree: SerializedTreeNode = { type: { resolvedName: 'ColumnLayout' }, props: { node_id: 'cols' }, nodes: [ { type: { resolvedName: 'Heading' }, props: { node_id: 'a' }, nodes: [] }, { type: { resolvedName: 'Heading' }, props: { node_id: 'b' }, nodes: [] }, ], }; const { nodes } = flattenTreeForCraft(tree); expect(nodes['cols'].nodes).toEqual([]); expect(nodes['cols'].linkedNodes).toEqual({ 'col-0': 'a', 'col-1': 'b' }); expect(nodes['cols'].props.columns).toBe(2); expect(nodes['a'].parent).toBe('cols'); expect(nodes['a'].isCanvas).toBe(true); expect(nodes['b'].parent).toBe('cols'); }); test('a Section with direct children wraps them in a synthetic section-inner Container', () => { const tree: SerializedTreeNode = { type: { resolvedName: 'Section' }, props: { node_id: 'sec' }, nodes: [ { type: { resolvedName: 'Heading' }, props: { node_id: 'h1' }, nodes: [] }, ], }; const { rootNodeId, nodes, syntheticIds } = flattenTreeForCraft(tree); expect(rootNodeId).toBe('sec'); const innerId = nodes['sec'].linkedNodes['section-inner']; expect(innerId).toBe('sec__section-inner'); expect(syntheticIds.has(innerId)).toBe(true); expect(nodes[innerId].nodes).toEqual(['h1']); expect(nodes[innerId].type.resolvedName).toBe('Container'); expect(nodes['h1'].parent).toBe(innerId); expect(nodes['sec'].nodes).toEqual([]); }); test('a small tree with both a ColumnLayout and a SHELL_INNER wrapper flattens the same way regardless of caller intent', () => { // Same shape both PageContext.treeToCraftState and apply-ai-response's // buildNodeTree feed through this helper — the walk itself must not care // which one is calling it. const tree: SerializedTreeNode = { type: { resolvedName: 'Section' }, props: { node_id: 'sec' }, nodes: [ { type: { resolvedName: 'ColumnLayout' }, props: { node_id: 'cols' }, nodes: [ { type: { resolvedName: 'Heading' }, props: { node_id: 'a' }, nodes: [] }, ], }, ], }; const run1 = flattenTreeForCraft(tree); const run2 = flattenTreeForCraft(tree); expect(run1.rootNodeId).toBe(run2.rootNodeId); expect(Object.keys(run1.nodes).sort()).toEqual(Object.keys(run2.nodes).sort()); const innerId = run1.nodes['sec'].linkedNodes['section-inner']; expect(run1.nodes[innerId].linkedNodes).toEqual({}); expect(run1.nodes[innerId].nodes).toEqual(['cols']); expect(run1.nodes['cols'].linkedNodes).toEqual({ 'col-0': 'a' }); expect(run1.nodes['a'].parent).toBe('cols'); }); test('auto-generates an id when node_id is missing', () => { const tree: SerializedTreeNode = { type: { resolvedName: 'Heading' }, props: {}, nodes: [] }; const { rootNodeId, nodes } = flattenTreeForCraft(tree); expect(typeof rootNodeId).toBe('string'); expect(nodes[rootNodeId]).toBeDefined(); }); test('isCanvas is true only for Container', () => { const containerTree: SerializedTreeNode = { type: { resolvedName: 'Container' }, props: { node_id: 'c1' }, nodes: [] }; const headingTree: SerializedTreeNode = { type: { resolvedName: 'Heading' }, props: { node_id: 'h1' }, nodes: [] }; expect(flattenTreeForCraft(containerTree).nodes['c1'].isCanvas).toBe(true); expect(flattenTreeForCraft(headingTree).nodes['h1'].isCanvas).toBe(false); }); }); describe('sanitizeAiTree (re-exported for treeToCraftState parity with buildNodeTree)', () => { test('unknown resolvedName at the root is rejected (returns null)', () => { const tree = { type: { resolvedName: 'NotARealComponent' }, props: {}, nodes: [] }; expect(sanitizeAiTree(tree, new Set())).toBeNull(); }); test('unknown resolvedName on a nested child drops only that subtree', () => { const tree = { type: { resolvedName: 'Section' }, props: { node_id: 's1' }, nodes: [ { type: { resolvedName: 'NotARealComponent' }, props: {}, nodes: [] }, { type: { resolvedName: 'Heading' }, props: { node_id: 'h1' }, nodes: [] }, ], }; const out = sanitizeAiTree(tree, new Set()); expect(out!.nodes!.length).toBe(1); expect(out!.nodes![0].props.node_id).toBe('h1'); }); });