2026-07-13 17:11:40 -07:00
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' ;
2026-07-14 07:36:00 -07:00
import { getClipboardTree , setClipboardTree } from '../../hooks/clipboard' ;
2026-07-13 17:11:40 -07:00
/**
* 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 : {},
},
});
2026-07-14 07:36:00 -07:00
// A second, independent "page" state -- distinct node ids from INITIAL_STATE,
// simulating what PageContext.switchPage does: `query.serialize()` the
// current page, then `actions.deserialize()` the target page's stored
// state, replacing the ENTIRE node map. `social-1` (copied from page A)
// does not exist anywhere in this state.
const PAGE_B_STATE = JSON . stringify ({
ROOT : {
type : { resolvedName : 'Container' },
isCanvas : true ,
props : { style : {}, tag : 'div' },
displayName : 'Container' ,
custom : {},
hidden : false ,
nodes : [ 'page-b-heading-1' ],
linkedNodes : {},
},
'page-b-heading-1' : {
type : { resolvedName : 'Heading' },
isCanvas : false ,
props : { text : 'Page B Heading' , level : 'h2' },
displayName : 'Heading' ,
custom : {},
hidden : false ,
parent : 'ROOT' ,
nodes : [],
linkedNodes : {},
},
});
2026-07-13 17:11:40 -07:00
let harness : EditorHarness | null = null ;
afterEach (() => {
2026-07-14 07:36:00 -07:00
setClipboardTree ( null );
2026-07-13 17:11:40 -07:00
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 (< Probe />);
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 <a> 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 (< Consumer />);
// 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 }),
);
});
2026-07-14 07:36:00 -07:00
expect ( getClipboardTree () ! . rootNodeId ). toBe ( 'social-1' );
2026-07-13 17:11:40 -07:00
// 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 );
});
2026-07-14 07:36:00 -07:00
test ( 'CROSS-PAGE copy/paste: copy on page A, switch to page B, paste -- the node appears on page B' , () => {
harness = renderEditorHarness ({ initialState : INITIAL_STATE });
const Consumer : React.FC = () => {
useKeyboardShortcuts ();
return null ;
};
harness . mountChild (< Consumer />);
// --- Page A: 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 ( getClipboardTree () ! . rootNodeId ). toBe ( 'social-1' );
// --- Switch to page B: exactly what PageContext.switchPage does --
// serialize (discarded here, a real page switch would stash it) then
// deserialize the target page's state, replacing the ENTIRE node map.
// `social-1` no longer exists anywhere in `query` after this. ---
harness . act (() => {
harness ! . actions . deserialize ( PAGE_B_STATE );
});
expect ( harness . query . getNodes ()[ 'social-1' ]). toBeUndefined ();
// Select page B's only node, then paste.
harness . act (() => {
harness ! . actions . selectNode ( 'page-b-heading-1' );
});
const beforePaste = Object . keys ( harness . query . getNodes ());
expect (() => {
harness ! . act (() => {
document . dispatchEvent (
new KeyboardEvent ( 'keydown' , { key : 'v' , ctrlKey : true , bubbles : true , cancelable : true }),
);
});
}). not . toThrow ();
const afterPaste = Object . keys ( harness . query . getNodes ());
// The regression this guards against: with the old id-based clipboard,
// `query.node('social-1').get()` returns undefined once page B is
// loaded, so the paste handler's guard silently no-ops -- NOTHING gets
// added. With the tree-snapshot clipboard, the copied subtree is
// detached from any live query and pastes onto page B regardless.
expect ( afterPaste . length ). toBe ( beforePaste . length + 1 );
const pastedId = afterPaste . find (( id ) => ! beforePaste . includes ( id )) ! ;
expect ( pastedId ). toBeDefined ();
const pastedNode = harness . query . node ( pastedId ). get ();
expect ( pastedNode . data . displayName ). toBe ( 'Social Links' );
expect ( pastedNode . data . props . links ). toEqual ([
{ platform : 'facebook' , url : 'https://facebook.com/original' },
]);
// Actually landed on page B's tree, as a sibling of the selected node.
const rootChildren : string [] = harness . query . node ( 'ROOT' ). get (). data . nodes ;
expect ( rootChildren ). toContain ( pastedId );
// Real DOM assertion: the pasted SocialLinks component is actually
// rendered on the (now page B) canvas.
expect (
harness . container . querySelectorAll ( 'a[href="https://facebook.com/original"]' ),
). toHaveLength ( 1 );
});
2026-07-13 17:11:40 -07:00
});