import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { ref } from 'vue' import type { FormField } from '../../src/api/types' import MarkdownField from '../../src/components/form/fields/MarkdownField.vue' import MLMarkdownField from '../../src/components/form/fields/MLMarkdownField.vue' import { FORM_ENABLED_LOCALES } from '../../src/components/form/formContext' import { API, mockApi, requestsTo, resetState, type Reply } from '../helpers' const PREVIEW = `${API}/markdown/preview` function field(type = 'markdown', name = 'body'): FormField { return { name, label: 'Body', type } as FormField } function html(value: string): Reply { return { body: { data: { html: value }, meta: {} } } } function mountField(modelValue: string) { return mount(MarkdownField, { props: { field: field(), modelValue, controlId: 'f-body' }, attachTo: document.body, }) } async function bodyOf(request: Request): Promise { return request.clone().json() } beforeEach(() => { resetState() }) afterEach(() => { vi.useRealTimers() document.body.innerHTML = '' delete (window as unknown as { __md_xss?: number }).__md_xss }) enableAutoUnmount(afterEach) describe('MarkdownField preview', () => { it('posts the source on Preview and renders the server HTML', async () => { const calls = mockApi({ [`POST ${PREVIEW}`]: html('

Hello

\n') }) const wrapper = mountField('# Hello') expect(wrapper.find('textarea').element.value).toBe('# Hello') await wrapper.find('[data-markdown-preview]').trigger('click') await flushPromises() const sent = requestsTo(calls, 'POST', PREVIEW) expect(sent).toHaveLength(1) expect(await bodyOf(sent[0]!)).toEqual({ markdown: '# Hello' }) expect(sent[0]!.headers.get('X-Requested-With')).toBe('XMLHttpRequest') const pane = wrapper.find('[data-markdown-preview-pane]') expect(pane.find('h1').text()).toBe('Hello') expect(pane.attributes('aria-busy')).toBe('false') expect(wrapper.find('textarea').exists()).toBe(false) }) it('binds the server answer, not the source', async () => { mockApi({ [`POST ${PREVIEW}`]: html('

\n') }) const wrapper = mountField('') await wrapper.find('[data-markdown-preview]').trigger('click') await flushPromises() expect(wrapper.find('img').exists()).toBe(false) expect(wrapper.find('script').exists()).toBe(false) expect((window as unknown as { __md_xss?: number }).__md_xss).toBeUndefined() expect(wrapper.find('[data-markdown-preview-pane] p').exists()).toBe(true) }) it('shows a refused output as a text notice', async () => { const message = 'The rendered HTML was refused here.' mockApi({ [`POST ${PREVIEW}`]: { status: 422, body: { error: { code: 'validation_failed', message: 'Validation failed', details: { markdown: [message] } } }, }, }) const wrapper = mountField('see data: here') await wrapper.find('[data-markdown-preview]').trigger('click') await flushPromises() const notice = wrapper.find('[data-markdown-preview-error]') expect(notice.exists()).toBe(true) expect(notice.text()).toBe(message) expect(notice.find('b').exists()).toBe(false) const pane = wrapper.find('[data-markdown-preview-pane]') expect(pane.element.children).toHaveLength(0) expect(pane.text()).toBe('') expect(wrapper.find('b').exists()).toBe(false) }) it('shows a generic notice when the request fails', async () => { vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('network down')) const wrapper = mountField('# Hello') await wrapper.find('[data-markdown-preview]').trigger('click') await flushPromises() expect(wrapper.find('[data-markdown-preview-error]').text()).toBe('Preview unavailable.') expect(wrapper.find('[data-markdown-preview-pane]').element.children).toHaveLength(0) }) it('sends nothing for a blank source', async () => { const calls = mockApi({ [`POST ${PREVIEW}`]: html('

x

') }) const wrapper = mountField(' \n ') await wrapper.find('[data-markdown-preview]').trigger('click') await flushPromises() expect(requestsTo(calls, 'POST', PREVIEW)).toHaveLength(0) expect(wrapper.find('[data-markdown-preview-pane]').text()).toBe('') expect(wrapper.find('[data-markdown-preview-error]').exists()).toBe(false) }) it('does not fetch while editing with Preview closed', async () => { const calls = mockApi({ [`POST ${PREVIEW}`]: html('

x

') }) const wrapper = mountField('# Hello') await wrapper.find('textarea').setValue('# Changed') expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['# Changed']) await wrapper.setProps({ modelValue: '# Changed' }) await flushPromises() expect(requestsTo(calls, 'POST', PREVIEW)).toHaveLength(0) }) it('re-fetches once, debounced, when the source changes while open', async () => { vi.useFakeTimers() const calls = mockApi({ [`POST ${PREVIEW}`]: async (request) => { const { markdown } = (await request.json()) as { markdown: string } return html(markdown === '# Hello' ? '

Hello

' : '

Changed

') }, }) const wrapper = mountField('# Hello') await wrapper.find('[data-markdown-preview]').trigger('click') await flushPromises() expect(requestsTo(calls, 'POST', PREVIEW)).toHaveLength(1) await wrapper.setProps({ modelValue: '## Chan' }) await vi.advanceTimersByTimeAsync(100) await wrapper.setProps({ modelValue: '## Changed' }) await vi.advanceTimersByTimeAsync(299) expect(requestsTo(calls, 'POST', PREVIEW)).toHaveLength(1) await vi.advanceTimersByTimeAsync(1) await flushPromises() const sent = requestsTo(calls, 'POST', PREVIEW) expect(sent).toHaveLength(2) expect(await bodyOf(sent[1]!)).toEqual({ markdown: '## Changed' }) expect(wrapper.find('[data-markdown-preview-pane] h2').text()).toBe('Changed') }) it('ignores an older answer that resolves after a newer one', async () => { vi.useFakeTimers() const pending: Array<(reply: Reply) => void> = [] const calls = mockApi({ [`POST ${PREVIEW}`]: () => new Promise((resolve) => pending.push(resolve)), }) const wrapper = mountField('# Old') await wrapper.find('[data-markdown-preview]').trigger('click') await flushPromises() await wrapper.setProps({ modelValue: '# New' }) await vi.advanceTimersByTimeAsync(300) await flushPromises() expect(requestsTo(calls, 'POST', PREVIEW)).toHaveLength(2) expect(pending).toHaveLength(2) pending[1]!(html('

New

')) await flushPromises() pending[0]!(html('

Old

')) await flushPromises() expect(wrapper.find('[data-markdown-preview-pane] h1').text()).toBe('New') }) it('returns to the unchanged source when Preview is clicked again', async () => { mockApi({ [`POST ${PREVIEW}`]: html('

Hello

') }) const wrapper = mountField('# Hello') await wrapper.find('[data-markdown-preview]').trigger('click') await flushPromises() await wrapper.find('[data-markdown-preview]').trigger('click') expect(wrapper.find('[data-markdown-preview-pane]').exists()).toBe(false) expect(wrapper.find('textarea').element.value).toBe('# Hello') expect(wrapper.find('[data-markdown-preview]').attributes('aria-pressed')).toBe('false') }) }) describe('MarkdownField inside mlmarkdown', () => { it('previews the active locale and follows a locale switch', async () => { vi.useFakeTimers() const calls = mockApi({ [`POST ${PREVIEW}`]: async (request) => { const { markdown } = (await request.json()) as { markdown: string } return html(markdown === '# Hi' ? '

Hi

' : '

Hallo

') }, }) const wrapper = mount(MLMarkdownField, { props: { field: field('mlmarkdown', 'body'), modelValue: { en: '# Hi', pl: '# Hallo' }, controlId: 'f-body', }, attachTo: document.body, global: { provide: { [FORM_ENABLED_LOCALES as symbol]: ref(['en', 'pl']), }, }, }) expect(wrapper.findComponent(MarkdownField).exists()).toBe(true) await wrapper.find('[data-markdown-preview]').trigger('click') await flushPromises() expect(wrapper.find('[data-markdown-preview-pane] h1').text()).toBe('Hi') await wrapper.find('[data-ml-locale]').setValue('pl') await vi.advanceTimersByTimeAsync(300) await flushPromises() const sent = requestsTo(calls, 'POST', PREVIEW) expect(sent).toHaveLength(2) expect(await bodyOf(sent[1]!)).toEqual({ markdown: '# Hallo' }) expect(wrapper.find('[data-markdown-preview-pane] h1').text()).toBe('Hallo') }) })