Properties, Raw JSON and AI proposals sent the version captured when the dialog opened. Any other edit to the node meanwhile (style chip, drag handle, inline text, child insert/move) bumped it, so the save 409'd against the user's own work. Save through ctrl.saveData instead: compare only the keys being written against the current node (a real conflict is a key someone else changed) and send no version. 409 toasts now show the server's reason. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
246 lines
11 KiB
TypeScript
246 lines
11 KiB
TypeScript
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<HTMLElement>('.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<HTMLElement>('.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<HTMLInputElement>('[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<HTMLElement>('.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' } })
|
|
})
|
|
|
|
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<HTMLElement>('[aria-label="Add item"]')!.click())
|
|
const save = () => h.shadow.querySelector<HTMLButtonElement>('.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<HTMLInputElement>('[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('edits to OTHER fields of the node while the dialog is open do not conflict (the dialog does not pin a version)', async () => {
|
|
h = await setup()
|
|
await selectBlock('s1')
|
|
await h.press('Properties')
|
|
// style chip / drag handle / inline edit on the same node bump its version under the open dialog
|
|
await h.editor.ctrl.setData('s1', { tone: 'accent' })
|
|
await h.editor.ctrl.setData('s1', { padded: false })
|
|
const input = h.shadow.querySelector<HTMLInputElement>('[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<HTMLElement>('.cms-btn--primary')!.click())
|
|
await h.settle()
|
|
expect(h.cms.graph.node('s1').data).toMatchObject({ title: 'Mine', tone: 'accent', padded: false })
|
|
expect(h.editor.ctrl.store.get().toast?.kind).not.toBe('error')
|
|
})
|
|
|
|
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<HTMLInputElement>('[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<HTMLElement>('.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)
|
|
})
|
|
})
|