import { describe, test, expect, vi } from 'vitest'; import type { SerializedTreeNode } from '../types/sitesmith'; import { treeToCraftState } from './PageContext'; /** * E3: treeToState (here, the extracted pure `treeToCraftState`) previously had * NO resolvedName allowlist — an AI `replace` response (scope site/page, * reaches this function via `actions.deserialize`) containing an unknown * component would produce a state that could throw at render/deserialize * time. This mirrors the guard `buildNodeTree` (apply-ai-response.ts) * already had. * * Tested directly against the exported pure function (no React/Craft.js * Editor needed) per the brief's guidance to extract+test the core * transform rather than exercise this through the PageProvider. */ describe('treeToCraftState resolvedName guard', () => { test('an unknown resolvedName at the root does not throw and falls back to a valid empty canvas', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const tree: SerializedTreeNode = { type: { resolvedName: 'NotARealComponent' }, props: { node_id: 'bad-root' }, nodes: [], }; let state = ''; expect(() => { state = treeToCraftState(tree); }).not.toThrow(); const parsed = JSON.parse(state); expect(parsed.ROOT).toBeDefined(); expect(parsed.ROOT.type.resolvedName).toBe('Container'); expect(parsed.ROOT.nodes).toEqual([]); expect(warnSpy).toHaveBeenCalled(); warnSpy.mockRestore(); }); test('an unknown resolvedName on a nested child is soft-skipped; valid siblings still render', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const tree: SerializedTreeNode = { type: { resolvedName: 'Section' }, props: { node_id: 'sec-1' }, nodes: [ { type: { resolvedName: 'NotARealComponent' }, props: {}, nodes: [] }, { type: { resolvedName: 'Heading' }, props: { node_id: 'h-1', text: 'Welcome' }, nodes: [] }, ], }; const state = treeToCraftState(tree); const parsed = JSON.parse(state); // The unknown node must not appear anywhere in the produced state. for (const id of Object.keys(parsed)) { expect(parsed[id].type.resolvedName).not.toBe('NotARealComponent'); } // The Heading sibling survived (wrapped in Section's section-inner). expect(parsed['h-1']).toBeDefined(); expect(parsed['h-1'].props.text).toBe('Welcome'); expect(warnSpy).toHaveBeenCalled(); warnSpy.mockRestore(); }); test('a fully-valid tree still produces a working ROOT-keyed state (regression)', () => { const tree: SerializedTreeNode = { type: { resolvedName: 'Section' }, props: { node_id: 'sec-1' }, nodes: [ { type: { resolvedName: 'Heading' }, props: { node_id: 'h-1', text: 'Hi' }, nodes: [] }, ], }; const state = treeToCraftState(tree); const parsed = JSON.parse(state); expect(parsed.ROOT).toBeDefined(); expect(parsed.ROOT.type.resolvedName).toBe('Section'); expect(parsed.ROOT.isCanvas).toBe(true); expect(parsed.ROOT.parent).toBeNull(); const innerId = parsed.ROOT.linkedNodes['section-inner']; expect(innerId).toBeDefined(); expect(parsed[innerId].nodes).toEqual(['h-1']); expect(parsed['h-1'].parent).toBe(innerId); expect(parsed['h-1'].props.text).toBe('Hi'); }); test('a ColumnLayout root still flattens children into col-N linkedNodes (regression)', () => { const tree: SerializedTreeNode = { type: { resolvedName: 'ColumnLayout' }, props: { node_id: 'cols-1' }, nodes: [ { type: { resolvedName: 'Heading' }, props: { node_id: 'a' }, nodes: [] }, { type: { resolvedName: 'Heading' }, props: { node_id: 'b' }, nodes: [] }, ], }; const state = treeToCraftState(tree); const parsed = JSON.parse(state); expect(parsed.ROOT.type.resolvedName).toBe('ColumnLayout'); expect(parsed.ROOT.linkedNodes).toEqual({ 'col-0': 'a', 'col-1': 'b' }); expect(parsed.ROOT.props.columns).toBe(2); expect(parsed['a'].isCanvas).toBe(true); // I-2: the ROOT-aliasing step must reparent linkedNodes children too, not // just nodes[] children. Before the fix, 'a'/'b'.parent stayed pointing // at the tree's original (pre-alias) root id ('cols-1'), which is then // `delete`d from the output -- a dangling parent reference that breaks // select/move/delete of those nodes in the Craft.js editor. expect(parsed['a'].parent).toBe('ROOT'); expect(parsed['b'].parent).toBe('ROOT'); expect(parsed['cols-1']).toBeUndefined(); }); test('I-2: every child parent in the output tree references an id that exists (no dangling reference to the deleted old-root id) -- ColumnLayout root', () => { const tree: SerializedTreeNode = { type: { resolvedName: 'ColumnLayout' }, props: { node_id: 'cols-1' }, nodes: [ { type: { resolvedName: 'Heading' }, props: { node_id: 'a' }, nodes: [] }, { type: { resolvedName: 'Heading' }, props: { node_id: 'b' }, nodes: [] }, ], }; const parsed = JSON.parse(treeToCraftState(tree)); for (const id of Object.keys(parsed)) { const parent = parsed[id].parent; if (parent == null) continue; expect(parsed[parent]).toBeDefined(); } }); test('I-2: a Section (SHELL_INNER) root reparents its section-inner linkedNode child to ROOT', () => { const tree: SerializedTreeNode = { type: { resolvedName: 'Section' }, props: { node_id: 'sec-root' }, nodes: [ { type: { resolvedName: 'Heading' }, props: { node_id: 'h-1', text: 'Hi' }, nodes: [] }, ], }; const parsed = JSON.parse(treeToCraftState(tree)); const innerId = parsed.ROOT.linkedNodes['section-inner']; expect(innerId).toBeDefined(); // The section-inner node itself is a linkedNodes child of the // (re-keyed) root -- its parent must point at 'ROOT', not the deleted // original root id 'sec-root'. expect(parsed[innerId].parent).toBe('ROOT'); expect(parsed['sec-root']).toBeUndefined(); // And every parent reference in the whole tree resolves to a real node. for (const id of Object.keys(parsed)) { const parent = parsed[id].parent; if (parent == null) continue; expect(parsed[parent]).toBeDefined(); } }); test('style: [] normalizes to {} (regression)', () => { const tree: SerializedTreeNode = { type: { resolvedName: 'Heading' }, props: { node_id: 'h-1', style: [] as unknown as Record }, nodes: [], }; const state = treeToCraftState(tree); const parsed = JSON.parse(state); expect(parsed.ROOT.props.style).toEqual({}); }); });