diff --git a/craft/src/test-utils/editorHarness.tsx b/craft/src/test-utils/editorHarness.tsx new file mode 100644 index 0000000..3bb5d8a --- /dev/null +++ b/craft/src/test-utils/editorHarness.tsx @@ -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 `` + `` 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(); + }, + }; +} diff --git a/craft/src/test-utils/integration/ai-apply-response.integration.test.tsx b/craft/src/test-utils/integration/ai-apply-response.integration.test.tsx new file mode 100644 index 0000000..2e7f1d2 --- /dev/null +++ b/craft/src/test-utils/integration/ai-apply-response.integration.test.tsx @@ -0,0 +1,185 @@ +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'); + }); +}); diff --git a/craft/src/test-utils/integration/duplicate-paste.integration.test.tsx b/craft/src/test-utils/integration/duplicate-paste.integration.test.tsx new file mode 100644 index 0000000..299f62d --- /dev/null +++ b/craft/src/test-utils/integration/duplicate-paste.integration.test.tsx @@ -0,0 +1,193 @@ +import { describe, test, expect, afterEach } from 'vitest'; +import React from 'react'; +import { renderEditorHarness, EditorHarness } from '../editorHarness'; +import { useNodeActions, NodeActions } from '../../hooks/useNodeActions'; +import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'; +import { getClipboardNodeId, setClipboardNodeId } from '../../hooks/clipboard'; + +/** + * Real-`@craftjs/core` integration coverage for duplicate/paste. + * + * Historical bug: `regenerateTreeIds` used `structuredClone(oldNode.data)` to + * clone a copied subtree. For a REAL (live) Craft.js node, `data.type` is the + * actual component function reference (not a plain `{resolvedName}` string + * wrapper) -- `structuredClone` cannot clone a function and throws + * `DataCloneError`, which silently broke duplicate/paste for EVERY + * component in the real app. The pre-existing mocked unit tests + * (`useNodeActions.test.tsx`, `useKeyboardShortcuts.test.tsx`) faked + * `query.node(id).toNodeTree()` to return a plain-data object with + * `type: { resolvedName: 'Container' }` -- never a function -- so + * `structuredClone` never saw anything it couldn't clone there, and the bug + * shipped anyway. + * + * This suite drives the REAL `useNodeActions`/`useKeyboardShortcuts` hooks + * (no `vi.mock('@craftjs/core')` anywhere) against a real + * `EditorStore`/`query`/`actions` from `editorHarness.tsx`, on a real + * `SocialLinks` node (its `.craft` config makes Craft.js store the actual + * `SocialLinks` function as `data.type`) -- so a regression back to + * `structuredClone(oldNode.data)` throws here exactly as it did live. See + * `task-integ-harness-report.md` for the recorded red-proof run. + */ + +const INITIAL_STATE = JSON.stringify({ + ROOT: { + type: { resolvedName: 'Container' }, + isCanvas: true, + props: { style: {}, tag: 'div' }, + displayName: 'Container', + custom: {}, + hidden: false, + nodes: ['social-1', 'heading-1'], + linkedNodes: {}, + }, + 'social-1': { + type: { resolvedName: 'SocialLinks' }, + isCanvas: false, + props: { + links: [{ platform: 'facebook', url: 'https://facebook.com/original' }], + iconColor: '#ffffff', + }, + displayName: 'Social Links', + custom: {}, + hidden: false, + parent: 'ROOT', + nodes: [], + linkedNodes: {}, + }, + 'heading-1': { + type: { resolvedName: 'Heading' }, + isCanvas: false, + props: { text: 'Sibling Heading', level: 'h2' }, + displayName: 'Heading', + custom: {}, + hidden: false, + parent: 'ROOT', + nodes: [], + linkedNodes: {}, + }, +}); + +let harness: EditorHarness | null = null; + +afterEach(() => { + setClipboardNodeId(null); + if (harness) { + harness.unmount(); + harness = null; + } +}); + +describe('duplicate/paste (real @craftjs/core editor)', () => { + test('useNodeActions().duplicate() on a real SocialLinks node: no throw, +1 node, inserted right after the source, with an independent props copy', () => { + harness = renderEditorHarness({ initialState: INITIAL_STATE }); + + const before = Object.keys(harness.query.getNodes()); + expect(before).toHaveLength(3); // ROOT, social-1, heading-1 + + const probeRef: { current: NodeActions | null } = { current: null }; + const Probe: React.FC = () => { + probeRef.current = useNodeActions('social-1'); + return null; + }; + harness.mountChild(); + expect(probeRef.current).not.toBeNull(); + + let newId: string | null = null; + expect(() => { + harness!.act(() => { + newId = probeRef.current!.duplicate(); + }); + }).not.toThrow(); + + expect(newId).not.toBeNull(); + expect(newId).not.toBe('social-1'); + + // +1 node overall. + const after = Object.keys(harness.query.getNodes()); + expect(after).toHaveLength(4); + + // Inserted immediately AFTER the source, not appended at the end. + const rootChildren: string[] = harness.query.node('ROOT').get().data.nodes; + expect(rootChildren).toEqual(['social-1', newId, 'heading-1']); + + // The new node was selected. + expect(Array.from(harness.query.getEvent('selected').all())).toEqual([newId]); + + // The copy's props object (and any nested arrays/objects within it) must + // be a SEPARATE object from the original -- not shared by reference. + const originalProps = harness.query.node('social-1').get().data.props; + const copyProps = harness.query.node(newId!).get().data.props; + expect(copyProps).not.toBe(originalProps); + expect(copyProps.links).not.toBe(originalProps.links); + expect(copyProps).toEqual(originalProps); // same VALUES though + + // Mutating the copy via setProp must not affect the original. + harness.act(() => { + harness!.actions.setProp(newId!, (p: any) => { + p.links[0].url = 'https://facebook.com/MUTATED-COPY'; + }); + }); + expect(harness.query.node(newId!).get().data.props.links[0].url).toBe( + 'https://facebook.com/MUTATED-COPY', + ); + expect(harness.query.node('social-1').get().data.props.links[0].url).toBe( + 'https://facebook.com/original', + ); + + // Real DOM assertion, not just serialized state: the Frame actually + // re-rendered with a second SocialLinks link on the canvas. + expect(harness.container.querySelectorAll('a[href="https://facebook.com/original"]')).toHaveLength(1); + expect(harness.container.querySelectorAll('a[href="https://facebook.com/MUTATED-COPY"]')).toHaveLength(1); + }); + + test('Ctrl+C / Ctrl+V (useKeyboardShortcuts) on the real editor: pastes a fresh-id sibling copy, original untouched', () => { + harness = renderEditorHarness({ initialState: INITIAL_STATE }); + + const Consumer: React.FC = () => { + useKeyboardShortcuts(); + return null; + }; + harness.mountChild(); + + // Select + copy social-1. + harness.act(() => { + harness!.actions.selectNode('social-1'); + }); + harness.act(() => { + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'c', ctrlKey: true, bubbles: true, cancelable: true }), + ); + }); + expect(getClipboardNodeId()).toBe('social-1'); + + // Select heading-1 (a sibling), then paste -- should land as a sibling + // of heading-1's parent (ROOT), with brand-new ids. + harness.act(() => { + harness!.actions.selectNode('heading-1'); + }); + + const before = Object.keys(harness.query.getNodes()); + expect(() => { + harness!.act(() => { + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'v', ctrlKey: true, bubbles: true, cancelable: true }), + ); + }); + }).not.toThrow(); + + const after = Object.keys(harness.query.getNodes()); + expect(after).toHaveLength(before.length + 1); + + const pastedId = after.find((id) => !before.includes(id))!; + expect(pastedId).toBeDefined(); + + const rootChildren: string[] = harness.query.node('ROOT').get().data.nodes; + expect(rootChildren).toContain(pastedId); + + // Independent props copy again -- same class of bug, same guard. + const pastedProps = harness.query.node(pastedId).get().data.props; + const originalProps = harness.query.node('social-1').get().data.props; + expect(pastedProps.links).not.toBe(originalProps.links); + expect(pastedProps).toEqual(originalProps); + }); +}); diff --git a/craft/src/test-utils/integration/template-load.integration.test.tsx b/craft/src/test-utils/integration/template-load.integration.test.tsx new file mode 100644 index 0000000..15aaec4 --- /dev/null +++ b/craft/src/test-utils/integration/template-load.integration.test.tsx @@ -0,0 +1,161 @@ +import { describe, test, expect, afterEach } from 'vitest'; +import { renderEditorHarness, EditorHarness } from '../editorHarness'; +import { allTemplates } from '../../templates'; +import { buildNodeTree } from '../../utils/craft-tree'; +import { templateComponentToTreeNode } from '../../templates/apply-template'; +import { exportBodyHtml } from '../../utils/html-export'; + +/** + * Real-`@craftjs/core` integration coverage for loading a template. + * + * Historical bug: `TemplateModal.addTemplateComponents` used to build each + * template component via `React.createElement(Component, comp.props)` with + * NO children argument, silently dropping every nested `children` array + * authored in `templates/definitions.ts` (e.g. `makeHeader()`'s + * `Container > Logo + Menu`, `makeFooterContent()`'s `Container > TextBlock`, + * or any page `Section` wrapping a `Heading`/`ColumnLayout`). The resulting + * Craft.js node had `nodes: []`, so both the editor's own header/footer zone + * preview AND the published HTML (same `exportBodyHtml` path) rendered as an + * empty strip instead of the real nav/footer/section content. The mocked + * unit test for `templateComponentToTreeNode` (`apply-template.test.ts`) + * tests the pure conversion function in isolation and would pass either way + * -- it never drove the result through a real Craft.js tree/render, so it + * couldn't have caught `TemplateModal` itself dropping children at the + * call site. + * + * This suite drives the EXACT same path `TemplateModal.tsx`'s + * `addTemplateComponents` uses against a REAL Craft.js editor mounted with a + * REAL `` (via `editorHarness.tsx`) -- so it also exercises real + * component rendering (Section/Container SHELL_INNER canvases, ColumnLayout + * linked columns), not just tree-shape assertions. + */ + +const tpl = allTemplates.find((t) => t.id === 'saas-landing')!; + +/** Mirrors TemplateModal.tsx's addTemplateComponents(). */ +function addTemplateComponents(harness: EditorHarness, components: any[]) { + for (const comp of components) { + const tree = buildNodeTree(harness.query, templateComponentToTreeNode(comp)); + harness.actions.addNodeTree(tree, 'ROOT'); + } +} + +const EMPTY_HEADER_ROOT = JSON.stringify({ + ROOT: { + type: { resolvedName: 'Container' }, + isCanvas: true, + props: { style: {}, tag: 'header' }, + displayName: 'Container', + custom: {}, + hidden: false, + nodes: [], + linkedNodes: {}, + }, +}); + +const EMPTY_FOOTER_ROOT = JSON.stringify({ + ROOT: { + type: { resolvedName: 'Container' }, + isCanvas: true, + props: { style: {}, tag: 'footer' }, + displayName: 'Container', + custom: {}, + hidden: false, + nodes: [], + linkedNodes: {}, + }, +}); + +let harness: EditorHarness | null = null; + +afterEach(() => { + if (harness) { + harness.unmount(); + harness = null; + } +}); + +describe('template load (real @craftjs/core editor + real render)', () => { + test('sanity: the SaaS Landing template actually has nested header/footer/page content to lose', () => { + expect(tpl).toBeDefined(); + expect(tpl.header.components.length).toBeGreaterThan(0); + expect(tpl.footer.components.length).toBeGreaterThan(0); + expect(tpl.pages[0].content.components.length).toBeGreaterThan(0); + const hasNestedChildren = tpl.header.components.some((c: any) => (c.children?.length ?? 0) > 0); + expect(hasNestedChildren).toBe(true); + }); + + test('loading the header keeps its nested nav links + logo -- both in the real DOM and in export', () => { + harness = renderEditorHarness({ initialState: EMPTY_HEADER_ROOT, frameTag: 'header' }); + + expect(() => { + harness!.act(() => addTemplateComponents(harness!, tpl.header.components)); + }).not.toThrow(); + + // Real rendered DOM -- not just serialized state. + expect(harness.container.textContent).toContain('FlowStack'); + expect(harness.container.textContent).toContain('Features'); + expect(harness.container.textContent).toContain('Start Free'); + + const { html } = exportBodyHtml(harness.getSerialized()); + expect(html).toContain('FlowStack'); + expect(html).toContain('Features'); + expect(html).toContain('Start Free'); + }); + + test('loading the footer keeps its nested copyright/tagline content -- both in the real DOM and in export', () => { + harness = renderEditorHarness({ initialState: EMPTY_FOOTER_ROOT, frameTag: 'footer' }); + + expect(() => { + harness!.act(() => addTemplateComponents(harness!, tpl.footer.components)); + }).not.toThrow(); + + expect(harness.container.textContent).toContain('FlowStack, Inc.'); + expect(harness.container.textContent).toContain('Ship products faster'); + + const { html } = exportBodyHtml(harness.getSerialized()); + expect(html).toContain('FlowStack, Inc.'); + expect(html).toContain('Ship products faster'); + }); + + test('loading the home page keeps a Section-wrapped Heading\'s nested content -- both in the real DOM and in export', () => { + const homePage = tpl.pages[0]; + const sectionComp = homePage.content.components.find((c: any) => c.type === 'Section'); + expect(sectionComp).toBeDefined(); + expect(sectionComp!.children?.length).toBeGreaterThan(0); + + harness = renderEditorHarness(); // default empty 'div' root, like a regular page + + expect(() => { + harness!.act(() => addTemplateComponents(harness!, [sectionComp])); + }).not.toThrow(); + + expect(harness.container.textContent).toContain('Trusted by 10,000+ development teams'); + + const { html } = exportBodyHtml(harness.getSerialized()); + expect(html).toContain('Trusted by 10,000+ development teams'); + }); + + test('loading the FULL home page (all components) keeps every top-level node\'s content -- no silent drops', () => { + const homePage = tpl.pages[0]; + harness = renderEditorHarness(); + + expect(() => { + harness!.act(() => addTemplateComponents(harness!, homePage.content.components)); + }).not.toThrow(); + + // ROOT should have exactly as many direct children as the template + // defines top-level page components -- if children got dropped, nested + // content wouldn't show up in ROOT's descendant count via serialize, but + // an easier, more direct regression signal is: every top-level + // component's OWN text ends up rendered somewhere on the canvas. + const serialized = JSON.parse(harness.getSerialized()); + const totalNodeCount = Object.keys(serialized).length; + // A flat (children-dropped) tree would have exactly + // homePage.content.components.length + 1 (ROOT) nodes. The real template + // has nested children, so a correct load must produce MORE nodes than + // that -- this is the shape-level assertion the pre-existing mocked test + // could never make (it never touched a real tree). + expect(totalNodeCount).toBeGreaterThan(homePage.content.components.length + 1); + }); +});