From a578ddabdaaf3f52bfc55974daf874d5e00c2db7 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sat, 10 Oct 2026 16:03:07 +0200 Subject: [PATCH] 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 --- tests/blogeditor/embed.test.ts | 276 ++++++++++++++++++++++++++++ tests/blogeditor/schema.test.ts | 220 ++++++++++++++++++++++ tests/blogeditor/serializer.test.ts | 155 ++++++++++++++++ 3 files changed, 651 insertions(+) create mode 100644 tests/blogeditor/embed.test.ts create mode 100644 tests/blogeditor/schema.test.ts create mode 100644 tests/blogeditor/serializer.test.ts diff --git a/tests/blogeditor/embed.test.ts b/tests/blogeditor/embed.test.ts new file mode 100644 index 0000000..0d6421d --- /dev/null +++ b/tests/blogeditor/embed.test.ts @@ -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 = `
` + +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 = `

Film:

${PLACEHOLDER}

Koniec

` + 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(`

XFilm:

${PLACEHOLDER}

Koniec

`) + }) + + 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', `
`], + ['a short id', '
'], + ['an extra attribute', `
`], + ['content', `
x
`], + ['no id', '
'], + ])('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(`

a

`) + 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(`

Xa

`) + }) + + 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('

a

') + 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('

a

') + 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('input[type="file"]')! + Object.defineProperty(input, 'files', { value: files, configurable: true }) + input.dispatchEvent(new Event('change')) + } + + function answer(el: HTMLElement, detail: Record): 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('

a

') + 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('

a

', { 'ui-locale': 'pl' }) + expect(el.querySelector('button[data-tab="source"]')!.textContent).toBe(labelsFor('pl').tabSource) + const { el: other } = mount('

a

', { '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('

a

', { 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('

a

', { disabled: '' }) + click(button(el, 'bold')) + click(button(el, 'embed')) + expect(changes).toEqual([]) + expect(el.value).toBe('

a

') + }) +}) diff --git a/tests/blogeditor/schema.test.ts b/tests/blogeditor/schema.test.ts new file mode 100644 index 0000000..3f9a462 --- /dev/null +++ b/tests/blogeditor/schema.test.ts @@ -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: '

Autor

', + section: '

Sekcja

', + aside: '', + header: '

Nagłówek

', + nav: '', + details: '
WięcejTreść
', + style: '', + div: '
Pudełko
', + article: '

Artykuł

', + main: '

Główna

', + address: '
ul. Leśna 1
', + dl: '
Termin
Opis
', + iframe: '', + video: '', + audio: '', + picture: '', + object: '', + embed: '', + form: '
', + svg: '', + math: 'x', + script: '', + 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 = `

a

${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(`

Xa

${block}`) + expect((window as unknown as { __schemaPwned?: number }).__schemaPwned).toBeUndefined() + }) + + it('shows the card source as escaped text, never as live markup', () => { + const payload = '
bold
' + 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('
a
')).toEqual(['figure']) + expect(model('
a
')).toBe('
a
') + }) + + it('models a figure with an image and a caption', () => { + const html = '
a
Podpis zdjęcia
' + 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', '

tekst

'], + ['a quote with a caption', '
Cytat
Autor
'], + ['two images', '
'], + ['loose text', '
tekst
'], + ['a caption first', '
Najpierw
'], + ['a data URL image', '
'], + ['an image without src', '
x
'], + ['nothing', '
'], + ])('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('

a

')).toBe('

a

') + expect(model('

ab

')).toBe('

ab

') + }) +}) + +describe('marks and global attributes', () => { + it('keeps a span with attributes as a mark and drops a bare span', () => { + expect(shape(model('

a b c

'))).toBe(shape('

a b c

')) + expect(model('

a b c

')).toBe('

a b c

') + }) + + it.each([ + ['paragraph', '

a

'], + ['heading', '

a

'], + ['blockquote', '

a

'], + ['bullet list and item', '
  • a

'], + ['ordered list with start', '
  1. a

'], + ['code block', '
a
'], + ['horizontal rule', '
'], + ['image', '

a

'], + ['figcaption', '
a
c
'], + ['link', '

a

'], + ['table, row and cells', '
a
'], + ])('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('

a

a

') + expect(out).toBe('

a

a

') + }) +}) + +describe('links', () => { + it('adds no target, rel or class and keeps stored ones', () => { + expect(model('

a

')).toBe('

a

') + const stored = model('

a

') + 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(`

tekst

`) + expect(out).toBe('

tekst

') + }, + ) +}) + +describe('tables', () => { + it('renders without colgroup or a min-width style, header row in thead, bare cell text', () => { + const out = model('
AB
12
') + expect(out).toBe('
AB
12
') + expect(out).not.toContain('colgroup') + expect(out).not.toContain('min-width') + }) + + it('keeps a paragraph with attributes inside a cell', () => { + expect(model('

1

')).toBe('

1

') + }) + + it('keeps colspan and rowspan', () => { + const html = '
a
bc
d
' + 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 ['

Tytuł

', '
', '
a
', 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('

Tytuł

') + insertAtBlock(editor, 0, 'Nowy ') + expect(el.value).toBe('

Nowy Tytuł

') + }) +}) diff --git a/tests/blogeditor/serializer.test.ts b/tests/blogeditor/serializer.test.ts new file mode 100644 index 0000000..125fc1f --- /dev/null +++ b/tests/blogeditor/serializer.test.ts @@ -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 = [ + "

Pierwszy akapit & więcej

", + '

Wstęp

', + '

Drugi link

', + '
    \n
  • jeden
  • \n
  • dwa
  • \n
', + '

Trzeci
wiersz

', + '

Autor

', +] +const separators = ['\n\n', '\n\n', '\r\n', '\n \n', '\n'] +const source = blocks.map((block, i) => block + (separators[i] ?? '')).join('') + '\n\n' + +/** topLevel returns the editor document's top-level node types. */ +function topLevel(el: ReturnType['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('

XDrugi') + expect(value.endsWith('\n\n')).toBe(true) + // Only the edited block differs from the source. + expect(value.replace(/

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('

Nowy

') + 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('

Inny

') + expect(el.value).toBe('

Inny

') + }) +}) + +describe('captureBlocks', () => { + const schema = getSchema(preservationExtensions()) + + it('records each block with the text before it and the trailing text', () => { + const captured = captureBlocks('\n

a

\n\n

b

', schema)! + expect(captured.blocks.map((b) => [b.before, b.html])).toEqual([ + ['\n', '

a

'], + ['\n\n', '

b

'], + ]) + 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 = '
a

b

c

' + const captured = captureBlocks(html, schema)! + expect(captured.blocks.map((b) => b.html)).toEqual(['', '
a

b

', '

c

']) + }) + + it.each([ + ['an unclosed comment', '

a