test(builder): real-@craftjs/core integration test harness (duplicate/paste, template load, AI apply-response)
Mocked unit tests for regenerateTreeIds, TemplateModal.addTemplateComponents,
and buildNodeTree each let a real bug ship (DataCloneError on duplicate/paste,
dropped template children, no-op AI section-replace/insert) because their
fake @craftjs/core query/actions never exercised Craft's real node shape
(data.type as a live component reference) or real parseFreshNode validation.
Adds src/test-utils/editorHarness.tsx, which mounts a REAL <Editor>+<Frame>
(no vi.mock('@craftjs/core') anywhere) via react-dom/client + act, plus the
jsdom shims Craft/this component library actually needs (ResizeObserver,
matchMedia, and an HTMLElement.prototype.innerText polyfill -- jsdom has no
native innerText, which Heading/TextBlock rely on to paint their text).
Adds 3 integration suites under src/test-utils/integration/ driving the real
useNodeActions/useKeyboardShortcuts, TemplateModal's real tree-build pipeline,
and the real useApplyAiResponse hook against a live EditorStore, asserting on
both the real rendered DOM and query.serialize()/exportBodyHtml output.
Red-proofed the duplicate/paste suite: temporarily reverted
regenerateTreeIds to structuredClone(oldNode.data) and confirmed both tests
fail with the historical DataCloneError before restoring the fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
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(<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 }),
|
||||
);
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user