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>
186 lines
6.7 KiB
TypeScript
186 lines
6.7 KiB
TypeScript
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');
|
|
});
|
|
});
|