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 `` + `` 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 * `` 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 `...` 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 }> = ({ 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 `` * 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 `` tree, * alongside `` -- 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 `` + `` (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 = { current: null }; let extraChild: React.ReactNode = null; let root: Root; const frameTag = opts.frameTag ?? 'div'; function paint() { root.render( {extraChild} , ); } 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(); }, }; }