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('
Hello
') editor.view.dispatch(editor.state.tr.insertText('X', 1)) expect(changes).toHaveLength(1) expect(changes[0]).toBe(el.value) expect(el.value).toBe('XHello
') // The SPA echoes the emitted value back: no reset, no event. el.value = changes[0]! expect(changes).toHaveLength(1) expect(el.value).toBe('XHello
') }) 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 = 'Edited in source
\n' textarea.dispatchEvent(new Event('input')) expect(changes).toEqual(['Edited in source
\n']) expect(el.value).toBe('Edited in source
\n') // Back to WYSIWYG: re-parsed, but the value stays the exact text. el.showSource(false) expect(el.value).toBe('Edited in source
\n') expect(changes).toHaveLength(1) }) it('keeps an edited value after it is detached and attached again', () => { const { el, editor, changes } = mount('a
') editor.view.dispatch(editor.state.tr.insertText('X', 1)) el.remove() expect(el.value).toBe('Xa
') document.body.append(el) expect(el.value).toBe('Xa
') expect(el.editor!.state.doc.textContent).toBe('Xa') expect(changes).toEqual(['Xa
']) }) it('a new external value replaces the content without an event', () => { const { el, changes } = mount('One
') el.value = 'Two
' expect(el.value).toBe('Two
') 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('x
') 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 = [ '', 'Sekcja
Cytat
Start
\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(`XStart
\n${rawBlocks.join('\n')}\n`) }) it('renders a raw HTML card as text, never as live markup', () => { const payload = 'a
${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('
Tekst czerwony
' + 'Punkt
Trzy
' + '' + 'Cytat
| 1 |
XTekst czerwony
' + 'XPunkt
XTrzy
' + '' + 'XCytat
| X1 |
Bez zmian
' const html = `Edytowany
\n${kept}\n` const { el, editor } = mount(html) expect(el.value).toBe(html) insertAtBlock(editor, 0, 'X') expect(el.value).toBe(`XEdytowany
\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('Turkey Tail', '
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 = '
| A | B |
|---|---|
| 1 | 2 |
| XA | XB |
|---|---|
| X1 | X2 |
t
') 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('<\/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('
x y
Zx y
link
') fresh.editor.commands.setTextSelection({ from: 1, to: 5 }) expect(fresh.editor.commands.setLink({ href: 'https://example.test/' })).toBe(true) expect(fresh.el.value).toBe('') }) }) 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 = 'a
${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(`Xa
${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('

a
') 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('a
') 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('a
') 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('a
') }) }) 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,x', '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('text
') 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('link
') 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('link
') fill(linkForm.querySelector('input'), 'mailto:redakcja@example.test') click(linkForm.querySelector('[data-action="apply"]')) expect(el.value).toBe('') 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('link
') }) }) 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('abc
') editor.commands.setTextSelection({ from: 1, to: 4 }) click(button(el, 'bold')) expect(el.value).toBe('abc
') expect(button(el, 'bold').getAttribute('aria-pressed')).toBe('true') click(button(el, 'undo')) expect(el.value).toBe('abc
') click(button(el, 'redo')) expect(el.value).toBe('abc
') click(button(el, 'heading3')) expect(el.value).toBe('