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
This commit is contained in:
276
tests/blogeditor/embed.test.ts
Normal file
276
tests/blogeditor/embed.test.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
// Embeds, the URL rule, uploads and labels of the Blog Editor (Phase 16.2
|
||||
// D-04, D-05, D-13, D-18).
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { EMBED_PROVIDERS, isEmbedProvider, parseEmbedUrl } from '../../assets/src/blogeditor/embed'
|
||||
import { isPolicyUrl } from '../../assets/src/blogeditor/extensions'
|
||||
import { format, labelsFor } from '../../assets/src/blogeditor/labels'
|
||||
import { button, click, fill, form, mount, status } from './helpers'
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
const ID = 'dQw4w9WgXcQ'
|
||||
const PLACEHOLDER = `<figure data-embed="youtube" data-id="${ID}"></figure>`
|
||||
|
||||
describe('parseEmbedUrl', () => {
|
||||
it.each([
|
||||
[`https://www.youtube.com/watch?v=${ID}`],
|
||||
[`https://youtube.com/watch?v=${ID}`],
|
||||
[`https://m.youtube.com/watch?v=${ID}`],
|
||||
[`https://music.youtube.com/watch?v=${ID}`],
|
||||
[`http://www.youtube.com/watch?v=${ID}`],
|
||||
[`https://WWW.YOUTUBE.COM/watch?v=${ID}`],
|
||||
[`https://www.youtube.com/watch?v=${ID}&t=42s&list=PL123`],
|
||||
[`https://www.youtube.com/watch?feature=share&v=${ID}`],
|
||||
[`https://youtu.be/${ID}`],
|
||||
[`https://youtu.be/${ID}?t=10`],
|
||||
[`https://www.youtube.com/shorts/${ID}`],
|
||||
[`https://www.youtube.com/shorts/${ID}/`],
|
||||
[`https://www.youtube.com/embed/${ID}`],
|
||||
[`https://www.youtube-nocookie.com/embed/${ID}?rel=0`],
|
||||
[` https://youtu.be/${ID} `],
|
||||
])('accepts %s', (url) => {
|
||||
expect(parseEmbedUrl(url)).toEqual({ provider: 'youtube', id: ID })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a 10-character id', 'https://youtu.be/dQw4w9WgXc'],
|
||||
['a 12-character id', 'https://youtu.be/dQw4w9WgXcQQ'],
|
||||
['an id with a dot', 'https://www.youtube.com/watch?v=dQw4w9WgX.Q'],
|
||||
['no id', 'https://www.youtube.com/watch'],
|
||||
['a channel page', 'https://www.youtube.com/@channel'],
|
||||
['an extra path segment', `https://www.youtube.com/embed/${ID}/extra`],
|
||||
['another provider', `https://vimeo.com/${ID}`],
|
||||
['a lookalike host', `https://www.youtube.com.evil.test/watch?v=${ID}`],
|
||||
['a suffix host', `https://notyoutube.com/watch?v=${ID}`],
|
||||
['a subdomain of youtu.be', `https://x.youtu.be/${ID}`],
|
||||
['javascript:', `javascript:alert('${ID}')`],
|
||||
['ftp:', `ftp://youtu.be/${ID}`],
|
||||
['data:', `data:text/html,${ID}`],
|
||||
['a relative URL', `/watch?v=${ID}`],
|
||||
['text', 'not a url'],
|
||||
['empty', ''],
|
||||
])('refuses %s', (_name, url) => {
|
||||
expect(parseEmbedUrl(url)).toBeNull()
|
||||
})
|
||||
|
||||
it('has YouTube as the only provider, with an 11-character id pattern', () => {
|
||||
expect(Object.keys(EMBED_PROVIDERS)).toEqual(['youtube'])
|
||||
expect(EMBED_PROVIDERS.youtube.idPattern.test(ID)).toBe(true)
|
||||
expect(EMBED_PROVIDERS.youtube.idPattern.test(`${ID}\n`)).toBe(false)
|
||||
expect(isEmbedProvider('youtube')).toBe(true)
|
||||
expect(isEmbedProvider('vimeo')).toBe(false)
|
||||
expect(isEmbedProvider('toString')).toBe(false)
|
||||
expect(isEmbedProvider('__proto__')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('embed node', () => {
|
||||
it('round-trips the exact placeholder between other blocks', () => {
|
||||
const html = `<p>Film:</p>${PLACEHOLDER}<p>Koniec</p>`
|
||||
const { el, editor } = mount(html)
|
||||
const node = editor.state.doc.child(1)
|
||||
expect(node.type.name).toBe('embed')
|
||||
expect(node.attrs).toMatchObject({ provider: 'youtube', id: ID })
|
||||
expect(el.value).toBe(html)
|
||||
editor.view.dispatch(editor.state.tr.insertText('X', 1))
|
||||
expect(el.value).toBe(`<p>XFilm:</p>${PLACEHOLDER}<p>Koniec</p>`)
|
||||
})
|
||||
|
||||
it('shows a card and never an iframe', () => {
|
||||
const { el } = mount(PLACEHOLDER)
|
||||
expect(el.querySelector('iframe')).toBeNull()
|
||||
expect(el.querySelector('.blogeditor__embed')!.textContent).toBe(`${labelsFor("en").embedCard}: YouTube ${ID}`)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['another provider', `<figure data-embed="vimeo" data-id="${ID}"></figure>`],
|
||||
['a short id', '<figure data-embed="youtube" data-id="abc"></figure>'],
|
||||
['an extra attribute', `<figure data-embed="youtube" data-id="${ID}" class="x"></figure>`],
|
||||
['content', `<figure data-embed="youtube" data-id="${ID}">x</figure>`],
|
||||
['no id', '<figure data-embed="youtube"></figure>'],
|
||||
])('keeps a placeholder with %s as raw HTML, byte for byte', (_name, html) => {
|
||||
const { el, editor } = mount(html)
|
||||
expect(editor.state.doc.child(0).type.name).toBe('rawHtml')
|
||||
expect(el.value).toBe(html)
|
||||
})
|
||||
|
||||
it('serializes the attributes in placeholder order whatever the stored order', () => {
|
||||
const { el, editor } = mount(`<p>a</p><figure data-id="${ID}" data-embed="youtube"></figure>`)
|
||||
expect(editor.state.doc.child(1).type.name).toBe('embed')
|
||||
editor.view.dispatch(editor.state.tr.insertText('X', 1))
|
||||
// The untouched embed block keeps its stored bytes.
|
||||
expect(el.value).toBe(`<p>Xa</p><figure data-id="${ID}" data-embed="youtube"></figure>`)
|
||||
})
|
||||
|
||||
it('setEmbed refuses an unknown provider or a bad id', () => {
|
||||
const { el, editor } = mount('')
|
||||
expect(editor.commands.setEmbed({ provider: 'vimeo', id: ID })).toBe(false)
|
||||
expect(editor.commands.setEmbed({ provider: 'youtube', id: 'short' })).toBe(false)
|
||||
expect(el.value).toBe('')
|
||||
expect(editor.commands.setEmbed({ provider: 'youtube', id: ID })).toBe(true)
|
||||
expect(el.value).toBe(PLACEHOLDER)
|
||||
})
|
||||
|
||||
it('inserts from the toolbar, keeps the form open on an error and closes on cancel', () => {
|
||||
const { el, changes } = mount('<p>a</p>')
|
||||
click(button(el, 'embed'))
|
||||
const embedForm = form(el, 'embed')!
|
||||
fill(embedForm.querySelector('input'), 'javascript:alert(1)')
|
||||
click(embedForm.querySelector('[data-action="insert"]'))
|
||||
expect(form(el, 'embed')).not.toBeNull()
|
||||
expect(el.value).toBe('<p>a</p>')
|
||||
expect(changes).toEqual([])
|
||||
click(embedForm.querySelector('[data-action="cancel"]'))
|
||||
expect(form(el, 'embed')).toBeNull()
|
||||
|
||||
click(button(el, 'embed'))
|
||||
fill(form(el, 'embed')!.querySelector('input'), `https://www.youtube.com/shorts/${ID}`)
|
||||
click(form(el, 'embed')!.querySelector('[data-action="insert"]'))
|
||||
expect(el.value).toContain(PLACEHOLDER)
|
||||
expect(changes).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPolicyUrl', () => {
|
||||
it.each(['http://a.test', 'https://a.test/x?y#z', 'HTTPS://A.TEST', 'mailto:a@b.test', '/rel', 'rel/path', '../up', '#fragment', '?q=1', '', 'https://a.test/javascript:x'])(
|
||||
'accepts %j',
|
||||
(url) => {
|
||||
expect(isPolicyUrl(url)).toBe(true)
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
'javascript:alert(1)',
|
||||
'JavaScript:alert(1)',
|
||||
' javascript:alert(1)',
|
||||
'java\tscript:alert(1)',
|
||||
'java\nscript:alert(1)',
|
||||
'java\rscript:alert(1)',
|
||||
'javascript\u0000:alert(1)',
|
||||
'vbscript:msgbox(1)',
|
||||
'data:text/html,x',
|
||||
'tel:123',
|
||||
'ftp://a.test',
|
||||
'file:///etc/passwd',
|
||||
])('refuses %j', (url) => {
|
||||
expect(isPolicyUrl(url)).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses null and undefined', () => {
|
||||
expect(isPolicyUrl(null)).toBe(false)
|
||||
expect(isPolicyUrl(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('upload flow', () => {
|
||||
const png = () => new File([new Uint8Array([137, 80, 78, 71])], 'grzyb.png', { type: 'image/png' })
|
||||
|
||||
function pick(el: HTMLElement, files: File[]): void {
|
||||
const input = el.querySelector<HTMLInputElement>('input[type="file"]')!
|
||||
Object.defineProperty(input, 'files', { value: files, configurable: true })
|
||||
input.dispatchEvent(new Event('change'))
|
||||
}
|
||||
|
||||
function answer(el: HTMLElement, detail: Record<string, string>): void {
|
||||
el.dispatchEvent(new CustomEvent('summer-upload-result', { detail }))
|
||||
}
|
||||
|
||||
it('ignores an answer with an id it did not send', () => {
|
||||
const { el, uploads, changes } = mount('<p>a</p>')
|
||||
pick(el, [png()])
|
||||
answer(el, { id: 'not-ours', url: '/x.png' })
|
||||
answer(el, { id: 'not-ours', error: 'nope' })
|
||||
expect(form(el, 'image')).toBeNull()
|
||||
expect(status(el)).toBe('Uploading grzyb.png…')
|
||||
answer(el, { id: uploads[0]!.id, url: '/ours.png' })
|
||||
expect(form(el, 'image')).not.toBeNull()
|
||||
expect(changes).toEqual([])
|
||||
})
|
||||
|
||||
it('times out after 60 seconds and ignores the late answer', () => {
|
||||
vi.useFakeTimers()
|
||||
const { el, uploads } = mount('')
|
||||
pick(el, [png()])
|
||||
vi.advanceTimersByTime(59_999)
|
||||
expect(status(el)).toBe('Uploading grzyb.png…')
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(status(el)).toBe('The upload of grzyb.png took too long.')
|
||||
answer(el, { id: uploads[0]!.id, url: '/late.png' })
|
||||
expect(form(el, 'image')).toBeNull()
|
||||
expect(el.value).toBe('')
|
||||
})
|
||||
|
||||
it('answers each of several uploads by its own id', () => {
|
||||
const { el, uploads } = mount('')
|
||||
pick(el, [png(), new File([new Uint8Array([255, 216, 255])], 'b.jpg', { type: 'image/jpeg' })])
|
||||
expect(uploads).toHaveLength(2)
|
||||
expect(uploads[0]!.id).not.toBe(uploads[1]!.id)
|
||||
answer(el, { id: uploads[1]!.id, error: 'Too big' })
|
||||
expect(status(el)).toBe('Upload failed: Too big')
|
||||
})
|
||||
|
||||
it('refuses an uploaded URL outside the policy', () => {
|
||||
const { el, uploads } = mount('')
|
||||
pick(el, [png()])
|
||||
answer(el, { id: uploads[0]!.id, url: 'javascript:alert(1)' })
|
||||
expect(form(el, 'image')).toBeNull()
|
||||
expect(status(el)).toMatch(/^Upload failed/)
|
||||
expect(el.value).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('labels', () => {
|
||||
it('has Polish labels, an English fallback and every key in both', () => {
|
||||
const en = labelsFor('en')
|
||||
const pl = labelsFor('pl')
|
||||
expect(Object.keys(pl).sort()).toEqual(Object.keys(en).sort())
|
||||
for (const key of Object.keys(en) as (keyof typeof en)[]) {
|
||||
expect(en[key], key).not.toBe('')
|
||||
expect(pl[key], key).not.toBe('')
|
||||
}
|
||||
expect(pl.tabSource).not.toBe(en.tabSource)
|
||||
expect(labelsFor('pl-PL')).toBe(pl)
|
||||
expect(labelsFor('PL')).toBe(pl)
|
||||
expect(labelsFor('de')).toBe(en)
|
||||
expect(labelsFor('')).toBe(en)
|
||||
expect(labelsFor(null)).toBe(en)
|
||||
expect(labelsFor(undefined)).toBe(en)
|
||||
})
|
||||
|
||||
it('follows the ui-locale attribute of the element', () => {
|
||||
const { el } = mount('<p>a</p>', { 'ui-locale': 'pl' })
|
||||
expect(el.querySelector('button[data-tab="source"]')!.textContent).toBe(labelsFor('pl').tabSource)
|
||||
const { el: other } = mount('<p>a</p>', { 'ui-locale': 'fr' })
|
||||
expect(other.querySelector('button[data-tab="source"]')!.textContent).toBe(labelsFor('en').tabSource)
|
||||
})
|
||||
|
||||
it('format fills known placeholders and leaves unknown ones', () => {
|
||||
expect(format('Uploading {name}…', { name: 'a.png' })).toBe('Uploading a.png…')
|
||||
expect(format('{a} and {b}', { a: '1' })).toBe('1 and {b}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('disabled and invalid', () => {
|
||||
it('disables the formatting buttons and the source while disabled, and marks the editor invalid', () => {
|
||||
const { el } = mount('<p>a</p>', { disabled: '', invalid: '' })
|
||||
expect(el.editor!.isEditable).toBe(false)
|
||||
expect(button(el, 'bold').disabled).toBe(true)
|
||||
expect(el.querySelector('textarea')!.readOnly).toBe(true)
|
||||
expect(el.editor!.view.dom.getAttribute('aria-invalid')).toBe('true')
|
||||
// The preview toggle stays usable: it never edits.
|
||||
expect(button(el, 'preview').disabled).toBe(false)
|
||||
el.removeAttribute('disabled')
|
||||
expect(button(el, 'bold').disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('emits nothing for clicks on a disabled editor', () => {
|
||||
const { el, changes } = mount('<p>a</p>', { disabled: '' })
|
||||
click(button(el, 'bold'))
|
||||
click(button(el, 'embed'))
|
||||
expect(changes).toEqual([])
|
||||
expect(el.value).toBe('<p>a</p>')
|
||||
})
|
||||
})
|
||||
220
tests/blogeditor/schema.test.ts
Normal file
220
tests/blogeditor/schema.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
// 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('<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>')
|
||||
})
|
||||
})
|
||||
155
tests/blogeditor/serializer.test.ts
Normal file
155
tests/blogeditor/serializer.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
// Top-level block reuse (Phase 16.2 D-03): after an edit, every top-level
|
||||
// block the edit did not touch is emitted as its original source bytes,
|
||||
// with the white space and comments between blocks, whatever the edit was
|
||||
// (one changed block, a deletion, an insertion).
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { getSchema } from '@tiptap/core'
|
||||
import { preservationExtensions } from '../../assets/src/blogeditor/extensions'
|
||||
import { captureBlocks } from '../../assets/src/blogeditor/serializer'
|
||||
import { insertAtBlock, mount } from './helpers'
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
// Source formatting the schema serializer would not reproduce: upper-case
|
||||
// tags, single quotes, unquoted and reordered attributes, entities,
|
||||
// self-closing void tags and odd white space.
|
||||
const blocks = [
|
||||
"<P CLASS='lead'>Pierwszy akapit & więcej</P>",
|
||||
'<h2 id=intro >Wstęp</h2>',
|
||||
'<p>Drugi <a title="t" href="/x">link</a></p>',
|
||||
'<ul>\n <li>jeden</li>\n <li>dwa</li>\n</ul>',
|
||||
'<p>Trzeci<br/>wiersz</p>',
|
||||
'<footer class="author-footer"><p>Autor</p></footer>',
|
||||
]
|
||||
const separators = ['\n\n', '\n<!-- komentarz -->\n', '\r\n', '\n \n', '\n']
|
||||
const source = blocks.map((block, i) => block + (separators[i] ?? '')).join('') + '\n<!-- end -->\n'
|
||||
|
||||
/** topLevel returns the editor document's top-level node types. */
|
||||
function topLevel(el: ReturnType<typeof mount>['editor']): string[] {
|
||||
const out: string[] = []
|
||||
el.state.doc.forEach((node) => out.push(node.type.name))
|
||||
return out
|
||||
}
|
||||
|
||||
describe('block reuse', () => {
|
||||
it('returns the source unchanged before any edit', () => {
|
||||
const { el, editor } = mount(source)
|
||||
expect(topLevel(editor)).toEqual(['paragraph', 'heading', 'paragraph', 'bulletList', 'paragraph', 'rawHtml'])
|
||||
expect(el.value).toBe(source)
|
||||
})
|
||||
|
||||
it('keeps every other block, separator and comment byte for byte after one edit', () => {
|
||||
const { el, editor } = mount(source)
|
||||
insertAtBlock(editor, 2, 'X')
|
||||
const value = el.value
|
||||
expect(value).not.toBe(source)
|
||||
for (const [i, block] of blocks.entries()) {
|
||||
if (i === 2) continue
|
||||
expect(value).toContain(block)
|
||||
}
|
||||
for (const separator of separators) expect(value).toContain(separator)
|
||||
expect(value).toContain('<p>XDrugi')
|
||||
expect(value.endsWith('\n<!-- end -->\n')).toBe(true)
|
||||
// Only the edited block differs from the source.
|
||||
expect(value.replace(/<p>XDrugi[^]*?<\/p>/, blocks[2]!)).toBe(source)
|
||||
})
|
||||
|
||||
it('keeps the remaining blocks after a deletion', () => {
|
||||
const { el, editor } = mount(source)
|
||||
let from = 0
|
||||
let to = 0
|
||||
editor.state.doc.forEach((node, offset, index) => {
|
||||
if (index === 1) {
|
||||
from = offset
|
||||
to = offset + node.nodeSize
|
||||
}
|
||||
})
|
||||
editor.view.dispatch(editor.state.tr.delete(from, to))
|
||||
const value = el.value
|
||||
expect(value).not.toContain('Wstęp')
|
||||
for (const [i, block] of blocks.entries()) {
|
||||
if (i !== 1) expect(value).toContain(block)
|
||||
}
|
||||
expect(value.startsWith(blocks[0]!)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps every block around an insertion and serializes only the new one', () => {
|
||||
const { el, editor } = mount(source)
|
||||
let at = 0
|
||||
editor.state.doc.forEach((_node, offset, index) => {
|
||||
if (index === 3) at = offset
|
||||
})
|
||||
const paragraph = editor.schema.nodes.paragraph!.create(null, editor.schema.text('Nowy'))
|
||||
editor.view.dispatch(editor.state.tr.insert(at, paragraph))
|
||||
const value = el.value
|
||||
for (const block of blocks) expect(value).toContain(block)
|
||||
const inserted = value.indexOf('<p>Nowy</p>')
|
||||
expect(inserted).toBeGreaterThan(value.indexOf(blocks[2]!))
|
||||
expect(inserted).toBeLessThan(value.indexOf(blocks[3]!))
|
||||
})
|
||||
|
||||
it('keeps reused blocks after several edits and an undo returns the exact source', () => {
|
||||
const { el, editor } = mount(source)
|
||||
insertAtBlock(editor, 0, 'A')
|
||||
insertAtBlock(editor, 4, 'B')
|
||||
const value = el.value
|
||||
for (const i of [1, 2, 3, 5]) expect(value).toContain(blocks[i]!)
|
||||
editor.commands.undo()
|
||||
editor.commands.undo()
|
||||
expect(el.value).toBe(source)
|
||||
})
|
||||
|
||||
it('serializes every block when the document is replaced entirely', () => {
|
||||
const { el, editor } = mount(source)
|
||||
editor.commands.setContent('<p>Inny</p>')
|
||||
expect(el.value).toBe('<p>Inny</p>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureBlocks', () => {
|
||||
const schema = getSchema(preservationExtensions())
|
||||
|
||||
it('records each block with the text before it and the trailing text', () => {
|
||||
const captured = captureBlocks('\n<p>a</p>\n<!-- c -->\n<h2>b</h2> ', schema)!
|
||||
expect(captured.blocks.map((b) => [b.before, b.html])).toEqual([
|
||||
['\n', '<p>a</p>'],
|
||||
['\n<!-- c -->\n', '<h2>b</h2>'],
|
||||
])
|
||||
expect(captured.trailing).toBe(' ')
|
||||
expect(captured.blocks.map((b) => b.nodes.map((n) => n.type.name))).toEqual([['paragraph'], ['heading']])
|
||||
})
|
||||
|
||||
it('splits raw text elements and nested elements by source position', () => {
|
||||
const html = '<style>p > a { x: "</p>" }</style><div><div>a</div><p>b</p></div><p>c<!-- </p> --></p>'
|
||||
const captured = captureBlocks(html, schema)!
|
||||
expect(captured.blocks.map((b) => b.html)).toEqual(['<style>p > a { x: "</p>" }</style>', '<div><div>a</div><p>b</p></div>', '<p>c<!-- </p> --></p>'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an unclosed comment', '<p>a</p><!-- open'],
|
||||
['an unclosed element', '<p>a</p><div><p>b'],
|
||||
['an unclosed tag', '<p>a</p><p class="x'],
|
||||
])('gives up on %s, and the element still returns the untouched source', (_name, html) => {
|
||||
expect(captureBlocks(html, schema)).toBeNull()
|
||||
const { el } = mount(html)
|
||||
expect(el.value).toBe(html)
|
||||
})
|
||||
|
||||
it('gives up on implied end tags it cannot split by source position', () => {
|
||||
// The second <p> closes the first only in the HTML parser's context.
|
||||
expect(captureBlocks('<p>a<p>b', schema)).toBeNull()
|
||||
const { el, editor } = mount('<p>a<p>b')
|
||||
expect(el.value).toBe('<p>a<p>b')
|
||||
// An edit serializes the whole document from the model.
|
||||
insertAtBlock(editor, 0, 'X')
|
||||
expect(el.value).toBe('<p>Xa</p><p>b</p>')
|
||||
})
|
||||
|
||||
it('handles an empty value', () => {
|
||||
const captured = captureBlocks('', schema)!
|
||||
expect(captured.blocks).toEqual([])
|
||||
expect(captured.trailing).toBe('')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user