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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user