Files
cms-onpageeditor/test/editor.test.tsx
T
matsandClaude Sonnet 5.5 d3fb060016 Fix self-inflicted edit conflicts: dialogs no longer pin a stale node version
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>
2026-11-01 13:57:00 +01:00

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)
})
})