- signpost: activation refusals, host overrides, D-02 pattern layers incl. PatternFrom, ReferenceOptions depths - seaglass: removal report, bluemonday bypass corpus, Check - pact Option.Depth omitempty and the menu item type contracts; party activates menu item types after Register and before the first Boot - cabana: repeater compile and decode, dependsOn compile and recheck, richeditor keys and notices, reorder and singleton boot checks (Docker-free); repeater save, dependent recheck, richeditor save, reorder and singleton routes on Postgres; conformance cases for the documented 404, 409 and 422 answers of the new routes - admin: richeditor value contract, link popover, notices and chunk failure; repeater bounds, remove and reorder, collapsed rows; dependent dropdown states and last request wins; reorder announcements, partition switch and tree rules; SingletonForm spec - gate: required names in the go, admin and postgres stages
361 lines
16 KiB
TypeScript
361 lines
16 KiB
TypeScript
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, 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 = '<P class="lead">Hello <b>bold</b> world</P>\n<div data-x="1" style="color: red">Box</div><!-- kept -->'
|
|
|
|
function record(description: string | null) {
|
|
return { data: { id: 1, title: 'Docs', description }, meta: {} }
|
|
}
|
|
|
|
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
|
return {
|
|
[`GET ${BASE}/schema/form`]: { body: companionsFormSchemaFixture },
|
|
[`GET ${RECORD}`]: { body: record(STORED) },
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
async function editorReady(wrapper: VueWrapper): Promise<void> {
|
|
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<string, unknown> | null = null
|
|
const { wrapper, calls } = await mountApp(
|
|
'/acme/companions/links/1',
|
|
routes({
|
|
[`PUT ${RECORD}`]: async (request) => {
|
|
body = (await request.json()) as Record<string, unknown>
|
|
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 = '<table><tr><td>Cell</td></tr></table><p>Text</p>'
|
|
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<HTMLTextAreaElement>('[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<string, unknown> | null = null
|
|
const sanitized = '<h3>Hello bold world</h3>'
|
|
const { wrapper } = await mountApp(
|
|
'/acme/companions/links/1',
|
|
routes({
|
|
[`GET ${RECORD}`]: { body: record('<p>Hello <b>bold</b> world</p>') },
|
|
[`PUT ${RECORD}`]: async (request) => {
|
|
body = (await request.json()) as Record<string, unknown>
|
|
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('<h3>Hello <strong>bold</strong> world</h3>')
|
|
expect(wrapper.find('[data-field="description"] [data-field-notice]').text()).toBe('1 element was removed: b')
|
|
expect(wrapper.find('[data-richeditor-area]').html()).toContain('<h3>Hello bold world</h3>')
|
|
})
|
|
|
|
it('RichEditorField edit emits editor HTML', async () => {
|
|
const field = { name: 'body', label: 'Body', type: 'richeditor' } as FormField
|
|
const wrapper = mount(RichEditorField, {
|
|
props: { field, modelValue: '<p>Hello</p>', 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('<h3>Hello</h3>')
|
|
expect(wrapper.find('[data-richeditor-area]').html()).toContain('<h3>Hello</h3>')
|
|
|
|
// The HTML view emits the text as typed.
|
|
await wrapper.find('[data-tool="source"]').trigger('click')
|
|
await wrapper.find('[data-richeditor-source]').setValue('<p>Typed <em>here</em></p>')
|
|
const afterSource = wrapper.emitted('update:modelValue') ?? []
|
|
expect(afterSource[afterSource.length - 1]![0]).toBe('<p>Typed <em>here</em></p>')
|
|
})
|
|
|
|
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: '<p onclick="x()">Hi <a href="javascript:alert(1)">bad</a> <a href="/ok">ok</a></p><script>window.__re = 1</script>',
|
|
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('<P>a</P><DIV><SPAN>b</SPAN></DIV>')).toBe(false)
|
|
expect(isLossy('<p>a</p><H2>b</H2>')).toBe(true)
|
|
expect(isLossy('')).toBe(false)
|
|
})
|
|
|
|
it('unwraps unknown elements and drops scripts for display', () => {
|
|
expect(displayHtml('<section><p class="x">a</p></section><script>b</script>')).toBe('<p>a</p>')
|
|
})
|
|
})
|
|
|
|
// 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: '<p></p>', attributes: {}, onChange: () => changes++, onTransaction: () => undefined })
|
|
try {
|
|
expect(editorValue(editor)).toBe('')
|
|
loadContent(editor, '<p>Hello</p>')
|
|
expect(changes).toBe(0)
|
|
expect(editorValue(editor)).toBe('<p>Hello</p>')
|
|
editor.commands.clearContent(true)
|
|
expect(changes).toBe(1)
|
|
expect(editorValue(editor)).toBe('')
|
|
editor.commands.setContent('<h3>Head</h3><ul><li><p>one</p></li></ul>', { emitUpdate: true })
|
|
expect(editorValue(editor)).toBe('<h3>Head</h3><ul><li><p>one</p></li></ul>')
|
|
// Headings other than level 3 and the dropped nodes do not survive the editor.
|
|
loadContent(editor, '<h1>Big</h1><pre><code>x</code></pre><hr>')
|
|
expect(editorValue(editor)).not.toContain('<h1')
|
|
expect(editorValue(editor)).not.toContain('<pre')
|
|
expect(editorValue(editor)).not.toContain('<hr')
|
|
} finally {
|
|
editor.destroy()
|
|
}
|
|
})
|
|
|
|
it('a value from outside replaces the document and leaves the field untouched', async () => {
|
|
const field = { name: 'body', label: 'Body', type: 'richeditor' } as FormField
|
|
const wrapper = mount(RichEditorField, { props: { field, modelValue: '<p>One</p>', controlId: 'f-body' }, attachTo: document.body })
|
|
await editorReady(wrapper)
|
|
await wrapper.setProps({ modelValue: '<P>Two <b>b</b></P>' })
|
|
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: '<p>a <strong>b</strong></p><ul><li>c</li></ul>', 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: '<p>a</p><iframe src="x"></iframe>', 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: '<button type="button" data-anchor>Link</button>' },
|
|
attachTo: document.body,
|
|
})
|
|
await wrapper.setProps({ open: true })
|
|
await flushPromises()
|
|
const input = () => document.body.querySelector<HTMLInputElement>('#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: '<p>x</p>' },
|
|
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: '<p>Stored <b>x</b></p>', 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<HTMLTextAreaElement>('[data-richeditor-source]')
|
|
expect(textarea.element.value).toBe('<p>Stored <b>x</b></p>')
|
|
expect(textarea.attributes('id')).toBe('f-body')
|
|
expect(wrapper.find('[role="toolbar"]').exists()).toBe(false)
|
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
|
await textarea.setValue('<p>Edited</p>')
|
|
expect(wrapper.emitted('update:modelValue')).toEqual([['<p>Edited</p>']])
|
|
})
|
|
})
|