Files
sm-journal-plugin/tests/blogeditor/schema.test.ts
Jakub Zych a578ddabda test(16.2-06): Blog Editor schema, serializer and embed coverage
- schema: every raw HTML tag rule, figure shapes, span mark, unmodelled
  attributes, handler attributes dropped, links, tables, no trailing node
- serializer: block reuse after an edit, a deletion and an insertion,
  separators and comments kept, captureBlocks fallbacks
- embeds: parseEmbedUrl table, placeholder round trip, isPolicyUrl table,
  upload id matching and timeout, labels, disabled and invalid
2026-10-10 16:03:07 +02:00

221 lines
10 KiB
TypeScript

// The Blog Editor's preservation schema (Phase 16.2 D-03, D-04): what the
// schema models, what becomes a raw HTML card, which attributes survive an
// edit, and how the stored form of links, tables and figures looks when a
// block is serialized from the model.
import { afterEach, describe, expect, it } from 'vitest'
import { RAW_HTML_TAGS } from '../../assets/src/blogeditor/extensions'
import { serializeDocument } from '../../assets/src/blogeditor/serializer'
import { insertAtBlock, mount } from './helpers'
afterEach(() => {
document.body.replaceChildren()
})
/** types lists the top-level node types of the editor's document. */
function types(html: string): string[] {
const { editor } = mount(html)
const out: string[] = []
editor.state.doc.forEach((node) => out.push(node.type.name))
return out
}
/**
* shape describes markup for comparison: element names, attributes sorted by
* name (a style without its final semicolon) and text. An edited block may
* list attributes in another order (the model renders modelled ones after
* the captured ones) and the DOM normalizes a style declaration; names and
* values must stay the same.
*/
function shape(html: string): string {
const template = document.createElement('template')
template.innerHTML = html
const walk = (node: Node): string => {
if (node.nodeType === 3) return JSON.stringify(node.nodeValue)
if (node.nodeType !== 1) return ''
const el = node as Element
const attrs = Array.from(el.attributes)
.map((a) => `${a.name}=${JSON.stringify(a.name === 'style' ? a.value.trim().replace(/;$/, '') : a.value)}`)
.sort()
return `<${el.nodeName.toLowerCase()} ${attrs.join(' ')}>${Array.from(el.childNodes).map(walk).join('')}</>`
}
return Array.from(template.content.childNodes).map(walk).join('')
}
/** model serializes the whole document from the model, without block reuse. */
function model(html: string): string {
const { editor } = mount(html)
return serializeDocument(editor, null)
}
const rawSamples: Record<(typeof RAW_HTML_TAGS)[number], string> = {
footer: '<footer class="author-footer" itemscope="" itemtype="https://schema.org/Person"><p>Autor</p></footer>',
section: '<section class="s"><p>Sekcja</p></section>',
aside: '<aside>Na marginesie</aside>',
header: '<header><h2>Nagłówek</h2></header>',
nav: '<nav><a href="/a">a</a></nav>',
details: '<details><summary>Więcej</summary>Treść</details>',
style: '<style>.x { color: red; }</style>',
div: '<div class="box" data-x="1">Pudełko</div>',
article: '<article><p>Artykuł</p></article>',
main: '<main><p>Główna</p></main>',
address: '<address>ul. Leśna 1</address>',
dl: '<dl><dt>Termin</dt><dd>Opis</dd></dl>',
iframe: '<iframe title="video"></iframe>',
video: '<video controls=""></video>',
audio: '<audio controls=""></audio>',
picture: '<picture><source srcset="/a.webp"></picture>',
object: '<object data="/x.bin"></object>',
embed: '<embed src="/x.bin">',
form: '<form action="/x"><input name="q"></form>',
svg: '<svg viewBox="0 0 1 1"><circle r="1"></circle></svg>',
math: '<math><mi>x</mi></math>',
script: '<script>window.__schemaPwned = 1</script>',
noscript: '<noscript>Bez skryptów</noscript>',
}
describe('raw HTML blocks', () => {
it('has a sample for every raw HTML tag rule', () => {
expect(Object.keys(rawSamples).sort()).toEqual([...RAW_HTML_TAGS].sort())
})
it.each(Object.entries(rawSamples))('keeps <%s> as one raw HTML card through an edit elsewhere', (_tag, block) => {
const html = `<p>a</p>${block}`
const { el, editor } = mount(html)
expect(editor.state.doc.childCount).toBe(2)
expect(editor.state.doc.child(1).type.name).toBe('rawHtml')
insertAtBlock(editor, 0, 'X')
expect(el.value).toBe(`<p>Xa</p>${block}`)
expect((window as unknown as { __schemaPwned?: number }).__schemaPwned).toBeUndefined()
})
it('shows the card source as escaped text, never as live markup', () => {
const payload = '<div><img src="x" onerror="window.__schemaPwned = 2"><b>bold</b></div>'
const { el } = mount(payload)
const source = el.querySelector('.blogeditor__raw-source')!
expect(source.textContent).toBe(payload)
expect(source.innerHTML).toContain('&lt;img')
expect(el.querySelector('.ProseMirror img, .ProseMirror b')).toBeNull()
expect((window as unknown as { __schemaPwned?: number }).__schemaPwned).toBeUndefined()
})
it('emits a raw block verbatim when the document is serialized from the model', () => {
expect(model(rawSamples.footer)).toBe(rawSamples.footer)
})
})
describe('figures', () => {
it('models a figure holding only an image', () => {
expect(types('<figure><img src="/a.jpg" alt="a"></figure>')).toEqual(['figure'])
expect(model('<figure><img src="/a.jpg" alt="a"></figure>')).toBe('<figure><img src="/a.jpg" alt="a"></figure>')
})
it('models a figure with an image and a caption', () => {
const html = '<figure><img src="/a.jpg" alt="a"><figcaption>Podpis <em>zdjęcia</em></figcaption></figure>'
const { editor } = mount(html)
const figure = editor.state.doc.child(0)
expect(figure.type.name).toBe('figure')
expect(figure.child(0).type.name).toBe('figureImage')
expect(figure.child(1).type.name).toBe('figcaption')
expect(serializeDocument(editor, null)).toBe(html)
})
it.each([
['other content', '<figure><p>tekst</p></figure>'],
['a quote with a caption', '<figure class="quote"><blockquote>Cytat</blockquote><figcaption>Autor</figcaption></figure>'],
['two images', '<figure><img src="/a.jpg"><img src="/b.jpg"></figure>'],
['loose text', '<figure>tekst<img src="/a.jpg"></figure>'],
['a caption first', '<figure><figcaption>Najpierw</figcaption><img src="/a.jpg"></figure>'],
['a data URL image', '<figure><img src="data:image/png;base64,AAAA"></figure>'],
['an image without src', '<figure><img alt="x"></figure>'],
['nothing', '<figure></figure>'],
])('keeps a figure with %s as raw HTML', (_name, html) => {
expect(types(html)).toEqual(['rawHtml'])
})
it('makes an uncaptioned inline image a paragraph image without base64', () => {
expect(model('<p><img src="/a.jpg" alt="a"></p>')).toBe('<p><img src="/a.jpg" alt="a"></p>')
expect(model('<p>a<img src="data:image/png;base64,AAAA">b</p>')).toBe('<p>ab</p>')
})
})
describe('marks and global attributes', () => {
it('keeps a span with attributes as a mark and drops a bare span', () => {
expect(shape(model('<p>a <span class="hl" style="color: red">b</span> c</p>'))).toBe(shape('<p>a <span class="hl" style="color: red">b</span> c</p>'))
expect(model('<p>a <span>b</span> c</p>')).toBe('<p>a b c</p>')
})
it.each([
['paragraph', '<p class="lead" data-x="1">a</p>'],
['heading', '<h2 id="intro" class="h">a</h2>'],
['blockquote', '<blockquote class="q"><p>a</p></blockquote>'],
['bullet list and item', '<ul class="list"><li class="item"><p>a</p></li></ul>'],
['ordered list with start', '<ol start="3" class="steps"><li><p>a</p></li></ol>'],
['code block', '<pre class="code"><code>a</code></pre>'],
['horizontal rule', '<hr class="sep">'],
['image', '<p><img src="/a.jpg" alt="a" class="photo" loading="lazy"></p>'],
['figcaption', '<figure><img src="/a.jpg" alt="a"><figcaption class="cap">c</figcaption></figure>'],
['link', '<p><a href="/a" class="more" data-id="7">a</a></p>'],
['table, row and cells', '<table class="t"><tbody><tr class="r"><td class="c" style="text-align: right">a</td></tr></tbody></table>'],
])('keeps unmodelled attributes on a %s', (_name, html) => {
expect(shape(model(html))).toBe(shape(html))
})
it('never keeps an event handler attribute', () => {
const out = model('<p class="a" onclick="x()" ONMOUSEOVER="y()">a</p><p><img src="/a.jpg" alt="a" onerror="z()"></p>')
expect(out).toBe('<p class="a">a</p><p><img src="/a.jpg" alt="a"></p>')
})
})
describe('links', () => {
it('adds no target, rel or class and keeps stored ones', () => {
expect(model('<p><a href="https://example.test/">a</a></p>')).toBe('<p><a href="https://example.test/">a</a></p>')
const stored = model('<p><a href="https://example.test/" target="_blank" rel="noopener">a</a></p>')
expect(stored).toContain('target="_blank"')
expect(stored).toContain('rel="noopener"')
})
it.each(['javascript:alert(1)', 'JAVASCRIPT:alert(1)', 'java\tscript:alert(1)', 'vbscript:x', 'data:text/html,x', 'tel:123'])(
'drops the link but keeps the text for %s',
(href) => {
const out = model(`<p><a href="${href}">tekst</a></p>`)
expect(out).toBe('<p>tekst</p>')
},
)
})
describe('tables', () => {
it('renders without colgroup or a min-width style, header row in thead, bare cell text', () => {
const out = model('<table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>')
expect(out).toBe('<table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>')
expect(out).not.toContain('colgroup')
expect(out).not.toContain('min-width')
})
it('keeps a paragraph with attributes inside a cell', () => {
expect(model('<table><tbody><tr><td><p class="x">1</p></td></tr></tbody></table>')).toBe('<table><tbody><tr><td><p class="x">1</p></td></tr></tbody></table>')
})
it('keeps colspan and rowspan', () => {
const html = '<table><tbody><tr><td colspan="2">a</td></tr><tr><td rowspan="2">b</td><td>c</td></tr><tr><td>d</td></tr></tbody></table>'
const out = model(html)
expect(out).toContain('colspan="2"')
expect(out).toContain('rowspan="2"')
})
})
describe('document shape', () => {
it('adds no trailing paragraph after a final block', () => {
for (const html of ['<h2>Tytuł</h2>', '<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>', '<table><tbody><tr><td>a</td></tr></tbody></table>', rawSamples.footer]) {
const { el, editor } = mount(html)
expect(editor.state.doc.lastChild!.type.name).not.toBe('paragraph')
expect(el.value).toBe(html)
}
})
it('serializes an edited heading level and list without wrappers it did not have', () => {
const { el, editor } = mount('<h3>Tytuł</h3>')
insertAtBlock(editor, 0, 'Nowy ')
expect(el.value).toBe('<h3>Nowy Tytuł</h3>')
})
})