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
\nBox
'
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 = '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('')).toBe('a
')
})
})