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`/`` 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 | null } { const ref: { current: ReturnType | null } = { current: null }; const Probe: React.FC = () => { ref.current = useApplyAiResponse(); return null; }; h.mountChild(); 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'); }); });