Files
sm-journal-plugin/tests/blogeditor/smoke.test.ts
Jakub Zych b997a416a7 fix(16.2): IN-04 make upload ids without crypto.randomUUID
crypto.randomUUID exists only in a secure context, so an admin served
over plain HTTP from a LAN host threw on every editor upload while the
status line still said uploading. uploadRequestId falls back to a v4
UUID from crypto.getRandomValues, or a time and counter id when no
crypto object exists.
2026-10-10 16:39:29 +02:00

545 lines
23 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest'
import fixture from './fixtures/imported-post.html?raw'
import { BlogEditorElement, TAG } from '../../assets/src/blogeditor/index'
import { uploadRequestId } from '../../assets/src/blogeditor/element'
import { parseEmbedUrl } from '../../assets/src/blogeditor/embed'
import { isPolicyUrl } from '../../assets/src/blogeditor/extensions'
import { button, click, fill, form, insertAtBlock, insertAtEveryTextblock, mount, status } from './helpers'
afterEach(() => {
vi.useRealTimers()
document.body.replaceChildren()
})
describe('blog editor element (tracer)', () => {
it('defines the custom element tag once', () => {
expect(customElements.get(TAG)).toBe(BlogEditorElement)
expect(document.createElement(TAG)).toBeInstanceOf(BlogEditorElement)
})
it('returns the imported post byte for byte without an edit', () => {
const { el, changes } = mount(fixture)
expect(el.editor).toBeDefined()
expect(el.value).toBe(fixture)
// Setting the value after connect is untouched too.
const { el: late, changes: lateChanges } = mount()
late.value = fixture
expect(late.value).toBe(fixture)
expect(changes).toEqual([])
expect(lateChanges).toEqual([])
})
it('emits no summer-change on set or on an echo set', () => {
const { el, changes } = mount(fixture)
el.value = fixture
el.value = el.value
expect(changes).toEqual([])
expect(el.value).toBe(fixture)
})
it('emits exactly one summer-change per user edit with the element value', () => {
const { el, editor, changes } = mount('<p>Hello</p>')
editor.view.dispatch(editor.state.tr.insertText('X', 1))
expect(changes).toHaveLength(1)
expect(changes[0]).toBe(el.value)
expect(el.value).toBe('<p>XHello</p>')
// The SPA echoes the emitted value back: no reset, no event.
el.value = changes[0]!
expect(changes).toHaveLength(1)
expect(el.value).toBe('<p>XHello</p>')
})
it('shows the exact value in the source tab', () => {
const { el, changes } = mount(fixture)
el.showSource(true)
const textarea = el.querySelector('textarea')!
expect(textarea.hidden).toBe(false)
expect(textarea.value).toBe(fixture)
textarea.value = '<p>Edited in source</p>\n<footer>kept</footer>'
textarea.dispatchEvent(new Event('input'))
expect(changes).toEqual(['<p>Edited in source</p>\n<footer>kept</footer>'])
expect(el.value).toBe('<p>Edited in source</p>\n<footer>kept</footer>')
// Back to WYSIWYG: re-parsed, but the value stays the exact text.
el.showSource(false)
expect(el.value).toBe('<p>Edited in source</p>\n<footer>kept</footer>')
expect(changes).toHaveLength(1)
})
it('keeps an edited value after it is detached and attached again', () => {
const { el, editor, changes } = mount('<p>a</p>')
editor.view.dispatch(editor.state.tr.insertText('X', 1))
el.remove()
expect(el.value).toBe('<p>Xa</p>')
document.body.append(el)
expect(el.value).toBe('<p>Xa</p>')
expect(el.editor!.state.doc.textContent).toBe('Xa')
expect(changes).toEqual(['<p>Xa</p>'])
})
it('a new external value replaces the content without an event', () => {
const { el, changes } = mount('<p>One</p>')
el.value = '<p>Two</p>'
expect(el.value).toBe('<p>Two</p>')
expect(el.editor!.state.doc.textContent).toBe('Two')
expect(changes).toEqual([])
// The replaced value is not in the undo history.
expect(el.editor!.can().undo()).toBe(false)
})
it('disabled makes the editor and the source read-only; invalid sets aria-invalid', () => {
const { el } = mount('<p>x</p>')
el.setAttribute('disabled', '')
el.setAttribute('invalid', '')
expect(el.editor!.isEditable).toBe(false)
expect(el.querySelector('textarea')!.readOnly).toBe(true)
expect(el.editor!.view.dom.getAttribute('aria-invalid')).toBe('true')
el.removeAttribute('disabled')
el.removeAttribute('invalid')
expect(el.editor!.isEditable).toBe(true)
expect(el.editor!.view.dom.hasAttribute('aria-invalid')).toBe(false)
})
})
describe('preservation schema', () => {
const rawBlocks = [
'<footer class="author-footer" itemscope="" itemtype="https://schema.org/Person"><p>Autor</p></footer>',
'<section class="s"><p>Sekcja</p></section>',
'<aside>Na marginesie</aside>',
'<header><h2>Nagłówek</h2></header>',
'<nav><a href="/a">a</a></nav>',
'<details><summary>Więcej</summary>Treść</details>',
'<style>.x { color: red; }</style>',
'<div class="box" data-x="1">Pudełko</div>',
'<figure class="quote"><blockquote>Cytat</blockquote><figcaption>Autor</figcaption></figure>',
]
it('turns unknown block markup into raw HTML cards that keep it through an edit', () => {
const html = `<p>Start</p>\n${rawBlocks.join('\n')}\n`
const { el, editor } = mount(html)
const types: string[] = []
editor.state.doc.forEach((node) => types.push(node.type.name))
expect(types).toEqual(['paragraph', ...rawBlocks.map(() => 'rawHtml')])
const cards = Array.from(el.querySelectorAll('.blogeditor__raw-source')).map((n) => n.textContent)
expect(cards).toEqual(rawBlocks)
insertAtBlock(editor, 0, 'X')
expect(el.value).toBe(`<p>XStart</p>\n${rawBlocks.join('\n')}\n`)
})
it('renders a raw HTML card as text, never as live markup', () => {
const payload = '<div><img src="x" onerror="window.__blogeditorPwned = 1"><script>window.__blogeditorPwned = 2</script></div>'
const { el, editor } = mount(`<p>a</p>${payload}`)
expect(editor.state.doc.child(1).type.name).toBe('rawHtml')
expect(el.querySelector('.ProseMirror img')).toBeNull()
expect(el.querySelector('.ProseMirror script')).toBeNull()
expect(el.querySelector('.blogeditor__raw-source')!.textContent).toBe(payload)
expect((window as unknown as { __blogeditorPwned?: number }).__blogeditorPwned).toBeUndefined()
// Handler attributes on modelled nodes never reach the editor DOM.
const inline = mount('<p><img src="/a.png" alt="a" onerror="window.__blogeditorPwned = 3" onload="x()"></p>')
const img = inline.el.querySelector('.ProseMirror img:not(.ProseMirror-separator)')!
expect(img.getAttribute('src')).toBe('/a.png')
expect(img.getAttributeNames().filter((name) => name.startsWith('on'))).toEqual([])
})
it('keeps view-safe unmodelled attributes on edited blocks and spans as marks', () => {
const html =
'<p class="lead" data-x="1" itemprop="description">Tekst <span style="color: red;" class="s">czerwony</span></p>' +
'<h2 id="h" class="c">Tytuł</h2>' +
'<ul class="u"><li data-i="1"><p>Punkt</p></li></ul>' +
'<ol start="3" class="o"><li><p>Trzy</p></li></ol>' +
'<blockquote class="q"><p>Cytat</p></blockquote>' +
'<p><a href="/x" class="lnk" data-t="1">link</a> <img src="/a.png" alt="A" class="photo" data-id="7"></p>' +
'<table class="t"><tbody><tr><td class="c" style="text-align: right;">1</td></tr></tbody></table>'
const { el, editor } = mount(html)
insertAtEveryTextblock(editor, 'X')
// WR-05: itemprop, style and id are not view-safe, so an edited block
// drops them; class and data-* survive.
expect(el.value).toBe(
'<p class="lead" data-x="1">XTekst <span class="s">czerwony</span></p>' +
'<h2 class="c">XTytuł</h2>' +
'<ul class="u"><li data-i="1"><p>XPunkt</p></li></ul>' +
'<ol class="o" start="3"><li><p>XTrzy</p></li></ol>' +
'<blockquote class="q"><p>XCytat</p></blockquote>' +
'<p><a class="lnk" data-t="1" href="/x">Xlink</a> <img class="photo" data-id="7" src="/a.png" alt="A"></p>' +
'<table class="t"><tbody><tr><td class="c">X1</td></tr></tbody></table>',
)
})
it('keeps every stored attribute of a block the edit does not touch', () => {
const kept = '<p id="keep" style="color: red;" itemprop="x">Bez zmian</p>'
const html = `<p>Edytowany</p>\n${kept}\n`
const { el, editor } = mount(html)
expect(el.value).toBe(html)
insertAtBlock(editor, 0, 'X')
expect(el.value).toBe(`<p>XEdytowany</p>\n${kept}\n`)
})
it('leaves every other top-level block byte-identical after editing one paragraph', () => {
const { el, editor, changes } = mount(fixture)
insertAtBlock(editor, 1, 'X')
const expected = fixture.replace('<p>Turkey Tail', '<p>XTurkey Tail')
expect(expected).not.toBe(fixture)
expect(el.value).toBe(expected)
expect(changes).toEqual([expected])
// Undoing the edit gives back the exact original bytes.
expect(editor.commands.undo()).toBe(true)
expect(el.value).toBe(fixture)
})
it('serializes tables without colgroup or min-width, with thead and bare cell text', () => {
const html = '<table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>'
const { el, editor } = mount(html)
insertAtEveryTextblock(editor, 'X')
expect(el.value).toBe(
'<table><thead><tr><th>XA</th><th>XB</th></tr></thead><tbody><tr><td>X1</td><td>X2</td></tr></tbody></table>',
)
const fresh = mount('<p>t</p>')
fresh.editor.commands.setTextSelection(1)
expect(fresh.editor.commands.insertTable({ rows: 2, cols: 2, withHeaderRow: true })).toBe(true)
expect(fresh.el.value).not.toMatch(/colgroup|min-width|style=/)
expect(fresh.el.value).toContain('<thead><tr><th></th><th></th></tr></thead><tbody><tr><td></td><td></td></tr></tbody>')
expect(fresh.el.value).not.toMatch(/<p><\/p>$/)
})
it('keeps stored link target and rel, adds none and leaves no trailing paragraph', () => {
// Text typed at the start of a link joins the link (links are inclusive).
// Edited links keep their attributes; their order may change.
const { el, editor } = mount('<p><a href="https://x.test/" target="_blank" rel="noopener">x</a> y</p><hr>')
insertAtBlock(editor, 0, 'Z')
expect(el.value).toBe('<p><a target="_blank" rel="noopener" href="https://x.test/">Zx</a> y</p><hr>')
const fresh = mount('<p>link</p>')
fresh.editor.commands.setTextSelection({ from: 1, to: 5 })
expect(fresh.editor.commands.setLink({ href: 'https://example.test/' })).toBe(true)
expect(fresh.el.value).toBe('<p><a href="https://example.test/">link</a></p>')
})
})
describe('embeds', () => {
it('parses supported YouTube URLs to the 11-character id', () => {
const id = 'dQw4w9WgXcQ'
for (const url of [
`https://www.youtube.com/watch?v=${id}`,
`https://youtube.com/watch?v=${id}&t=42s`,
`https://m.youtube.com/watch?feature=share&v=${id}`,
`https://youtu.be/${id}`,
`https://youtu.be/${id}?si=abc`,
`https://www.youtube.com/shorts/${id}`,
`https://www.youtube.com/embed/${id}`,
` https://www.youtube-nocookie.com/embed/${id} `,
]) {
expect(parseEmbedUrl(url), url).toEqual({ provider: 'youtube', id })
}
for (const url of [
'https://vimeo.com/123456',
'https://www.youtube.com/watch?v=short',
'https://www.youtube.com/watch?v=dQw4w9WgXcQQ',
'https://youtu.be/dQw4w9WgXc!',
'https://evil.test/embed/dQw4w9WgXcQ',
'javascript:alert(1)//youtu.be/dQw4w9WgXcQ',
'ftp://youtu.be/dQw4w9WgXcQ',
'not a url',
'',
]) {
expect(parseEmbedUrl(url), url).toBeNull()
}
})
it('round-trips the exact placeholder and shows a card, never an iframe', () => {
const placeholder = '<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>'
const { el, editor } = mount(`<p>a</p>${placeholder}`)
expect(editor.state.doc.child(1).type.name).toBe('embed')
expect(el.querySelector('iframe')).toBeNull()
expect(el.querySelector('.blogeditor__embed')!.textContent).toContain('YouTube')
insertAtBlock(editor, 0, 'X')
expect(el.value).toBe(`<p>Xa</p>${placeholder}`)
const fresh = mount('')
expect(fresh.editor.commands.setEmbed({ provider: 'youtube', id: 'dQw4w9WgXcQ' })).toBe(true)
expect(fresh.el.value).toBe(placeholder)
expect(fresh.editor.commands.setEmbed({ provider: 'youtube', id: 'bad' })).toBe(false)
expect(fresh.editor.commands.setEmbed({ provider: 'vimeo', id: 'dQw4w9WgXcQ' })).toBe(false)
})
it('keeps a malformed placeholder as raw HTML', () => {
const { editor } = mount('<figure data-embed="youtube" data-id="bad"></figure>')
expect(editor.state.doc.child(0).type.name).toBe('rawHtml')
})
it('inserts an embed from the toolbar URL input and refuses unsupported URLs', () => {
const { el } = mount('')
click(button(el, 'embed'))
const embedForm = form(el, 'embed')!
fill(embedForm.querySelector('input'), 'https://vimeo.com/1')
click(embedForm.querySelector('[data-action="insert"]'))
expect(form(el, 'embed')).not.toBeNull()
expect(embedForm.querySelector('[data-error]')!.textContent).toBe('This video URL is not supported. Paste a YouTube link.')
expect(el.value).toBe('')
fill(embedForm.querySelector('input'), 'https://youtu.be/dQw4w9WgXcQ')
click(embedForm.querySelector('[data-action="insert"]'))
expect(form(el, 'embed')).toBeNull()
expect(el.value).toBe('<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>')
})
})
describe('image uploads', () => {
function pick(el: HTMLElement, files: File[]): void {
const input = el.querySelector<HTMLInputElement>('input[type="file"]')!
expect(input.accept).toBe('image/jpeg,image/png,image/gif,image/webp')
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 }))
}
const png = () => new File([new Uint8Array([137, 80, 78, 71])], 'grzyb.png', { type: 'image/png' })
// IN-04: over plain HTTP from a host other than localhost the page is not
// a secure context and crypto.randomUUID does not exist.
it('makes upload ids without crypto.randomUUID outside a secure context', () => {
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
const real = globalThis.crypto
try {
vi.stubGlobal('crypto', { getRandomValues: real.getRandomValues.bind(real) })
const ids = [uploadRequestId(), uploadRequestId()]
expect(ids[0]).toMatch(uuid)
expect(ids[1]).toMatch(uuid)
expect(ids[0]).not.toBe(ids[1])
const { el, uploads } = mount('')
click(button(el, 'image'))
pick(el, [png()])
expect(uploads).toHaveLength(1)
expect(uploads[0]!.id).toMatch(uuid)
expect(status(el)).toBe('Uploading grzyb.png…')
answer(el, { id: uploads[0]!.id, url: '/storage/app/uploads/public/journal/grzyb.png' })
expect(form(el, 'image')).not.toBeNull()
vi.stubGlobal('crypto', undefined)
const plain = [uploadRequestId(), uploadRequestId()]
expect(plain[0]).toMatch(/^upload-[0-9a-z]+-\d+$/)
expect(plain[0]).not.toBe(plain[1])
} finally {
vi.unstubAllGlobals()
}
expect(globalThis.crypto).toBe(real)
})
it('sends a picked image as summer-upload and inserts img with alt text', () => {
const { el, uploads } = mount('')
click(button(el, 'image'))
pick(el, [png()])
expect(uploads).toHaveLength(1)
expect(uploads[0]!.file.name).toBe('grzyb.png')
expect(uploads[0]!.id).toMatch(/[0-9a-f-]{36}/)
expect(status(el)).toBe('Uploading grzyb.png…')
answer(el, { id: 'someone-else', url: '/x.png' })
expect(form(el, 'image')).toBeNull()
answer(el, { id: uploads[0]!.id, url: '/storage/app/uploads/public/journal/grzyb.png' })
const imageForm = form(el, 'image')!
click(imageForm.querySelector('[data-action="insert"]'))
expect(imageForm.querySelector('[data-error]')!.textContent).toBe('Enter the alt text.')
expect(el.value).toBe('')
fill(imageForm.querySelector('input[name="alt"]'), 'Grzyb: czy to "Turkey Tail"?')
click(imageForm.querySelector('[data-action="insert"]'))
expect(form(el, 'image')).toBeNull()
expect(el.value).toBe('<p><img src="/storage/app/uploads/public/journal/grzyb.png" alt="Grzyb: czy to &quot;Turkey Tail&quot;?"></p>')
})
it('inserts figure with figcaption when a caption is given', () => {
const { el, uploads } = mount('')
pick(el, [png()])
answer(el, { id: uploads[0]!.id, url: '/a.png' })
const imageForm = form(el, 'image')!
fill(imageForm.querySelector('input[name="alt"]'), 'Owocniki')
fill(imageForm.querySelector('input[name="caption"]'), 'Owocniki na pniu.')
click(imageForm.querySelector('[data-action="insert"]'))
expect(el.value).toBe('<figure><img src="/a.png" alt="Owocniki"><figcaption>Owocniki na pniu.</figcaption></figure>')
})
it('shows an upload error and inserts nothing', () => {
const { el, uploads, changes } = mount('<p>a</p>')
pick(el, [png()])
answer(el, { id: uploads[0]!.id, error: 'File too large' })
expect(form(el, 'image')).toBeNull()
expect(status(el)).toBe('Upload failed: File too large')
expect(el.value).toBe('<p>a</p>')
expect(changes).toEqual([])
})
it('ends a pending upload after 60 seconds', () => {
vi.useFakeTimers()
const { el, uploads } = mount('')
pick(el, [png()])
vi.advanceTimersByTime(60_000)
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()
})
it('uploads image files dropped on the editor', () => {
const { el, editor, uploads } = mount('<p>a</p>')
let prevented = false
const event = {
dataTransfer: { files: [png(), new File(['x'], 'notes.txt', { type: 'text/plain' })] },
preventDefault: () => {
prevented = true
},
} as unknown as DragEvent
const handled = editor.view.someProp('handleDrop', (f) => f(editor.view, event, editor.state.doc.slice(0, 0), false))
expect(handled).toBe(true)
expect(prevented).toBe(true)
expect(uploads.map((u) => u.file.name)).toEqual(['grzyb.png'])
expect(el.value).toBe('<p>a</p>')
})
})
describe('links and the URL rule', () => {
it('accepts only http, https, mailto and relative URLs', () => {
for (const url of ['http://a.test', 'https://a.test/x?y#z', 'HTTPS://A.TEST', 'mailto:a@b.test', '/rel', 'rel/path', '#frag', '?q=1', '']) {
expect(isPolicyUrl(url), url).toBe(true)
}
for (const url of [
'javascript:alert(1)',
'JavaScript:alert(1)',
' javascript:alert(1)',
'java\tscript:alert(1)',
'java\nscript:alert(1)',
'vbscript:msgbox(1)',
'data:text/html,<b>x</b>',
'tel:+48123',
'ftp://a.test/',
]) {
expect(isPolicyUrl(url), JSON.stringify(url)).toBe(false)
}
})
it('refuses links outside the rule in commands and in stored HTML', () => {
const { el, editor } = mount('<p>text</p><p><a href="javascript:alert(1)">bad</a></p>')
expect(el.querySelector('.ProseMirror a')).toBeNull()
editor.commands.setTextSelection({ from: 1, to: 5 })
expect(editor.commands.setLink({ href: 'javascript:alert(1)' })).toBe(false)
expect(editor.commands.setLink({ href: 'tel:+48123' })).toBe(false)
expect(editor.commands.setLink({ href: 'java\tscript:alert(1)' })).toBe(false)
})
it('creates, refuses and removes links through the toolbar input', () => {
const { el, editor } = mount('<p>link</p>')
editor.commands.setTextSelection({ from: 1, to: 5 })
click(button(el, 'link'))
const linkForm = form(el, 'link')!
fill(linkForm.querySelector('input'), 'data:text/html,x')
click(linkForm.querySelector('[data-action="apply"]'))
expect(linkForm.querySelector('[data-error]')!.textContent).toBe('Links must use http, https, mailto or a relative URL.')
expect(el.value).toBe('<p>link</p>')
fill(linkForm.querySelector('input'), 'mailto:redakcja@example.test')
click(linkForm.querySelector('[data-action="apply"]'))
expect(el.value).toBe('<p><a href="mailto:redakcja@example.test">link</a></p>')
editor.commands.setTextSelection(2)
click(button(el, 'link'))
const again = form(el, 'link')!
expect((again.querySelector('input') as HTMLInputElement).value).toBe('mailto:redakcja@example.test')
fill(again.querySelector('input'), '')
click(again.querySelector('[data-action="apply"]'))
expect(el.value).toBe('<p>link</p>')
})
})
describe('toolbar', () => {
it('offers every D-04 feature and the tabs', () => {
const { el } = mount('')
for (const command of [
'paragraph',
'heading2',
'heading3',
'heading4',
'bold',
'italic',
'bulletList',
'orderedList',
'link',
'blockquote',
'insertTable',
'addRow',
'deleteRow',
'addColumn',
'deleteColumn',
'deleteTable',
'image',
'embed',
'rawHtml',
'undo',
'redo',
]) {
expect(button(el, command).getAttribute('aria-label'), command).toBeTruthy()
}
expect(el.querySelector('[data-tab="wysiwyg"]')!.textContent).toBe('WYSIWYG')
expect(el.querySelector('[data-tab="source"]')!.textContent).toBe('Source')
})
it('uses the Polish labels for ui-locale pl', () => {
const { el } = mount('', { 'ui-locale': 'pl' })
expect(el.querySelector('[data-tab="source"]')!.textContent).toBe('Źródło')
expect(button(el, 'bold').getAttribute('aria-label')).toBe('Pogrubienie')
})
it('formats text and edits tables from the toolbar', () => {
const { el, editor } = mount('<p>abc</p>')
editor.commands.setTextSelection({ from: 1, to: 4 })
click(button(el, 'bold'))
expect(el.value).toBe('<p><strong>abc</strong></p>')
expect(button(el, 'bold').getAttribute('aria-pressed')).toBe('true')
click(button(el, 'undo'))
expect(el.value).toBe('<p>abc</p>')
click(button(el, 'redo'))
expect(el.value).toBe('<p><strong>abc</strong></p>')
click(button(el, 'heading3'))
expect(el.value).toBe('<h3><strong>abc</strong></h3>')
editor.commands.setTextSelection(4)
click(button(el, 'insertTable'))
expect(el.value).toContain('<table><thead><tr><th></th><th></th><th></th></tr></thead>')
click(button(el, 'addRow'))
click(button(el, 'deleteColumn'))
expect(el.value.match(/<tr>/g)).toHaveLength(4)
expect(el.value).toContain('<tr><th></th><th></th></tr>')
click(button(el, 'deleteTable'))
expect(el.value).not.toContain('<table')
})
it('inserts a raw HTML block and opens the source tab at it', () => {
const { el, editor } = mount('<p>a</p>')
editor.commands.setTextSelection(2)
click(button(el, 'rawHtml'))
const textarea = el.querySelector('textarea')!
expect(textarea.hidden).toBe(false)
expect(el.value).toBe('<p>a</p><div></div>')
expect(textarea.value).toBe('<p>a</p><div></div>')
expect(textarea.selectionStart).toBe('<p>a</p><div>'.length)
})
it('edits a raw HTML card in the source tab', () => {
const { el } = mount('<p>a</p>\n<footer>f</footer>')
click(el.querySelector('.blogeditor__raw button'))
const textarea = el.querySelector('textarea')!
expect(textarea.hidden).toBe(false)
expect(textarea.value.slice(textarea.selectionStart, textarea.selectionEnd)).toBe('<footer>f</footer>')
})
})