import { Editor } from '@tiptap/core' import Document from '@tiptap/extension-document' import Paragraph from '@tiptap/extension-paragraph' import Text from '@tiptap/extension-text' import Twitch from '@tiptap/extension-twitch' import { describe, expect, it } from 'vite-plus/test' describe('extension-twitch', () => { const editorElClass = 'tiptap' let editor: Editor | null = null const createEditorEl = () => { const editorEl = document.createElement('div') editorEl.classList.add(editorElClass) document.body.appendChild(editorEl) return editorEl } const getEditorEl = () => document.querySelector(`.${editorElClass}`) const invalidUrls = [ // We have to disable the oxlint rule here because we're trying to purposely test eval urls // oxlint-disable-next-line no-script-url 'javascript:alert(window.origin)//embed/', 'https://twitch.google.com/videos/1234567890', 'https://twitch.com.bad/videos/1234567890', 'https://www.twtich.tv/videos/1234567890', ] invalidUrls.forEach(url => { it(`does not output html for javascript schema or non-twitch links for url ${url}`, () => { editor = new Editor({ element: createEditorEl(), extensions: [Document, Text, Paragraph, Twitch], content: { type: 'doc', content: [ { type: 'twitch', attrs: { src: url, }, }, ], }, }) expect(editor.getHTML()).not.toContain(url) editor?.destroy() getEditorEl()?.remove() }) }) it('outputs html with iframe for valid twitch video id', () => { editor = new Editor({ element: createEditorEl(), extensions: [ Document, Text, Paragraph, Twitch.configure({ parent: 'example.com', }), ], content: { type: 'doc', content: [ { type: 'twitch', attrs: { src: 'https://www.twitch.tv/videos/1234567890', }, }, ], }, }) expect(editor.getHTML()).toContain('player.twitch.tv') expect(editor.getHTML()).toContain('1234567890') expect(editor.getHTML()).toContain('example.com') editor?.destroy() getEditorEl()?.remove() }) it('outputs html with iframe for twitch clip url', () => { editor = new Editor({ element: createEditorEl(), extensions: [ Document, Text, Paragraph, Twitch.configure({ parent: 'example.com', }), ], content: { type: 'doc', content: [ { type: 'twitch', attrs: { src: 'https://clips.twitch.tv/ExampleClipName-ABC123', }, }, ], }, }) expect(editor.getHTML()).toContain('clips.twitch.tv/embed') expect(editor.getHTML()).toContain('ExampleClipName-ABC123') expect(editor.getHTML()).toContain('example.com') editor?.destroy() getEditorEl()?.remove() }) it('outputs html with iframe for twitch channel url', () => { editor = new Editor({ element: createEditorEl(), extensions: [ Document, Text, Paragraph, Twitch.configure({ parent: 'example.com', }), ], content: { type: 'doc', content: [ { type: 'twitch', attrs: { src: 'https://www.twitch.tv/examplechannel', }, }, ], }, }) expect(editor.getHTML()).toContain('player.twitch.tv') expect(editor.getHTML()).toContain('examplechannel') editor?.destroy() getEditorEl()?.remove() }) it('respects allowFullscreen option', () => { editor = new Editor({ element: createEditorEl(), extensions: [ Document, Text, Paragraph, Twitch.configure({ parent: 'example.com', allowFullscreen: true, }), ], content: { type: 'doc', content: [ { type: 'twitch', attrs: { src: 'https://www.twitch.tv/videos/1234567890', }, }, ], }, }) const html = editor.getHTML() expect(html).toContain('allowfullscreen="true"') editor?.destroy() getEditorEl()?.remove() }) it('respects custom width and height', () => { editor = new Editor({ element: createEditorEl(), extensions: [ Document, Text, Paragraph, Twitch.configure({ parent: 'example.com', width: 800, height: 600, }), ], content: { type: 'doc', content: [ { type: 'twitch', attrs: { src: 'https://www.twitch.tv/videos/1234567890', width: 800, height: 600, }, }, ], }, }) const html = editor.getHTML() expect(html).toContain('width="800"') expect(html).toContain('height="600"') editor?.destroy() getEditorEl()?.remove() }) it('can insert a video using the setTwitchVideo command', () => { editor = new Editor({ element: createEditorEl(), extensions: [ Document, Text, Paragraph, Twitch.configure({ parent: 'example.com', }), ], }) editor.commands.setTwitchVideo({ src: 'https://www.twitch.tv/videos/1234567890', }) expect(editor.getHTML()).toContain('data-twitch-video') editor?.destroy() getEditorEl()?.remove() }) it('does not insert video with invalid URL using setTwitchVideo command', () => { editor = new Editor({ element: createEditorEl(), extensions: [Document, Text, Paragraph, Twitch], }) const result = editor.commands.setTwitchVideo({ src: 'https://invalid.com/video', }) expect(result).toBe(false) editor?.destroy() getEditorEl()?.remove() }) it('handles video URL with search parameters', () => { editor = new Editor({ element: createEditorEl(), extensions: [ Document, Text, Paragraph, Twitch.configure({ parent: 'example.com', }), ], content: { type: 'doc', content: [ { type: 'twitch', attrs: { src: 'https://www.twitch.tv/videos/1234567890?filter=archives&sort=time', }, }, ], }, }) expect(editor.getHTML()).toContain('player.twitch.tv') expect(editor.getHTML()).toContain('1234567890') expect(editor.getHTML()).not.toContain('filter=archives') editor?.destroy() getEditorEl()?.remove() }) it('handles clip URL with search parameters', () => { editor = new Editor({ element: createEditorEl(), extensions: [ Document, Text, Paragraph, Twitch.configure({ parent: 'example.com', }), ], content: { type: 'doc', content: [ { type: 'twitch', attrs: { src: 'https://clips.twitch.tv/ExampleClipName-ABC123?foo=bar&baz=qux', }, }, ], }, }) expect(editor.getHTML()).toContain('clips.twitch.tv/embed') expect(editor.getHTML()).toContain('ExampleClipName-ABC123') expect(editor.getHTML()).not.toContain('foo=bar') editor?.destroy() getEditorEl()?.remove() }) it('respects muted option', () => { editor = new Editor({ element: createEditorEl(), extensions: [ Document, Text, Paragraph, Twitch.configure({ parent: 'example.com', muted: true, }), ], content: { type: 'doc', content: [ { type: 'twitch', attrs: { src: 'https://www.twitch.tv/videos/1234567890', }, }, ], }, }) const html = editor.getHTML() expect(html).toContain('muted=true') editor?.destroy() getEditorEl()?.remove() }) it('respects time option for videos', () => { editor = new Editor({ element: createEditorEl(), extensions: [ Document, Text, Paragraph, Twitch.configure({ parent: 'example.com', time: '1h2m3s', }), ], content: { type: 'doc', content: [ { type: 'twitch', attrs: { src: 'https://www.twitch.tv/videos/1234567890', }, }, ], }, }) const html = editor.getHTML() expect(html).toContain('time=1h2m3s') editor?.destroy() getEditorEl()?.remove() }) it('respects attribute overrides for autoplay', () => { editor = new Editor({ element: createEditorEl(), extensions: [ Document, Text, Paragraph, Twitch.configure({ parent: 'example.com', autoplay: true, }), ], content: { type: 'doc', content: [ { type: 'twitch', attrs: { src: 'https://www.twitch.tv/videos/1234567890', autoplay: false, }, }, ], }, }) const html = editor.getHTML() // Should NOT have autoplay=true since attribute overrides option expect(html).not.toContain('autoplay=true') editor?.destroy() getEditorEl()?.remove() }) it('respects attribute overrides for muted', () => { editor = new Editor({ element: createEditorEl(), extensions: [ Document, Text, Paragraph, Twitch.configure({ parent: 'example.com', muted: false, }), ], content: { type: 'doc', content: [ { type: 'twitch', attrs: { src: 'https://www.twitch.tv/videos/1234567890', muted: true, }, }, ], }, }) const html = editor.getHTML() expect(html).toContain('muted=true') editor?.destroy() getEditorEl()?.remove() }) it('respects attribute overrides for time', () => { editor = new Editor({ element: createEditorEl(), extensions: [ Document, Text, Paragraph, Twitch.configure({ parent: 'example.com', time: '1h0m0s', }), ], content: { type: 'doc', content: [ { type: 'twitch', attrs: { src: 'https://www.twitch.tv/videos/1234567890', time: '2h30m15s', }, }, ], }, }) const html = editor.getHTML() expect(html).toContain('time=2h30m15s') expect(html).not.toContain('1h0m0s') editor?.destroy() getEditorEl()?.remove() }) it.each([ 'https://www.twitch.tv/videos/1234567890', 'https://clips.twitch.tv/ExampleClipName-ABC123', 'https://www.twitch.tv/examplechannel', ])( 'preserves the twitch src when content is loaded back from rendered HTML for %s', originalSrc => { editor = new Editor({ element: createEditorEl(), extensions: [ Document, Text, Paragraph, Twitch.configure({ parent: 'example.com', }), ], content: { type: 'doc', content: [ { type: 'twitch', attrs: { src: originalSrc, }, }, ], }, }) const html = editor.getHTML() editor.destroy() getEditorEl()?.remove() editor = new Editor({ element: createEditorEl(), extensions: [ Document, Text, Paragraph, Twitch.configure({ parent: 'example.com', }), ], content: html, }) expect(editor.getJSON()).toMatchObject({ type: 'doc', content: [ { type: 'twitch', attrs: { src: originalSrc, }, }, ], }) editor?.destroy() getEditorEl()?.remove() }, ) it('does not persist NaN width or height when parsing iframe dimensions from HTML', () => { editor = new Editor({ element: createEditorEl(), extensions: [ Document, Text, Paragraph, Twitch.configure({ parent: 'example.com', }), ], content: '
', }) const attrs = editor.getJSON().content?.[0]?.attrs ?? {} const html = editor.getHTML() expect(Number.isNaN(attrs.width)).toBe(false) expect(Number.isNaN(attrs.height)).toBe(false) expect(html).not.toContain('width="NaN"') expect(html).not.toContain('height="NaN"') editor?.destroy() getEditorEl()?.remove() }) it('renders invalid Twitch content without crashing when parsed HTML contains an unsupported src', () => { editor = new Editor({ element: createEditorEl(), extensions: [Document, Text, Paragraph, Twitch], content: '', }) expect(() => editor?.getHTML()).not.toThrow() expect(editor.getHTML()).toContain('Invalid Twitch URL') editor?.destroy() getEditorEl()?.remove() }) })