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 { displayHtml, isAllowedLink } from '../../src/components/form/richeditor/html' import { isLossy } from '../../src/components/form/richeditor/lossy' import { companionsFormSchemaFixture } 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', }, }) const box = wrapper.find('[data-richeditor-readonly]') expect(box.find('script').exists()).toBe(false) expect(box.find('p').attributes('onclick')).toBeUndefined() expect(box.findAll('a').map((link) => link.attributes('href'))).toEqual([undefined, '/ok']) 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) }) it('unwraps unknown elements and drops scripts for display', () => { expect(displayHtml('

a

')).toBe('

a

') }) })