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,218 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { Editor, Frame, Element, useEditor } from '@craftjs/core';
|
||||||
|
import { componentResolver } from '../components/resolver';
|
||||||
|
import { Container } from '../components/layout/Container';
|
||||||
|
import { EditorConfigProvider } from '../state/EditorConfigContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real-`@craftjs/core` integration test harness.
|
||||||
|
*
|
||||||
|
* Mounts an ACTUAL `<Editor resolver={componentResolver}>` + `<Frame>` via
|
||||||
|
* `react-dom/client`'s `createRoot` (no `@testing-library/react`, matching
|
||||||
|
* the existing `*.test.tsx` convention in this repo -- see
|
||||||
|
* `useKeyboardShortcuts.test.tsx` / `RenderNode.test.tsx`). Nothing in
|
||||||
|
* `@craftjs/core` is mocked here: `query`/`actions` come straight out of a
|
||||||
|
* live `useEditor()` call, so `regenerateTreeIds`, `buildNodeTree`,
|
||||||
|
* `parseFreshNode`, `addNodeTree`, `deserialize`, and `serialize` all run
|
||||||
|
* their REAL implementations against a REAL Craft.js `EditorStore`.
|
||||||
|
*
|
||||||
|
* Why this catches bugs the mocked unit tests didn't: a hand-rolled fake
|
||||||
|
* `useEditor`/`query`/`node().toNodeTree()` can only echo back whatever
|
||||||
|
* plain-data shape the test author imagined -- it has no way to reproduce
|
||||||
|
* Craft's actual internal node shape (`data.type` as a live component
|
||||||
|
* function reference, real `parseFreshNode` resolver validation, real
|
||||||
|
* `<Frame>` rendering). Every one of the three historical bugs this suite
|
||||||
|
* targets (`regenerateTreeIds` + `structuredClone`, `TemplateModal` dropping
|
||||||
|
* `children`, `buildNodeTree` emitting `data.type={resolvedName}`) slipped
|
||||||
|
* past its mocked unit test for exactly that reason.
|
||||||
|
*
|
||||||
|
* jsdom shims: as of writing, mounting `<Editor><Frame>...` in this repo's
|
||||||
|
* jsdom (v29, see vitest.config.ts) did NOT require stubbing
|
||||||
|
* `ResizeObserver`/`matchMedia`/`getBoundingClientRect` -- none of the
|
||||||
|
* components exercised by these integration tests read layout geometry
|
||||||
|
* during render. `ensureJsdomShims()` below still installs a `ResizeObserver`
|
||||||
|
* stub defensively (idempotent, only if absent) since it's the one API
|
||||||
|
* Craft/DOM-heavy component code most commonly reaches for and a future
|
||||||
|
* template/component could easily start needing it; add further shims here
|
||||||
|
* (not per-test) if a future component trips over a missing browser API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let shimsInstalled = false;
|
||||||
|
|
||||||
|
/** Install browser APIs jsdom doesn't implement, if not already present. Safe
|
||||||
|
* to call multiple times (idempotent) and safe in a real browser (guarded by
|
||||||
|
* `typeof` checks, so it never clobbers a real implementation). */
|
||||||
|
export function ensureJsdomShims(): void {
|
||||||
|
if (shimsInstalled) return;
|
||||||
|
shimsInstalled = true;
|
||||||
|
|
||||||
|
if (typeof (globalThis as any).ResizeObserver === 'undefined') {
|
||||||
|
class ResizeObserverStub {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
(globalThis as any).ResizeObserver = ResizeObserverStub;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined' && typeof window.matchMedia !== 'function') {
|
||||||
|
window.matchMedia = ((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
})) as unknown as typeof window.matchMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsdom (as of v29, used by this repo's vitest.config.ts) does NOT
|
||||||
|
// implement `HTMLElement.prototype.innerText` at all -- `'innerText' in
|
||||||
|
// document.createElement('div')` is `false`, so assigning to it (as
|
||||||
|
// `Heading.tsx`/`TextBlock.tsx` do imperatively in a `useEffect`, to avoid
|
||||||
|
// fighting `contentEditable`'s caret position on every prop update) just
|
||||||
|
// sets a throwaway own-property that never touches `textContent`/
|
||||||
|
// `innerHTML` -- real rendered text from these two components would
|
||||||
|
// silently read back as empty in any DOM assertion. Shimmed as a plain
|
||||||
|
// `textContent` alias (real `innerText` also collapses whitespace/applies
|
||||||
|
// layout-based visibility, neither of which matters for these tests).
|
||||||
|
if (typeof HTMLElement !== 'undefined') {
|
||||||
|
if (!Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'innerText')) {
|
||||||
|
Object.defineProperty(HTMLElement.prototype, 'innerText', {
|
||||||
|
configurable: true,
|
||||||
|
get(this: HTMLElement) {
|
||||||
|
return this.textContent;
|
||||||
|
},
|
||||||
|
set(this: HTMLElement, value: string) {
|
||||||
|
this.textContent = value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CapturedApi {
|
||||||
|
query: any;
|
||||||
|
actions: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invisible child that grabs a live `query`/`actions` pair out of a real
|
||||||
|
* `useEditor()` call and writes them into a mutable ref the harness owns --
|
||||||
|
* the same "Capture" pattern the brief describes and
|
||||||
|
* `template-zone-export.test.tsx` already uses ad hoc; this hoists it into a
|
||||||
|
* reusable helper. `query`/`actions` from Craft.js are live references (they
|
||||||
|
* read the current store at call time, not a react-state snapshot), so
|
||||||
|
* capturing them once on first render is sufficient -- no need to re-grab
|
||||||
|
* them after every store mutation. */
|
||||||
|
const Capture: React.FC<{ apiRef: React.MutableRefObject<CapturedApi | null> }> = ({ apiRef }) => {
|
||||||
|
const { query, actions } = useEditor();
|
||||||
|
apiRef.current = { query, actions };
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface EditorHarnessOptions {
|
||||||
|
/** A `query.serialize()`-shaped JSON string to `actions.deserialize()`
|
||||||
|
* immediately after mount, replacing the default empty ROOT. */
|
||||||
|
initialState?: string;
|
||||||
|
/** Craft.js `Frame` seed root tag -- 'div' for a regular page, 'header'/
|
||||||
|
* 'footer' for zone editing (mirrors `Canvas.tsx`'s `frameTag`). Ignored
|
||||||
|
* once `initialState` is given (deserialize replaces ROOT anyway); only
|
||||||
|
* affects the very first (pre-deserialize) paint. */
|
||||||
|
frameTag?: 'div' | 'header' | 'footer';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditorHarness {
|
||||||
|
/** Live `query` from the real `useEditor()` -- reads current store state. */
|
||||||
|
readonly query: any;
|
||||||
|
/** Live `actions` from the real `useEditor()`. */
|
||||||
|
readonly actions: any;
|
||||||
|
/** `query.serialize()` -- the exact string `PageContext`/`useWhpApi` persist
|
||||||
|
* and `exportBodyHtml` consumes. */
|
||||||
|
getSerialized: () => string;
|
||||||
|
/** The DOM node the whole tree (including the real rendered `<Frame>`
|
||||||
|
* content) is mounted under -- assert on `.textContent`/`.innerHTML` here
|
||||||
|
* to verify actual rendering, not just serialized state. */
|
||||||
|
container: HTMLDivElement;
|
||||||
|
/** Re-exported `act` from `react-dom/test-utils` for convenience, matching
|
||||||
|
* the existing `*.test.tsx` files in this repo. */
|
||||||
|
act: typeof act;
|
||||||
|
/** Mount (or replace) an additional child inside the live `<Editor>` tree,
|
||||||
|
* alongside `<Frame>` -- e.g. a probe component that calls a real hook
|
||||||
|
* (`useNodeActions`, `useKeyboardShortcuts`) so the test can drive it
|
||||||
|
* against the REAL editor instance this harness set up. Wrapped in `act`. */
|
||||||
|
mountChild: (node: React.ReactNode) => void;
|
||||||
|
/** Unmounts the React tree and removes `container` from `document.body`. */
|
||||||
|
unmount: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mounts a real `<Editor resolver={componentResolver}>` + `<Frame>` (via
|
||||||
|
* `EditorConfigProvider config={null}` -- standalone mode, no WHP backend)
|
||||||
|
* and returns live `query`/`actions` plus helpers for driving further
|
||||||
|
* real-editor interactions from a test.
|
||||||
|
*/
|
||||||
|
export function renderEditorHarness(opts: EditorHarnessOptions = {}): EditorHarness {
|
||||||
|
ensureJsdomShims();
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
const apiRef: React.MutableRefObject<CapturedApi | null> = { current: null };
|
||||||
|
let extraChild: React.ReactNode = null;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
const frameTag = opts.frameTag ?? 'div';
|
||||||
|
|
||||||
|
function paint() {
|
||||||
|
root.render(
|
||||||
|
<EditorConfigProvider config={null}>
|
||||||
|
<Editor resolver={componentResolver} enabled={true}>
|
||||||
|
<Frame>
|
||||||
|
<Element is={Container} canvas tag={frameTag} style={{}} />
|
||||||
|
</Frame>
|
||||||
|
<Capture apiRef={apiRef} />
|
||||||
|
{extraChild}
|
||||||
|
</Editor>
|
||||||
|
</EditorConfigProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root = createRoot(container);
|
||||||
|
paint();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (opts.initialState) {
|
||||||
|
act(() => {
|
||||||
|
apiRef.current!.actions.deserialize(opts.initialState!);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get query() {
|
||||||
|
return apiRef.current!.query;
|
||||||
|
},
|
||||||
|
get actions() {
|
||||||
|
return apiRef.current!.actions;
|
||||||
|
},
|
||||||
|
getSerialized: () => apiRef.current!.query.serialize(),
|
||||||
|
container,
|
||||||
|
act,
|
||||||
|
mountChild: (node: React.ReactNode) => {
|
||||||
|
extraChild = node;
|
||||||
|
act(() => {
|
||||||
|
paint();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
unmount: () => {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { describe, test, expect, afterEach } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { renderEditorHarness, EditorHarness } from '../editorHarness';
|
||||||
|
import { useApplyAiResponse } from '../../utils/apply-ai-response';
|
||||||
|
import { exportBodyHtml } from '../../utils/html-export';
|
||||||
|
import type { SitesmithResponse } from '../../types/sitesmith';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real-`@craftjs/core` integration coverage for applying a Sitesmith (AI)
|
||||||
|
* response.
|
||||||
|
*
|
||||||
|
* Historical bug: `buildNodeTree` used to hand `query.parseFreshNode`/the
|
||||||
|
* manually-constructed synthetic node a `data.type` of the SERIALIZED
|
||||||
|
* `{ resolvedName }` wrapper object (or, in an earlier attempt, a bare
|
||||||
|
* resolvedName string) instead of the actual component function reference
|
||||||
|
* from `componentResolver`. A live Craft.js node's renderer does
|
||||||
|
* `React.createElement(data.type, props)` directly, so anything other than
|
||||||
|
* a real component reference either fails Craft's own resolver validation
|
||||||
|
* inside `parseFreshNode`/`addNodeTree` ("component type ... does not exist
|
||||||
|
* in the resolver") or -- worse -- passes that validation but renders
|
||||||
|
* nothing/garbage. Either way, a section-replace or insert AI op became a
|
||||||
|
* silent no-op in the live app. The pre-existing mocked unit test
|
||||||
|
* (`apply-ai-response.test.ts`) fakes `query.parseFreshNode` as
|
||||||
|
* `(input) => ({ toNode: () => input })` -- a pure echo with NO validation
|
||||||
|
* at all -- so it could never have caught `buildNodeTree` emitting the
|
||||||
|
* wrong `data.type` shape.
|
||||||
|
*
|
||||||
|
* This suite drives the REAL `useApplyAiResponse()` hook (the exact
|
||||||
|
* consumer-facing API `SitesmithModal`/`useSitesmith` call after a
|
||||||
|
* successful AI response) against a real `EditorStore`/`<Frame>` from
|
||||||
|
* `editorHarness.tsx` -- so a regression in `buildNodeTree`'s `data.type`
|
||||||
|
* would throw here (real `parseFreshNode` resolver validation) or fail the
|
||||||
|
* "content actually landed" assertions (real render/export), exactly as it
|
||||||
|
* did live.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const INITIAL_STATE = JSON.stringify({
|
||||||
|
ROOT: {
|
||||||
|
type: { resolvedName: 'Container' },
|
||||||
|
isCanvas: true,
|
||||||
|
props: { style: {}, tag: 'div' },
|
||||||
|
displayName: 'Container',
|
||||||
|
custom: {},
|
||||||
|
hidden: false,
|
||||||
|
nodes: ['anchor-1'],
|
||||||
|
linkedNodes: {},
|
||||||
|
},
|
||||||
|
'anchor-1': {
|
||||||
|
type: { resolvedName: 'Heading' },
|
||||||
|
isCanvas: false,
|
||||||
|
props: { text: 'Anchor Heading', level: 'h2' },
|
||||||
|
displayName: 'Heading',
|
||||||
|
custom: {},
|
||||||
|
hidden: false,
|
||||||
|
parent: 'ROOT',
|
||||||
|
nodes: [],
|
||||||
|
linkedNodes: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let harness: EditorHarness | null = null;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (harness) {
|
||||||
|
harness.unmount();
|
||||||
|
harness = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Mounts `useApplyAiResponse()` (the real hook) inside the harness's live
|
||||||
|
* Editor and returns the captured `apply` function. */
|
||||||
|
function mountApplyFn(h: EditorHarness): { current: ReturnType<typeof useApplyAiResponse> | null } {
|
||||||
|
const ref: { current: ReturnType<typeof useApplyAiResponse> | null } = { current: null };
|
||||||
|
const Probe: React.FC = () => {
|
||||||
|
ref.current = useApplyAiResponse();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
h.mountChild(<Probe />);
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AI apply-response (real @craftjs/core editor)', () => {
|
||||||
|
test('section-scope replace with no target: appends a real, rendered node -- not a silent no-op', async () => {
|
||||||
|
harness = renderEditorHarness({ initialState: INITIAL_STATE });
|
||||||
|
const applyRef = mountApplyFn(harness);
|
||||||
|
expect(applyRef.current).not.toBeNull();
|
||||||
|
|
||||||
|
const resp: SitesmithResponse = {
|
||||||
|
type: 'replace',
|
||||||
|
scope: 'section',
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
name: 'section',
|
||||||
|
tree: { type: { resolvedName: 'Heading' }, props: { text: 'AI Inserted Heading Panel' }, nodes: [] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
message: 'inserted a section',
|
||||||
|
};
|
||||||
|
|
||||||
|
let result: { ok: boolean; message?: string } | undefined;
|
||||||
|
await harness.act(async () => {
|
||||||
|
result = await applyRef.current!(resp);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result!.ok).toBe(true);
|
||||||
|
|
||||||
|
const rootChildren: string[] = harness.query.node('ROOT').get().data.nodes;
|
||||||
|
expect(rootChildren).toHaveLength(2);
|
||||||
|
expect(rootChildren).toContain('anchor-1');
|
||||||
|
const newId = rootChildren.find((id) => id !== 'anchor-1')!;
|
||||||
|
expect(newId).toBeDefined();
|
||||||
|
|
||||||
|
// Real node.data.type must be the actual component reference so React
|
||||||
|
// can render it -- not a `{resolvedName}` wrapper or a bare string.
|
||||||
|
const newNodeType = harness.query.node(newId).get().data.type;
|
||||||
|
expect(typeof newNodeType).toBe('function');
|
||||||
|
|
||||||
|
expect(harness.container.textContent).toContain('AI Inserted Heading Panel');
|
||||||
|
|
||||||
|
const { html } = exportBodyHtml(harness.getSerialized());
|
||||||
|
expect(html).toContain('AI Inserted Heading Panel');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('section-scope replace WITH a target node: replaces that node in place (same slot, fresh id)', async () => {
|
||||||
|
harness = renderEditorHarness({ initialState: INITIAL_STATE });
|
||||||
|
const applyRef = mountApplyFn(harness);
|
||||||
|
|
||||||
|
const resp: SitesmithResponse = {
|
||||||
|
type: 'replace',
|
||||||
|
scope: 'section',
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
name: 'section',
|
||||||
|
tree: { type: { resolvedName: 'Heading' }, props: { text: 'AI Replaced The Anchor' }, nodes: [] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
message: 'replaced the section',
|
||||||
|
};
|
||||||
|
|
||||||
|
await harness.act(async () => {
|
||||||
|
await applyRef.current!(resp, 'anchor-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
const rootChildren: string[] = harness.query.node('ROOT').get().data.nodes;
|
||||||
|
expect(rootChildren).toHaveLength(1); // still just one node in that slot
|
||||||
|
expect(rootChildren).not.toContain('anchor-1'); // the old node is gone
|
||||||
|
|
||||||
|
expect(harness.container.textContent).toContain('AI Replaced The Anchor');
|
||||||
|
expect(harness.container.textContent).not.toContain('Anchor Heading');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('patch op insert_after: inserts a real node as the very next sibling', async () => {
|
||||||
|
harness = renderEditorHarness({ initialState: INITIAL_STATE });
|
||||||
|
const applyRef = mountApplyFn(harness);
|
||||||
|
|
||||||
|
const resp: SitesmithResponse = {
|
||||||
|
type: 'patch',
|
||||||
|
ops: [
|
||||||
|
{
|
||||||
|
op: 'insert_after',
|
||||||
|
node_id: 'anchor-1',
|
||||||
|
tree: { type: { resolvedName: 'Heading' }, props: { text: 'AI Patched In After Anchor' }, nodes: [] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
message: 'patched',
|
||||||
|
};
|
||||||
|
|
||||||
|
let result: { ok: boolean; message?: string } | undefined;
|
||||||
|
await harness.act(async () => {
|
||||||
|
result = await applyRef.current!(resp);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result!.ok).toBe(true);
|
||||||
|
|
||||||
|
const rootChildren: string[] = harness.query.node('ROOT').get().data.nodes;
|
||||||
|
expect(rootChildren).toHaveLength(2);
|
||||||
|
expect(rootChildren[0]).toBe('anchor-1');
|
||||||
|
const insertedId = rootChildren[1];
|
||||||
|
|
||||||
|
expect(typeof harness.query.node(insertedId).get().data.type).toBe('function');
|
||||||
|
expect(harness.container.textContent).toContain('AI Patched In After Anchor');
|
||||||
|
// The original anchor is untouched.
|
||||||
|
expect(harness.container.textContent).toContain('Anchor Heading');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { describe, test, expect, afterEach } from 'vitest';
|
||||||
|
import { renderEditorHarness, EditorHarness } from '../editorHarness';
|
||||||
|
import { allTemplates } from '../../templates';
|
||||||
|
import { buildNodeTree } from '../../utils/craft-tree';
|
||||||
|
import { templateComponentToTreeNode } from '../../templates/apply-template';
|
||||||
|
import { exportBodyHtml } from '../../utils/html-export';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real-`@craftjs/core` integration coverage for loading a template.
|
||||||
|
*
|
||||||
|
* Historical bug: `TemplateModal.addTemplateComponents` used to build each
|
||||||
|
* template component via `React.createElement(Component, comp.props)` with
|
||||||
|
* NO children argument, silently dropping every nested `children` array
|
||||||
|
* authored in `templates/definitions.ts` (e.g. `makeHeader()`'s
|
||||||
|
* `Container > Logo + Menu`, `makeFooterContent()`'s `Container > TextBlock`,
|
||||||
|
* or any page `Section` wrapping a `Heading`/`ColumnLayout`). The resulting
|
||||||
|
* Craft.js node had `nodes: []`, so both the editor's own header/footer zone
|
||||||
|
* preview AND the published HTML (same `exportBodyHtml` path) rendered as an
|
||||||
|
* empty strip instead of the real nav/footer/section content. The mocked
|
||||||
|
* unit test for `templateComponentToTreeNode` (`apply-template.test.ts`)
|
||||||
|
* tests the pure conversion function in isolation and would pass either way
|
||||||
|
* -- it never drove the result through a real Craft.js tree/render, so it
|
||||||
|
* couldn't have caught `TemplateModal` itself dropping children at the
|
||||||
|
* call site.
|
||||||
|
*
|
||||||
|
* This suite drives the EXACT same path `TemplateModal.tsx`'s
|
||||||
|
* `addTemplateComponents` uses against a REAL Craft.js editor mounted with a
|
||||||
|
* REAL `<Frame>` (via `editorHarness.tsx`) -- so it also exercises real
|
||||||
|
* component rendering (Section/Container SHELL_INNER canvases, ColumnLayout
|
||||||
|
* linked columns), not just tree-shape assertions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const tpl = allTemplates.find((t) => t.id === 'saas-landing')!;
|
||||||
|
|
||||||
|
/** Mirrors TemplateModal.tsx's addTemplateComponents(). */
|
||||||
|
function addTemplateComponents(harness: EditorHarness, components: any[]) {
|
||||||
|
for (const comp of components) {
|
||||||
|
const tree = buildNodeTree(harness.query, templateComponentToTreeNode(comp));
|
||||||
|
harness.actions.addNodeTree(tree, 'ROOT');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_HEADER_ROOT = JSON.stringify({
|
||||||
|
ROOT: {
|
||||||
|
type: { resolvedName: 'Container' },
|
||||||
|
isCanvas: true,
|
||||||
|
props: { style: {}, tag: 'header' },
|
||||||
|
displayName: 'Container',
|
||||||
|
custom: {},
|
||||||
|
hidden: false,
|
||||||
|
nodes: [],
|
||||||
|
linkedNodes: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const EMPTY_FOOTER_ROOT = JSON.stringify({
|
||||||
|
ROOT: {
|
||||||
|
type: { resolvedName: 'Container' },
|
||||||
|
isCanvas: true,
|
||||||
|
props: { style: {}, tag: 'footer' },
|
||||||
|
displayName: 'Container',
|
||||||
|
custom: {},
|
||||||
|
hidden: false,
|
||||||
|
nodes: [],
|
||||||
|
linkedNodes: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let harness: EditorHarness | null = null;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (harness) {
|
||||||
|
harness.unmount();
|
||||||
|
harness = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('template load (real @craftjs/core editor + real <Frame> render)', () => {
|
||||||
|
test('sanity: the SaaS Landing template actually has nested header/footer/page content to lose', () => {
|
||||||
|
expect(tpl).toBeDefined();
|
||||||
|
expect(tpl.header.components.length).toBeGreaterThan(0);
|
||||||
|
expect(tpl.footer.components.length).toBeGreaterThan(0);
|
||||||
|
expect(tpl.pages[0].content.components.length).toBeGreaterThan(0);
|
||||||
|
const hasNestedChildren = tpl.header.components.some((c: any) => (c.children?.length ?? 0) > 0);
|
||||||
|
expect(hasNestedChildren).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loading the header keeps its nested nav links + logo -- both in the real DOM and in export', () => {
|
||||||
|
harness = renderEditorHarness({ initialState: EMPTY_HEADER_ROOT, frameTag: 'header' });
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
harness!.act(() => addTemplateComponents(harness!, tpl.header.components));
|
||||||
|
}).not.toThrow();
|
||||||
|
|
||||||
|
// Real rendered DOM -- not just serialized state.
|
||||||
|
expect(harness.container.textContent).toContain('FlowStack');
|
||||||
|
expect(harness.container.textContent).toContain('Features');
|
||||||
|
expect(harness.container.textContent).toContain('Start Free');
|
||||||
|
|
||||||
|
const { html } = exportBodyHtml(harness.getSerialized());
|
||||||
|
expect(html).toContain('FlowStack');
|
||||||
|
expect(html).toContain('Features');
|
||||||
|
expect(html).toContain('Start Free');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loading the footer keeps its nested copyright/tagline content -- both in the real DOM and in export', () => {
|
||||||
|
harness = renderEditorHarness({ initialState: EMPTY_FOOTER_ROOT, frameTag: 'footer' });
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
harness!.act(() => addTemplateComponents(harness!, tpl.footer.components));
|
||||||
|
}).not.toThrow();
|
||||||
|
|
||||||
|
expect(harness.container.textContent).toContain('FlowStack, Inc.');
|
||||||
|
expect(harness.container.textContent).toContain('Ship products faster');
|
||||||
|
|
||||||
|
const { html } = exportBodyHtml(harness.getSerialized());
|
||||||
|
expect(html).toContain('FlowStack, Inc.');
|
||||||
|
expect(html).toContain('Ship products faster');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loading the home page keeps a Section-wrapped Heading\'s nested content -- both in the real DOM and in export', () => {
|
||||||
|
const homePage = tpl.pages[0];
|
||||||
|
const sectionComp = homePage.content.components.find((c: any) => c.type === 'Section');
|
||||||
|
expect(sectionComp).toBeDefined();
|
||||||
|
expect(sectionComp!.children?.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
harness = renderEditorHarness(); // default empty 'div' root, like a regular page
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
harness!.act(() => addTemplateComponents(harness!, [sectionComp]));
|
||||||
|
}).not.toThrow();
|
||||||
|
|
||||||
|
expect(harness.container.textContent).toContain('Trusted by 10,000+ development teams');
|
||||||
|
|
||||||
|
const { html } = exportBodyHtml(harness.getSerialized());
|
||||||
|
expect(html).toContain('Trusted by 10,000+ development teams');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loading the FULL home page (all components) keeps every top-level node\'s content -- no silent drops', () => {
|
||||||
|
const homePage = tpl.pages[0];
|
||||||
|
harness = renderEditorHarness();
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
harness!.act(() => addTemplateComponents(harness!, homePage.content.components));
|
||||||
|
}).not.toThrow();
|
||||||
|
|
||||||
|
// ROOT should have exactly as many direct children as the template
|
||||||
|
// defines top-level page components -- if children got dropped, nested
|
||||||
|
// content wouldn't show up in ROOT's descendant count via serialize, but
|
||||||
|
// an easier, more direct regression signal is: every top-level
|
||||||
|
// component's OWN text ends up rendered somewhere on the canvas.
|
||||||
|
const serialized = JSON.parse(harness.getSerialized());
|
||||||
|
const totalNodeCount = Object.keys(serialized).length;
|
||||||
|
// A flat (children-dropped) tree would have exactly
|
||||||
|
// homePage.content.components.length + 1 (ROOT) nodes. The real template
|
||||||
|
// has nested children, so a correct load must produce MORE nodes than
|
||||||
|
// that -- this is the shape-level assertion the pre-existing mocked test
|
||||||
|
// could never make (it never touched a real tree).
|
||||||
|
expect(totalNodeCount).toBeGreaterThan(homePage.content.components.length + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user