import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' import type { FormField } from '../../src/api/types' import RichEditorField from '../../src/components/form/fields/RichEditorField.vue' import { isAllowedLink } from '../../src/components/form/richeditor/html' import { isLossy } from '../../src/components/form/richeditor/lossy' import { companionsFormSchemaFixture, langFixture } from '../fixtures/typed' import { API, mountApp, requestsTo, resetState, type Route } from '../helpers' // Phase 17 plan 02 smoke specs for the richeditor field (D-10). Plan 06 // completes the coverage; these prove the tracer slice end to end. const BASE = `${API}/acme/companions/links` const RECORD = `${BASE}/1` // b, div and attributes: markup TipTap would re-serialize if it were asked. const STORED = '

Hello bold world

\n
Box
' function record(description: string | null) { return { data: { id: 1, title: 'Docs', description }, meta: {} } } function routes(overrides: Record = {}): Record { return { [`GET ${BASE}/schema/form`]: { body: companionsFormSchemaFixture }, [`GET ${RECORD}`]: { body: record(STORED) }, ...overrides, } } async function editorReady(wrapper: VueWrapper): Promise { await vi.waitFor( () => { expect(wrapper.find('[data-richeditor-area]').exists()).toBe(true) }, { timeout: 5000 }, ) await flushPromises() } beforeEach(() => { resetState() }) afterEach(() => { document.body.innerHTML = '' }) enableAutoUnmount(afterEach) describe('RichEditorField', () => { it('RichEditorField tracer: untouched value is submitted byte-identical', async () => { let body: Record | null = null const { wrapper, calls } = await mountApp( '/acme/companions/links/1', routes({ [`PUT ${RECORD}`]: async (request) => { body = (await request.json()) as Record return { body: record(body.description as string) } }, }), { attach: true }, ) await editorReady(wrapper) // The editor shows the stored text; b and div are inside the editor's set. expect(wrapper.find('[data-richeditor-area]').text()).toContain('Hello bold world') expect(wrapper.find('[data-richeditor-lossy]').exists()).toBe(false) expect(wrapper.find('[data-richeditor-area]').attributes('aria-labelledby')).toBe('field-description-label') expect(wrapper.find('#field-description-label').text()).toBe('Description') await wrapper.find('[data-action="save"]').trigger('click') await flushPromises() expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1) expect(body).not.toBeNull() expect(body!.description).toBe(STORED) expect(body!.description === STORED).toBe(true) }) it('shows the lossy note for markup outside the editor schema and keeps the HTML view lossless', async () => { const lossy = '
Cell

Text

' const { wrapper } = await mountApp('/acme/companions/links/1', routes({ [`GET ${RECORD}`]: { body: record(lossy) } }), { attach: true, }) await editorReady(wrapper) expect(wrapper.find('[data-richeditor-lossy]').attributes('role')).toBe('note') await wrapper.find('[data-tool="source"]').trigger('click') const textarea = wrapper.find('[data-richeditor-source]') expect(textarea.element.value).toBe(lossy) expect(textarea.attributes('id')).toBe('field-description') }) it('sends the editor HTML after an edit and shows what the server removed', async () => { let body: Record | null = null const sanitized = '

Hello bold world

' const { wrapper } = await mountApp( '/acme/companions/links/1', routes({ [`GET ${RECORD}`]: { body: record('

Hello bold world

') }, [`PUT ${RECORD}`]: async (request) => { body = (await request.json()) as Record return { body: { data: { id: 1, title: 'Docs', description: sanitized }, meta: { notices: [{ field: 'description', message: '1 element was removed: b' }] }, }, } }, }), { attach: true }, ) await editorReady(wrapper) await wrapper.find('[data-tool="heading"]').trigger('click') await flushPromises() expect(wrapper.find('[data-tool="heading"]').attributes('aria-pressed')).toBe('true') await wrapper.find('[data-action="save"]').trigger('click') await flushPromises() expect(body!.description).toBe('

Hello bold world

') expect(wrapper.find('[data-field="description"] [data-field-notice]').text()).toBe('1 element was removed: b') expect(wrapper.find('[data-richeditor-area]').html()).toContain('

Hello bold world

') }) it('RichEditorField edit emits editor HTML', async () => { const field = { name: 'body', label: 'Body', type: 'richeditor' } as FormField const wrapper = mount(RichEditorField, { props: { field, modelValue: '

Hello

', controlId: 'f-body' }, attachTo: document.body, }) expect(wrapper.find('[data-richeditor-loading]').attributes('aria-busy')).toBe('true') await editorReady(wrapper) expect(wrapper.emitted('update:modelValue')).toBeUndefined() await wrapper.find('[data-tool="bold"]').trigger('click') await wrapper.find('[data-tool="heading"]').trigger('click') await flushPromises() const emitted = wrapper.emitted('update:modelValue') ?? [] expect(emitted.length).toBeGreaterThan(0) const last = emitted[emitted.length - 1]![0] expect(last).toBe('

Hello

') expect(wrapper.find('[data-richeditor-area]').html()).toContain('

Hello

') // The HTML view emits the text as typed. await wrapper.find('[data-tool="source"]').trigger('click') await wrapper.find('[data-richeditor-source]').setValue('

Typed here

') const afterSource = wrapper.emitted('update:modelValue') ?? [] expect(afterSource[afterSource.length - 1]![0]).toBe('

Typed here

') }) it('renders read-only values through the display policy, never as stored', () => { const field = { name: 'body', label: 'Body', type: 'richeditor', readOnly: true } as FormField const wrapper = mount(RichEditorField, { props: { field, modelValue: '

Hi bad ok

', controlId: 'f-body', }, }) // happy-dom cannot run DOMPurify faithfully, so displayHtml binds nothing // here (it fails closed). Exact display assertions live in // tests/browser/richeditor-display.test.ts; these hold in any engine. const box = wrapper.find('[data-richeditor-readonly]') expect(box.find('script').exists()).toBe(false) expect(box.find('[onclick]').exists()).toBe(false) expect(box.html()).not.toContain('javascript:') for (const link of box.findAll('a')) { expect(isAllowedLink(link.attributes('href'))).toBe(true) } expect((window as unknown as { __re?: number }).__re).toBeUndefined() }) }) describe('richeditor policy helpers', () => { it('accepts policy links only', () => { for (const url of ['https://example.test/a', 'http://x.test', 'mailto:a@example.test', 'tel:+48123', '/path', '#top', '?q=1', './rel', 'docs/page']) { expect(isAllowedLink(url), url).toBe(true) } for (const url of ['javascript:alert(1)', 'java\tscript:alert(1)', 'data:text/html,x', 'ftp://x.test', '', ' ']) { expect(isAllowedLink(url), url).toBe(false) } }) it('compares tag names case-insensitively for the lossy note', () => { expect(isLossy('

a

b
')).toBe(false) expect(isLossy('

a

b

')).toBe(true) expect(isLossy('')).toBe(false) }) }) // Plan 06 coverage: the value contract, the link popover, the notices // callout and the chunk-failure fallback (UI-SPEC RichEditorField). describe('RichEditorField value contract', () => { it('an empty paragraph is the empty string, any content its HTML', async () => { const { createRichEditor, editorValue, loadContent } = await import('../../src/components/form/richeditor/editor') const element = document.createElement('div') document.body.appendChild(element) let changes = 0 const editor = createRichEditor({ element, content: '

', attributes: {}, onChange: () => changes++, onTransaction: () => undefined }) try { expect(editorValue(editor)).toBe('') loadContent(editor, '

Hello

') expect(changes).toBe(0) expect(editorValue(editor)).toBe('

Hello

') editor.commands.clearContent(true) expect(changes).toBe(1) expect(editorValue(editor)).toBe('') editor.commands.setContent('

Head

  • one

', { emitUpdate: true }) expect(editorValue(editor)).toBe('

Head

  • one

') // Headings other than level 3 and the dropped nodes do not survive the editor. loadContent(editor, '

Big

x

') expect(editorValue(editor)).not.toContain(' { const field = { name: 'body', label: 'Body', type: 'richeditor' } as FormField const wrapper = mount(RichEditorField, { props: { field, modelValue: '

One

', controlId: 'f-body' }, attachTo: document.body }) await editorReady(wrapper) await wrapper.setProps({ modelValue: '

Two b

' }) await flushPromises() expect(wrapper.find('[data-richeditor-area]').text()).toBe('Two b') expect(wrapper.emitted('update:modelValue')).toBeUndefined() // An empty value shows the bare editing area and emits nothing. await wrapper.setProps({ modelValue: null }) await flushPromises() expect(wrapper.find('[data-richeditor-area]').text()).toBe('') expect(wrapper.emitted('update:modelValue')).toBeUndefined() }) it('shows the lossy note only for markup outside the editor schema', async () => { const field = { name: 'body', label: 'Body', type: 'richeditor' } as FormField const clean = mount(RichEditorField, { props: { field, modelValue: '

a b

  • c
', controlId: 'f-a' }, attachTo: document.body }) await editorReady(clean) expect(clean.find('[data-richeditor-lossy]').exists()).toBe(false) const lossy = mount(RichEditorField, { props: { field, modelValue: '

a

', controlId: 'f-b' }, attachTo: document.body }) await editorReady(lossy) expect(lossy.find('[data-richeditor-lossy]').text()).toContain('Części formatowania tego tekstu nie da się pokazać w edytorze.') }) it('reads empty and read-only values', () => { const field = { name: 'body', label: 'Body', type: 'richeditor', readOnly: true } as FormField const empty = mount(RichEditorField, { props: { field, modelValue: '', controlId: 'f-body' } }) expect(empty.find('[data-richeditor-readonly]').text()).toBe('—') expect(empty.find('[data-richeditor-loading]').exists()).toBe(false) }) }) describe('richeditor link popover', () => { async function popover(props: { href?: string; inLink?: boolean } = {}) { const LinkPopover = (await import('../../src/components/form/richeditor/LinkPopover.vue')).default const wrapper = mount(LinkPopover, { props: { open: false, href: props.href ?? '', inLink: props.inLink ?? false, idPrefix: 'f-body' }, slots: { default: '' }, attachTo: document.body, }) await wrapper.setProps({ open: true }) await flushPromises() const input = () => document.body.querySelector('#f-body-link-url')! return { wrapper, input } } it('refuses a URL outside the allowlist and applies a policy URL on Enter', async () => { const { wrapper, input } = await popover() expect(document.activeElement).toBe(input()) for (const bad of ['javascript:alert(1)', 'data:text/html,x', 'ftp://example.test/x']) { input().value = bad input().dispatchEvent(new Event('input')) ;(document.body.querySelector('[data-action="richeditor-link-apply"]') as HTMLElement).click() await flushPromises() expect(wrapper.emitted('apply')).toBeUndefined() expect(document.body.querySelector('#f-body-link-error')?.textContent).toContain('Link musi używać http, https, mailto, tel albo adresu względnego.') expect(input().getAttribute('aria-invalid')).toBe('true') } // Typing clears the error; Enter applies the trimmed URL and never submits. input().value = ' https://example.test/a ' input().dispatchEvent(new Event('input')) await flushPromises() expect(document.body.querySelector('#f-body-link-error')).toBeNull() const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) input().dispatchEvent(enter) await flushPromises() expect(enter.defaultPrevented).toBe(true) expect(wrapper.emitted('apply')).toEqual([['https://example.test/a']]) expect(document.body.querySelector('[data-action="richeditor-unlink"]')).toBeNull() }) it('starts from the current link and offers Remove link inside one', async () => { const { wrapper, input } = await popover({ href: 'mailto:a@example.test', inLink: true }) expect(input().value).toBe('mailto:a@example.test') ;(document.body.querySelector('[data-action="richeditor-unlink"]') as HTMLElement).click() await flushPromises() expect(wrapper.emitted('remove')).toHaveLength(1) }) }) describe('richeditor notices callout', () => { it('lists every notice of the field in one warning callout after a save', async () => { const { wrapper } = await mountApp( '/acme/companions/links/1', routes({ [`PUT ${RECORD}`]: { body: { data: { id: 1, title: 'Docs', description: '

x

' }, meta: { notices: [ { field: 'description', message: 'Usunięto 2 elementy: script, iframe' }, { field: 'description', message: 'Usunięto 1 atrybut: onclick' }, { field: 'title', message: 'other field' }, ], }, }, }, }), { attach: true }, ) await editorReady(wrapper) await wrapper.find('[data-action="save"]').trigger('click') await flushPromises() const callout = wrapper.find('[data-field="description"] [data-field-notice]') expect(callout.attributes('role')).toBe('status') expect(callout.classes()).toContain('summer-callout--warning') expect(callout.findAll('p').map((line) => line.text())).toEqual(['Usunięto 2 elementy: script, iframe', 'Usunięto 1 atrybut: onclick']) }) }) // Last in the file: it replaces the editor chunk for the rest of the run. describe('RichEditorField chunk failure', () => { afterEach(() => { vi.doUnmock('../../src/components/form/richeditor/editor') }) it('falls back to the HTML view with the load_failed note, and saving still works', async () => { vi.resetModules() vi.doMock('../../src/components/form/richeditor/editor', () => { throw new Error('chunk failed') }) // The fresh module graph has its own i18n state: load the strings there. ;(await import('../../src/app/i18n')).setBundle(langFixture.data, 'pl') const Field = (await import('../../src/components/form/fields/RichEditorField.vue')).default const field = { name: 'body', label: 'Body', type: 'richeditor' } as FormField const wrapper = mount(Field, { props: { field, modelValue: '

Stored x

', controlId: 'f-body' }, attachTo: document.body }) await vi.waitFor(() => { expect(wrapper.find('[data-richeditor-load-failed]').exists()).toBe(true) }) expect(wrapper.find('[data-richeditor-load-failed]').text()).toBe('Nie udało się wczytać edytora. Możesz edytować HTML bezpośrednio.') const textarea = wrapper.find('[data-richeditor-source]') expect(textarea.element.value).toBe('

Stored x

') expect(textarea.attributes('id')).toBe('f-body') expect(wrapper.find('[role="toolbar"]').exists()).toBe(false) expect(wrapper.emitted('update:modelValue')).toBeUndefined() await textarea.setValue('

Edited

') expect(wrapper.emitted('update:modelValue')).toEqual([['

Edited

']]) }) })