import { describe, test, expect, afterEach } from 'vitest'; import React from 'react'; import { renderEditorHarness, EditorHarness } from '../editorHarness'; import { useNodeActions, NodeActions } from '../../hooks/useNodeActions'; import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'; import { getClipboardNodeId, setClipboardNodeId } from '../../hooks/clipboard'; /** * Real-`@craftjs/core` integration coverage for duplicate/paste. * * Historical bug: `regenerateTreeIds` used `structuredClone(oldNode.data)` to * clone a copied subtree. For a REAL (live) Craft.js node, `data.type` is the * actual component function reference (not a plain `{resolvedName}` string * wrapper) -- `structuredClone` cannot clone a function and throws * `DataCloneError`, which silently broke duplicate/paste for EVERY * component in the real app. The pre-existing mocked unit tests * (`useNodeActions.test.tsx`, `useKeyboardShortcuts.test.tsx`) faked * `query.node(id).toNodeTree()` to return a plain-data object with * `type: { resolvedName: 'Container' }` -- never a function -- so * `structuredClone` never saw anything it couldn't clone there, and the bug * shipped anyway. * * This suite drives the REAL `useNodeActions`/`useKeyboardShortcuts` hooks * (no `vi.mock('@craftjs/core')` anywhere) against a real * `EditorStore`/`query`/`actions` from `editorHarness.tsx`, on a real * `SocialLinks` node (its `.craft` config makes Craft.js store the actual * `SocialLinks` function as `data.type`) -- so a regression back to * `structuredClone(oldNode.data)` throws here exactly as it did live. See * `task-integ-harness-report.md` for the recorded red-proof run. */ const INITIAL_STATE = JSON.stringify({ ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { style: {}, tag: 'div' }, displayName: 'Container', custom: {}, hidden: false, nodes: ['social-1', 'heading-1'], linkedNodes: {}, }, 'social-1': { type: { resolvedName: 'SocialLinks' }, isCanvas: false, props: { links: [{ platform: 'facebook', url: 'https://facebook.com/original' }], iconColor: '#ffffff', }, displayName: 'Social Links', custom: {}, hidden: false, parent: 'ROOT', nodes: [], linkedNodes: {}, }, 'heading-1': { type: { resolvedName: 'Heading' }, isCanvas: false, props: { text: 'Sibling Heading', level: 'h2' }, displayName: 'Heading', custom: {}, hidden: false, parent: 'ROOT', nodes: [], linkedNodes: {}, }, }); let harness: EditorHarness | null = null; afterEach(() => { setClipboardNodeId(null); if (harness) { harness.unmount(); harness = null; } }); describe('duplicate/paste (real @craftjs/core editor)', () => { test('useNodeActions().duplicate() on a real SocialLinks node: no throw, +1 node, inserted right after the source, with an independent props copy', () => { harness = renderEditorHarness({ initialState: INITIAL_STATE }); const before = Object.keys(harness.query.getNodes()); expect(before).toHaveLength(3); // ROOT, social-1, heading-1 const probeRef: { current: NodeActions | null } = { current: null }; const Probe: React.FC = () => { probeRef.current = useNodeActions('social-1'); return null; }; harness.mountChild(); expect(probeRef.current).not.toBeNull(); let newId: string | null = null; expect(() => { harness!.act(() => { newId = probeRef.current!.duplicate(); }); }).not.toThrow(); expect(newId).not.toBeNull(); expect(newId).not.toBe('social-1'); // +1 node overall. const after = Object.keys(harness.query.getNodes()); expect(after).toHaveLength(4); // Inserted immediately AFTER the source, not appended at the end. const rootChildren: string[] = harness.query.node('ROOT').get().data.nodes; expect(rootChildren).toEqual(['social-1', newId, 'heading-1']); // The new node was selected. expect(Array.from(harness.query.getEvent('selected').all())).toEqual([newId]); // The copy's props object (and any nested arrays/objects within it) must // be a SEPARATE object from the original -- not shared by reference. const originalProps = harness.query.node('social-1').get().data.props; const copyProps = harness.query.node(newId!).get().data.props; expect(copyProps).not.toBe(originalProps); expect(copyProps.links).not.toBe(originalProps.links); expect(copyProps).toEqual(originalProps); // same VALUES though // Mutating the copy via setProp must not affect the original. harness.act(() => { harness!.actions.setProp(newId!, (p: any) => { p.links[0].url = 'https://facebook.com/MUTATED-COPY'; }); }); expect(harness.query.node(newId!).get().data.props.links[0].url).toBe( 'https://facebook.com/MUTATED-COPY', ); expect(harness.query.node('social-1').get().data.props.links[0].url).toBe( 'https://facebook.com/original', ); // Real DOM assertion, not just serialized state: the Frame actually // re-rendered with a second SocialLinks link on the canvas. expect(harness.container.querySelectorAll('a[href="https://facebook.com/original"]')).toHaveLength(1); expect(harness.container.querySelectorAll('a[href="https://facebook.com/MUTATED-COPY"]')).toHaveLength(1); }); test('Ctrl+C / Ctrl+V (useKeyboardShortcuts) on the real editor: pastes a fresh-id sibling copy, original untouched', () => { harness = renderEditorHarness({ initialState: INITIAL_STATE }); const Consumer: React.FC = () => { useKeyboardShortcuts(); return null; }; harness.mountChild(); // Select + copy social-1. harness.act(() => { harness!.actions.selectNode('social-1'); }); harness.act(() => { document.dispatchEvent( new KeyboardEvent('keydown', { key: 'c', ctrlKey: true, bubbles: true, cancelable: true }), ); }); expect(getClipboardNodeId()).toBe('social-1'); // Select heading-1 (a sibling), then paste -- should land as a sibling // of heading-1's parent (ROOT), with brand-new ids. harness.act(() => { harness!.actions.selectNode('heading-1'); }); const before = Object.keys(harness.query.getNodes()); expect(() => { harness!.act(() => { document.dispatchEvent( new KeyboardEvent('keydown', { key: 'v', ctrlKey: true, bubbles: true, cancelable: true }), ); }); }).not.toThrow(); const after = Object.keys(harness.query.getNodes()); expect(after).toHaveLength(before.length + 1); const pastedId = after.find((id) => !before.includes(id))!; expect(pastedId).toBeDefined(); const rootChildren: string[] = harness.query.node('ROOT').get().data.nodes; expect(rootChildren).toContain(pastedId); // Independent props copy again -- same class of bug, same guard. const pastedProps = harness.query.node(pastedId).get().data.props; const originalProps = harness.query.node('social-1').get().data.props; expect(pastedProps.links).not.toBe(originalProps.links); expect(pastedProps).toEqual(originalProps); }); });