219 lines
8.5 KiB
TypeScript
219 lines
8.5 KiB
TypeScript
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();
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|