import { afterEach, describe, expect, it } from 'vitest' import { act } from 'react' import { byId, click, setup } from './harness' import type { Harness } from './harness' let h: Harness afterEach(() => h?.cleanup()) const selectBlock = async (id: string) => { await act(async () => void click(byId(id))) await h.settle() } const order = (parent: string) => [...byId(parent).querySelectorAll(':scope > [data-cms]')].map((e) => e.getAttribute('data-cms')) describe('on-page editor against the mock CMS', () => { it('scans blocks and selects on click without navigating', async () => { h = await setup() expect(h.editor.ctrl.store.get().blocks.map((b) => b.id)).toEqual(['s1', 'h1', 't1', 'b1', 's2', 't2', 'l1', 'x1', 'x2', 'f1']) const ev = new MouseEvent('click', { bubbles: true, cancelable: true }) await act(async () => void byId('b1').querySelector('a')!.dispatchEvent(ev)) expect(ev.defaultPrevented).toBe(true) expect(h.editor.ctrl.store.get().selected).toBe('b1') }) it('browse mode leaves the page alone', async () => { h = await setup() await h.press('Toggle editing (links work in browse mode)') const ev = new MouseEvent('click', { bubbles: true, cancelable: true }) await act(async () => void byId('b1').querySelector('a')!.dispatchEvent(ev)) expect(ev.defaultPrevented).toBe(false) expect(h.editor.ctrl.store.get().selected).toBeNull() }) it('resolves the edge through the API (no data-cms-edge in the markup) and moves a block', async () => { h = await setup() await selectBlock('t1') await h.editor.ctrl.nudge('t1', 1) await h.settle() expect(order('s1')).toEqual(['h1', 'b1', 't1']) // morphed from the server patch expect(h.cms.graph.children('s1').map((e) => e.to)).toEqual(['h1', 'b1', 't1']) }) it('undo/redo replay the server inverse and re-morph the page', async () => { h = await setup() await selectBlock('t1') await h.editor.ctrl.nudge('t1', 1) await h.settle() await h.press('Undo (Ctrl+Z)') expect(order('s1')).toEqual(['h1', 't1', 'b1']) await h.press('Redo (Ctrl+Shift+Z)') expect(order('s1')).toEqual(['h1', 'b1', 't1']) }) it('delete + undo restores the subtree and DOM', async () => { h = await setup() await h.editor.ctrl.remove('s1') await h.settle() expect(document.querySelector('[data-cms="s1"]')).toBeNull() await h.press('Undo (Ctrl+Z)') expect(byId('h1').textContent).toBe('Edit this headline') }) it('insert goes through the picker, uses x-graph children, and selects the new block', async () => { h = await setup() await selectBlock('s1') await h.press('Add inside') const tiles = [...h.shadow.querySelectorAll('.cms-tile')].map((t) => t.dataset.type) expect(tiles).toEqual(expect.arrayContaining(['heading', 'text', 'button', 'section'])) expect(tiles).not.toContain('page') await act(async () => void h.shadow.querySelector('.cms-tile[data-type="button"]')!.click()) await h.settle() expect(h.cms.graph.children('s1')).toHaveLength(4) expect(h.editor.ctrl.store.get().selected).toBe(h.cms.graph.children('s1')[3].to) }) it('page-level insert resolves the root from the top block, and a root patch error falls back to a soft reload', async () => { h = await setup() await selectBlock('s1') const root = await h.editor.ctrl.parentIdOf('s1') expect(root).toBe('p1') await h.editor.ctrl.insert(root, 'heading', { data: { text: 'Top-level' }, index: 0 }) await h.settle() // The server could not render the page node standalone: the editor re-fetched and morphed. expect(document.querySelector('[data-cms-type="heading"]')!.textContent).toBe('Top-level') }) it('properties dialog edits through the schema form and saves only the diff', async () => { h = await setup() await selectBlock('s1') await h.press('Properties') const input = h.shadow.querySelector('[data-path="title"] input')! expect(input.value).toBe('Welcome') const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set! await act(async () => { set.call(input, 'Hello') input.dispatchEvent(new Event('input', { bubbles: true })) }) await act(async () => void h.shadow.querySelector('.cms-btn--primary')!.click()) await h.settle() expect(h.cms.graph.node('s1').data).toMatchObject({ title: 'Hello', tone: 'dark', padded: true }) expect(byId('s1').querySelector('.sec__title')!.textContent).toBe('Hello') const edit = h.editor.ctrl.store.get().log.find((l) => l.url === '/api/edit')! expect((edit.request as any).ops[0]).toMatchObject({ op: 'set', id: 's1', data: { title: 'Hello' }, version: 1 }) }) it('array editors save through the dialog as a minimal diff, and invalid data blocks Save', async () => { h = await setup() await selectBlock('f1') await h.press('Properties') const links = h.shadow.querySelector('[data-path="links"]')! await act(async () => void links.querySelector('[aria-label="Add item"]')!.click()) const save = () => h.shadow.querySelector('.cms-btn--primary')! expect(save().disabled).toBe(true) // new link has an empty required "text", and nothing else changed yet expect(h.shadow.querySelector('.cms-actions__note')!.textContent).toContain('1 problem') const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set! const text = h.shadow.querySelector('[data-path="links.2.text"] input')! await act(async () => { set.call(text, 'Contact') text.dispatchEvent(new Event('input', { bubbles: true })) }) expect(save().disabled).toBe(false) await act(async () => void save().click()) await h.settle() expect(h.cms.graph.node('f1').data.links.map((l: any) => l.text)).toEqual(['About', 'Careers', 'Contact']) const op = h.editor.ctrl.store.get().log.find((l) => l.url === '/api/edit')!.request as any expect(Object.keys(op.ops[0].data)).toEqual(['links']) // companyName, usps, social... untouched }) it('a stale version shows a conflict instead of overwriting', async () => { h = await setup() await selectBlock('s1') await h.press('Properties') h.cms.graph.edit([{ op: 'set', id: 's1', data: { title: 'Someone else' } }]) // concurrent editor const input = h.shadow.querySelector('[data-path="title"] input')! const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set! await act(async () => { set.call(input, 'Mine') input.dispatchEvent(new Event('input', { bubbles: true })) }) await act(async () => void h.shadow.querySelector('.cms-btn--primary')!.click()) await h.settle() expect(h.cms.graph.node('s1').data.title).toBe('Someone else') expect(h.editor.ctrl.store.get().toast?.text).toMatch(/Someone else changed/) }) it('inline text editing sends debounced, coalesced sets (one undo step)', async () => { h = await setup() const field = byId('h1') await act(async () => void field.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, composed: true }))) await h.settle() expect(field.getAttribute('contenteditable')).toBeTruthy() for (const t of ['Hel', 'Hello', 'Hello world']) { field.textContent = t field.dispatchEvent(new Event('input', { bubbles: true })) } h.editor.ctrl.stopInline() // flushes await h.settle() expect(h.cms.graph.node('h1').data.text).toBe('Hello world') expect(field.hasAttribute('contenteditable')).toBe(false) await h.press('Undo (Ctrl+Z)') expect(h.cms.graph.node('h1').data.text).toBe('Edit this headline') expect(byId('h1').textContent).toBe('Edit this headline') }) it('delete offers an Undo toast that restores the block', async () => { h = await setup() await h.editor.ctrl.remove('t1') await h.settle() const toast = h.editor.ctrl.store.get().toast! expect(toast.text).toBe('Block deleted') toast.action!.run() await h.settle() expect(byId('t1')).not.toBeNull() }) it('dropBlock re-parents with one move op and the page follows', async () => { h = await setup() await h.editor.ctrl.dropBlock('t1', { parentId: 's2', index: 0, line: { x: 0, y: 0, w: 0, h: 0 } }) await h.settle() expect(order('s2')).toEqual(['t1', 't2']) expect(order('s1')).toEqual(['h1', 'b1']) // dropping where it already is does nothing const before = h.editor.ctrl.store.get().log.length await h.editor.ctrl.dropBlock('t1', { parentId: 's2', index: 0, line: { x: 0, y: 0, w: 0, h: 0 } }) expect(h.editor.ctrl.store.get().log.filter((l) => l.url === '/api/edit')).toHaveLength(1) expect(h.editor.ctrl.store.get().log.length).toBeGreaterThanOrEqual(before) }) it('the base page is swapped for the latest preview on load, via /api/preview', async () => { h = await setup({ stale: true }) await h.settle() expect(byId('h1').textContent).toBe('Latest from the store') expect(h.cms.requests.some((r) => r.startsWith('GET /api/preview?path=%2F'))).toBe(true) }) it('falls back to the page URL with the preview header when /api/preview is missing', async () => { h = await setup({ stale: true, noPreviewApi: true }) await h.settle() expect(byId('h1').textContent).toBe('Latest from the store') expect(h.cms.requests.filter((r) => r.startsWith('GET /api/preview')).length).toBeGreaterThan(0) }) it('does not touch the page while signed out (visitors keep the published file)', async () => { h = await setup({ stale: true, auth: true }) await h.settle() expect(byId('h1').textContent).toBe('Edit this headline') expect(h.cms.requests.some((r) => r.includes('/api/preview'))).toBe(false) }) it('refreshes when the tab becomes visible again (other editors\' changes)', async () => { h = await setup() await h.settle() h.cms.graph.edit([{ op: 'set', id: 'h1', data: { text: 'Edited elsewhere' } }]) ;(h.editor.ctrl as any).lastPreview = 0 // skip the 5s throttle document.dispatchEvent(new Event('visibilitychange')) await h.settle() expect(byId('h1').textContent).toBe('Edited elsewhere') }) it('exposes window.__cmsEditor and is idempotent', async () => { h = await setup() expect((window as any).__cmsEditor).toBe(h.editor) const { boot } = await import('../src/boot') expect(boot()).toBe(h.editor) }) })