Real-<Editor> integration test harness (catches the bug class mocked tests missed) #12
@@ -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