import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { act } from 'react' import { setup } from './harness' import type { Harness } from './harness' let h: Harness beforeEach(() => localStorage.clear()) afterEach(() => { h?.cleanup() delete (navigator as any).gpu }) const q = (sel: string) => h.shadow.querySelector(sel)! const qa = (sel: string) => [...h.shadow.querySelectorAll(sel)] describe('rail sections', () => { it('outline, blocks and nodes collapse via their headers', async () => { h = await setup() await h.settle() await h.press('Panel') await h.settle() await h.settle() expect(qa('.cms-outline').length).toBeGreaterThan(0) // collapse outline: the tree leaves the DOM, palette stays // (NB: the section itself shares the aria-label, so target the toggle button) await act(async () => void q('button[aria-label="Outline"]').click()) await h.settle() expect(qa('.cms-outline').length).toBe(0) expect(q('[aria-label="Search blocks"]')).not.toBeNull() // collapse blocks too await act(async () => void q('button[aria-label="Blocks"]').click()) await h.settle() expect(h.shadow.querySelector('[aria-label="Search blocks"]')).toBeNull() // and back open await act(async () => void q('button[aria-label="Blocks"]').click()) await h.settle() expect(q('[aria-label="Search blocks"]')).not.toBeNull() await act(async () => void q('button[aria-label="Outline"]').click()) await h.settle() expect(qa('.cms-outline').length).toBeGreaterThan(0) }) it('rail open state and collapsed sections survive a reload', async () => { h = await setup() await h.settle() await h.press('Panel') await h.settle() await act(async () => void q('button[aria-label="Blocks"]').click()) // collapse blocks await h.settle() expect(localStorage.getItem('cms-onpage.rail')).toBe('true') expect(localStorage.getItem('cms-onpage.sec.blocks')).toBe('false') // reload with the same profile: rail reopens, blocks stays collapsed h.cleanup() h = await setup({ keepStorage: true }) await h.settle() await h.settle() expect(q('.cms-rail')).not.toBeNull() expect(h.shadow.querySelector('[aria-label="Search blocks"]')).toBeNull() expect(qa('.cms-outline').length).toBeGreaterThan(0) }) it('dragging the rail edge resizes it and remembers the width', async () => { h = await setup() await h.settle() await h.press('Panel') await h.settle() const grip = q('.cms-rail__resize') const down = new Event('pointerdown', { bubbles: true, cancelable: true }) as Event & { clientX: number; button: number } down.clientX = 332 down.button = 0 await act(async () => void grip.dispatchEvent(down)) const move = new Event('pointermove', { bubbles: true, cancelable: true }) as Event & { clientX: number } move.clientX = 420 await act(async () => void document.dispatchEvent(move)) const up = new Event('pointerup', { bubbles: true, cancelable: true }) await act(async () => void document.dispatchEvent(up)) await h.settle() // jsdom reports rect.left as 0, so width == clientX, clamped to [240, 640] expect((q('.cms-rail') as HTMLElement).style.width).toBe('420px') expect(localStorage.getItem('cms-onpage.railwidth')).toBe('420') h.cleanup() h = await setup({ keepStorage: true }) await h.settle() await h.settle() expect((q('.cms-rail') as HTMLElement).style.width).toBe('420px') }) it('Escape closes the rail when nothing else is open', async () => { h = await setup() await h.settle() await h.press('Panel') await h.settle() expect(q('.cms-rail')).not.toBeNull() await act(async () => void document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true, composed: true }))) await h.settle() expect(h.shadow.querySelector('.cms-rail')).toBeNull() expect(localStorage.getItem('cms-onpage.rail')).toBe('false') }) it('dev panel remembers its tab', async () => { h = await setup() await h.settle() await act(async () => void h.editor.ctrl.store.set({ dev: true })) await h.settle() const registry = qa('.cms-dev header button').find((b) => b.textContent === 'registry')! await act(async () => void registry.click()) await h.settle() expect(localStorage.getItem('cms-onpage.devtab')).toBe('registry') h.cleanup() h = await setup({ keepStorage: true }) await h.settle() await act(async () => void h.editor.ctrl.store.set({ dev: true })) await h.settle() expect(q('.cms-dev__body').textContent).toContain('blockEditors') }) it('split mode shows the outline beside blocks/nodes tabs for drag & drop', async () => { h = await setup() await h.settle() await h.press('Panel') await h.settle() // stacked by default: single column, no tabs expect(h.shadow.querySelector('.cms-rail.is-split')).toBeNull() await act(async () => void q('button[aria-label="Split panels side by side"]').click()) await h.settle() expect(q('.cms-rail.is-split')).not.toBeNull() // outline and blocks visible at the same time: a drag source can reach a gap expect(qa('.cms-outline').length).toBeGreaterThan(0) expect(q('[aria-label="Search blocks"]')).not.toBeNull() // switch the library tab to nodes: blocks hide, nodes show, outline stays await act(async () => void q('button[aria-label="Show nodes panel"]').click()) await h.settle() expect(h.shadow.querySelector('[aria-label="Search blocks"]')).toBeNull() expect(q('[aria-label="Search nodes"]')).not.toBeNull() expect(qa('.cms-outline').length).toBeGreaterThan(0) // starting a palette drag reveals outline gaps while both columns stay visible h.editor.ctrl.beginPaletteDrag({ type: 'text', label: 'text' }) await h.settle() expect(qa('[data-ogap]').length).toBeGreaterThan(0) expect(q('[aria-label="Search nodes"]')).not.toBeNull() await act(async () => void h.editor.ctrl.cancelDrag()) await h.settle() // stacking collapses back to one column and remembers the choice await act(async () => void q('button[aria-label="Stack panels"]').click()) await h.settle() expect(h.shadow.querySelector('.cms-rail.is-split')).toBeNull() expect(localStorage.getItem('cms-onpage.railsplit')).toBe('false') }) it('assistant sits beside the dev panel instead of stacking under it', async () => { ;(navigator as any).gpu = {} // aiSupported() checks for a WebGPU API h = await setup() await h.settle() await act(async () => void h.editor.ctrl.store.set({ assistant: true, dev: true })) await h.settle() expect(q('.cms-dev')).not.toBeNull() expect(q('.cms-assistant.cms-assistant--shifted')).not.toBeNull() await act(async () => void h.editor.ctrl.store.set({ dev: false })) await h.settle() expect(h.shadow.querySelector('.cms-assistant--shifted')).toBeNull() }) })