// @vitest-environment jsdom // // The editor page. We mock `@voltro/client` (content.types fetch, the live list, // the save mutation) and drive it: after the types load the page renders a type // chip + an auto-generated (real, from @voltro/cms/web), and a // save that comes back `{ ok: false, violations }` annotates the form inline // rather than throwing. import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { act, createElement, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { I18nProvider } from '@voltro/i18n' import enCatalog from '../../locales/en' const blogType = { name: 'blogPost', displayName: 'Blog post', pluralName: 'Blog posts', columns: ['title', 'slug', 'status'], fields: [ { name: 'title', kind: 'string', editorHint: 'text', isOptional: false }, { name: 'body', kind: 'richText', editorHint: 'richText', isOptional: false }, ], } const typesRun = vi.fn<() => Promise<{ types: unknown }>>() const getRun = vi.fn<() => Promise<{ row: unknown }>>() const saveMutate = vi.fn<(input: unknown) => Promise>() const subData = vi.fn<() => { data: unknown }>() vi.mock('@voltro/client', () => ({ useAction: (_api: string, tag: string) => ({ run: tag === 'content.types' ? typesRun : getRun, pending: false, }), useMutation: (_api: string, tag: string) => ({ mutate: tag === 'content.saveDraft' ? saveMutate : vi.fn().mockResolvedValue({ ok: true }), pending: false, }), useSubscription: () => subData(), })) const { default: Editor, meta, renderMode } = await import('./page') ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true let container: HTMLDivElement let root: Root const render = (node: ReactNode): void => { container = document.createElement('div') document.body.appendChild(container) act(() => { root = createRoot(container) root.render( createElement(I18nProvider, { locale: 'en', messages: enCatalog, defaultLocale: 'en', children: node }), ) }) } // Flush the mount-effect fetch (typesRun.then → setState) + its re-render. const flush = async (): Promise => { await act(async () => { await Promise.resolve(); await Promise.resolve() }) } beforeEach(() => { typesRun.mockReset(); typesRun.mockResolvedValue({ types: [blogType] }) getRun.mockReset(); getRun.mockResolvedValue({ row: null }) saveMutate.mockReset(); saveMutate.mockResolvedValue({ ok: true, id: 'blogPost_1', status: 'draft' }) subData.mockReset(); subData.mockReturnValue({ data: [] }) }) afterEach(() => { if (root) act(() => root.unmount()) container?.remove() document.body.innerHTML = '' }) describe('editor — config', () => { test('is an SSR page titled Content (so the parent gate runs per request)', () => { expect(renderMode).toBe('ssr') expect(meta({ locale: 'en' }).title).toBe('Content') }) }) describe('editor — render', () => { test('after the types load, renders the type chip and an auto-generated form', async () => { render(createElement(Editor)) await flush() expect(typesRun).toHaveBeenCalled() expect(container.textContent).toContain('Blog post') // renders one [data-cms-field] per declared field. expect(container.querySelectorAll('[data-cms-field]').length).toBe(2) expect(container.querySelector('[data-cms-field="title"]')).not.toBeNull() }) }) describe('editor — validation', () => { test('a save that returns { ok: false, violations } annotates the form', async () => { saveMutate.mockResolvedValue({ ok: false, violations: [{ field: 'title', rule: 'required', message: 'missing required field' }] }) render(createElement(Editor)) await flush() const form = container.querySelector('form')! await act(async () => { form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) await Promise.resolve(); await Promise.resolve() }) expect(saveMutate).toHaveBeenCalled() expect(container.querySelector('.violations')).not.toBeNull() expect(container.textContent).toContain('missing required field') }) })