// 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

') }) })