mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2026-02-06 09:55:47 +00:00
test(tiptap): add Cypress component tests for TipTap extensions
This commit is contained in:
parent
64b6e60951
commit
5e2d19fb46
@ -6,6 +6,6 @@ export default defineConfig({
|
||||
framework: 'react',
|
||||
bundler: 'vite',
|
||||
},
|
||||
specPattern: ['**/**/*.cy.{ts,tsx}'],
|
||||
specPattern: 'cypress/component/**/*.cy.{ts,tsx}',
|
||||
},
|
||||
})
|
||||
|
||||
172
lib/cypress/component/TipTap/Hashtag.cy.tsx
Normal file
172
lib/cypress/component/TipTap/Hashtag.cy.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
/// <reference types="cypress" />
|
||||
import { mount } from 'cypress/react'
|
||||
|
||||
import { TestEditorWithRouter, createTestTag } from './TestEditor'
|
||||
|
||||
import type { Editor } from '@tiptap/core'
|
||||
|
||||
describe('Hashtag Extension', () => {
|
||||
describe('Hashtag Parsing', () => {
|
||||
it('parses simple hashtag in markdown', () => {
|
||||
const content = 'Hello #world'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} />)
|
||||
|
||||
cy.get('.hashtag').should('exist')
|
||||
cy.get('.hashtag').should('contain.text', '#world')
|
||||
})
|
||||
|
||||
it('parses multiple hashtags', () => {
|
||||
const content = '#one #two #three'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} />)
|
||||
|
||||
cy.get('.hashtag').should('have.length', 3)
|
||||
})
|
||||
|
||||
it('parses hashtag with numbers', () => {
|
||||
const content = 'Check out #tag123'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} />)
|
||||
|
||||
cy.get('.hashtag').should('contain.text', '#tag123')
|
||||
})
|
||||
|
||||
// Note: The regex in Hashtag.tsx includes underscores: /^#([a-zA-Z0-9À-ÖØ-öø-ʸ_-]+)/
|
||||
it('parses hashtag with underscore', () => {
|
||||
const content = 'See #my_tag here'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} />)
|
||||
|
||||
// decodeTag() converts underscores to non-breaking spaces (\u00A0) for display
|
||||
cy.get('.hashtag').should('exist')
|
||||
cy.get('.hashtag').should('contain.text', '#my\u00A0tag')
|
||||
})
|
||||
|
||||
it('parses hashtag with hyphen', () => {
|
||||
const content = 'Find #my-tag'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} />)
|
||||
|
||||
cy.get('.hashtag').should('contain.text', '#my-tag')
|
||||
})
|
||||
|
||||
it('parses unicode hashtag (German umlauts)', () => {
|
||||
const content = 'Visit #München'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} />)
|
||||
|
||||
cy.get('.hashtag').should('contain.text', '#München')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hashtag Styling', () => {
|
||||
it('applies color from known tag', () => {
|
||||
const tags = [createTestTag('nature', '#22C55E')]
|
||||
const content = 'Love #nature'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} tags={tags} />)
|
||||
|
||||
cy.get('.hashtag')
|
||||
.should('have.css', 'color')
|
||||
.and('match', /rgb\(34, 197, 94\)|#22[cC]55[eE]/)
|
||||
})
|
||||
|
||||
it('uses inherit color for unknown tag', () => {
|
||||
const content = 'Unknown #sometag'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} tags={[]} />)
|
||||
|
||||
cy.get('.hashtag').should('exist')
|
||||
// Unknown tags should use inherit
|
||||
cy.get('.hashtag').should('have.css', 'color')
|
||||
})
|
||||
|
||||
it('has bold font weight', () => {
|
||||
const content = '#test'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} />)
|
||||
|
||||
cy.get('.hashtag').should('have.class', 'tw:font-bold')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hashtag Click Behavior', () => {
|
||||
it('calls onTagClick when clicked in view mode', () => {
|
||||
const tags = [createTestTag('clickme', '#3B82F6')]
|
||||
const onTagClick = cy.stub().as('tagClickHandler')
|
||||
const content = '#clickme'
|
||||
|
||||
mount(
|
||||
<TestEditorWithRouter
|
||||
content={content}
|
||||
tags={tags}
|
||||
onTagClick={onTagClick}
|
||||
editable={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
cy.get('.hashtag').click()
|
||||
cy.get('@tagClickHandler').should('have.been.calledOnce')
|
||||
})
|
||||
|
||||
it('does NOT call onTagClick when clicked in edit mode', () => {
|
||||
const tags = [createTestTag('clickme', '#3B82F6')]
|
||||
const onTagClick = cy.stub().as('tagClickHandler')
|
||||
const content = '#clickme'
|
||||
|
||||
mount(
|
||||
<TestEditorWithRouter
|
||||
content={content}
|
||||
tags={tags}
|
||||
onTagClick={onTagClick}
|
||||
editable={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
cy.get('.hashtag').click()
|
||||
cy.get('@tagClickHandler').should('not.have.been.called')
|
||||
})
|
||||
|
||||
it('shows pointer cursor in view mode', () => {
|
||||
const content = '#test'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} editable={false} />)
|
||||
|
||||
cy.get('.hashtag').should('have.css', 'cursor', 'pointer')
|
||||
})
|
||||
|
||||
it('shows text cursor in edit mode', () => {
|
||||
const content = '#test'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} editable={true} />)
|
||||
|
||||
cy.get('.hashtag').should('have.css', 'cursor', 'text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Markdown Serialization (Roundtrip)', () => {
|
||||
it('serializes hashtag back to markdown', () => {
|
||||
const content = 'Hello #world'
|
||||
let editorInstance: Editor
|
||||
|
||||
mount(
|
||||
<TestEditorWithRouter
|
||||
content={content}
|
||||
onReady={(editor) => {
|
||||
editorInstance = editor
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
cy.get('.hashtag')
|
||||
.should('exist')
|
||||
.then(() => {
|
||||
// TipTap with Markdown extension exposes getMarkdown() directly on editor
|
||||
const markdown = editorInstance.getMarkdown()
|
||||
expect(markdown).to.include('#world')
|
||||
return null
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
168
lib/cypress/component/TipTap/ItemMention.cy.tsx
Normal file
168
lib/cypress/component/TipTap/ItemMention.cy.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
/// <reference types="cypress" />
|
||||
import { mount } from 'cypress/react'
|
||||
|
||||
import { TestEditorWithRouter, createTestItem } from './TestEditor'
|
||||
|
||||
import type { Editor } from '@tiptap/core'
|
||||
|
||||
describe('ItemMention Extension', () => {
|
||||
describe('Item Mention Parsing', () => {
|
||||
// Note: Item IDs are UUIDs (hex format: [a-fA-F0-9-])
|
||||
// Example real UUID: "144e379c-b719-4334-9f0e-de277d3b6d0f"
|
||||
|
||||
it('parses standard item mention with UUID', () => {
|
||||
// Using realistic UUID format
|
||||
const content = 'Thanks [@Alice](/item/144e379c-b719-4334-9f0e-de277d3b6d0f)'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} />)
|
||||
|
||||
cy.get('.item-mention').should('exist')
|
||||
cy.get('.item-mention').should('contain.text', '@Alice')
|
||||
})
|
||||
|
||||
it('parses item mention with layer path (legacy format)', () => {
|
||||
// The regex: /^\[@([^\]]+?)\]\(\/item\/(?:[^/]+\/)?([a-fA-F0-9-]+)\)/
|
||||
// Captures layer name as optional non-capturing group, then hex UUID
|
||||
const content = 'See [@Bob](/item/people/efe00aaa-8b14-47b5-a032-3e0560980c1e)'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} />)
|
||||
|
||||
cy.get('.item-mention').should('exist')
|
||||
cy.get('.item-mention').should('contain.text', '@Bob')
|
||||
})
|
||||
|
||||
it('parses multiple item mentions', () => {
|
||||
const content = '[@Alice](/item/aaa-111) and [@Bob](/item/bbb-222)'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} />)
|
||||
|
||||
cy.get('.item-mention').should('have.length', 2)
|
||||
})
|
||||
|
||||
it('parses item mention with spaces in label', () => {
|
||||
const content = 'Contact [@Max Müller](/item/abc-123-def)'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} />)
|
||||
|
||||
cy.get('.item-mention').should('contain.text', '@Max Müller')
|
||||
})
|
||||
|
||||
it('parses item mention with uppercase UUID', () => {
|
||||
// UUIDs are case-insensitive, regex uses [a-fA-F0-9-]
|
||||
const content = '[@Name](/item/ABC-DEF-123)'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} />)
|
||||
|
||||
cy.get('.item-mention').should('exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Item Mention Styling', () => {
|
||||
it('applies color from known item', () => {
|
||||
const items = [createTestItem('abc-123', 'Alice', '#EF4444')]
|
||||
const content = '[@Alice](/item/abc-123)'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} items={items} />)
|
||||
|
||||
cy.get('.item-mention')
|
||||
.should('have.css', 'color')
|
||||
.and('match', /rgb\(239, 68, 68\)|#[eE][fF]4444/)
|
||||
})
|
||||
|
||||
it('uses getItemColor function when provided', () => {
|
||||
const items = [createTestItem('abc-123', 'Alice')]
|
||||
const getItemColor = () => '#8B5CF6' // Purple
|
||||
const content = '[@Alice](/item/abc-123)'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} items={items} getItemColor={getItemColor} />)
|
||||
|
||||
cy.get('.item-mention')
|
||||
.should('have.css', 'color')
|
||||
.and('match', /rgb\(139, 92, 246\)|#8[bB]5[cC][fF]6/)
|
||||
})
|
||||
|
||||
it('uses fallback color for unknown item', () => {
|
||||
// UUID must be hex characters only: [a-fA-F0-9-]
|
||||
const content = '[@Unknown](/item/aaa-bbb-ccc-111)'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} items={[]} />)
|
||||
|
||||
// Should still render, using fallback color
|
||||
cy.get('.item-mention').should('exist')
|
||||
})
|
||||
|
||||
it('has bold font weight', () => {
|
||||
const content = '[@Test](/item/123)'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} />)
|
||||
|
||||
cy.get('.item-mention').should('have.class', 'tw:font-bold')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Item Mention Click Behavior', () => {
|
||||
it('shows pointer cursor in view mode', () => {
|
||||
const content = '[@Alice](/item/123)'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} editable={false} />)
|
||||
|
||||
cy.get('.item-mention').should('have.css', 'cursor', 'pointer')
|
||||
})
|
||||
|
||||
it('shows text cursor in edit mode', () => {
|
||||
const content = '[@Alice](/item/123)'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} editable={true} />)
|
||||
|
||||
cy.get('.item-mention').should('have.css', 'cursor', 'text')
|
||||
})
|
||||
|
||||
// Note: Navigation behavior uses react-router's useNavigate
|
||||
// Full navigation testing would require more complex setup
|
||||
})
|
||||
|
||||
describe('Markdown Serialization (Roundtrip)', () => {
|
||||
it('serializes item mention back to markdown', () => {
|
||||
const content = '[@Alice](/item/abc-123)'
|
||||
let editorInstance: Editor
|
||||
|
||||
mount(
|
||||
<TestEditorWithRouter
|
||||
content={content}
|
||||
onReady={(editor) => {
|
||||
editorInstance = editor
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
cy.get('.item-mention')
|
||||
.should('exist')
|
||||
.then(() => {
|
||||
const markdown = editorInstance.getMarkdown()
|
||||
expect(markdown).to.include('[@Alice](/item/abc-123)')
|
||||
return null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('does not parse regular links without @', () => {
|
||||
const content = '[Alice](/item/123)'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} />)
|
||||
|
||||
// Should be a regular link, not an item mention
|
||||
cy.get('.item-mention').should('not.exist')
|
||||
cy.get('a').should('contain.text', 'Alice')
|
||||
})
|
||||
|
||||
it('does not parse @ links to non-item paths', () => {
|
||||
const content = '[@Alice](/profile/123)'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} />)
|
||||
|
||||
// Should be a regular link, not an item mention
|
||||
cy.get('.item-mention').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
106
lib/cypress/component/TipTap/TestEditor.tsx
Normal file
106
lib/cypress/component/TipTap/TestEditor.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import { Image } from '@tiptap/extension-image'
|
||||
import { Link } from '@tiptap/extension-link'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import { EditorContent, useEditor } from '@tiptap/react'
|
||||
import { StarterKit } from '@tiptap/starter-kit'
|
||||
import { useEffect } from 'react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
|
||||
import { Hashtag, ItemMention, VideoEmbed } from '#components/TipTap/extensions'
|
||||
import { createConfiguredMarked } from '#components/TipTap/utils/configureMarked'
|
||||
|
||||
import type { Item } from '#types/Item'
|
||||
import type { Tag } from '#types/Tag'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
|
||||
const configuredMarked = createConfiguredMarked()
|
||||
|
||||
export interface TestEditorProps {
|
||||
content: string
|
||||
editable?: boolean
|
||||
tags?: Tag[]
|
||||
onTagClick?: (tag: Tag) => void
|
||||
items?: Item[]
|
||||
getItemColor?: (item: Item | undefined, fallback?: string) => string
|
||||
onReady?: (editor: Editor) => void
|
||||
testId?: string
|
||||
}
|
||||
|
||||
export function TestEditor({
|
||||
content,
|
||||
editable = false,
|
||||
tags = [],
|
||||
onTagClick,
|
||||
items = [],
|
||||
getItemColor,
|
||||
onReady,
|
||||
testId = 'test-editor',
|
||||
}: TestEditorProps) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
bulletList: { keepMarks: true, keepAttributes: false },
|
||||
orderedList: { keepMarks: true, keepAttributes: false },
|
||||
}),
|
||||
Markdown.configure({
|
||||
marked: configuredMarked,
|
||||
}),
|
||||
Image,
|
||||
Link,
|
||||
VideoEmbed,
|
||||
Hashtag.configure({
|
||||
tags,
|
||||
onTagClick,
|
||||
}),
|
||||
ItemMention.configure({
|
||||
items,
|
||||
getItemColor,
|
||||
}),
|
||||
],
|
||||
content,
|
||||
contentType: 'markdown',
|
||||
editable,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- editor can be null initially
|
||||
if (editor && onReady) {
|
||||
onReady(editor)
|
||||
}
|
||||
}, [editor, onReady])
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- editor can be null initially
|
||||
if (!editor) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid={testId} className='test-editor-wrapper'>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TestEditorWithRouter(props: TestEditorProps) {
|
||||
return (
|
||||
<MemoryRouter>
|
||||
<TestEditor {...props} />
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
export function createTestTag(name: string, color = '#3B82F6'): Tag {
|
||||
return {
|
||||
id: `tag-${name}`,
|
||||
name,
|
||||
color,
|
||||
} as Tag
|
||||
}
|
||||
|
||||
export function createTestItem(id: string, name: string, color = '#10B981'): Item {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
color,
|
||||
} as Item
|
||||
}
|
||||
288
lib/cypress/component/TipTap/VideoEmbed.cy.tsx
Normal file
288
lib/cypress/component/TipTap/VideoEmbed.cy.tsx
Normal file
@ -0,0 +1,288 @@
|
||||
/// <reference types="cypress" />
|
||||
import { mount } from 'cypress/react'
|
||||
|
||||
import { TestEditorWithRouter } from './TestEditor'
|
||||
|
||||
import type { Editor } from '@tiptap/core'
|
||||
|
||||
describe('VideoEmbed Extension', () => {
|
||||
describe('YouTube Video Parsing', () => {
|
||||
it('parses YouTube standard URL autolink to video embed', () => {
|
||||
const content = '<https://www.youtube.com/watch?v=dQw4w9WgXcQ>'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} />)
|
||||
|
||||
// Should render as iframe, not as text
|
||||
cy.get('iframe.video-embed').should('exist')
|
||||
cy.get('iframe.video-embed')
|
||||
.should('have.attr', 'src')
|
||||
.and('include', 'youtube-nocookie.com/embed/dQw4w9WgXcQ')
|
||||
})
|
||||
|
||||
it('parses YouTube short URL autolink to video embed', () => {
|
||||
const content = '<https://youtu.be/dQw4w9WgXcQ>'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} />)
|
||||
|
||||
cy.get('iframe.video-embed').should('exist')
|
||||
cy.get('iframe.video-embed')
|
||||
.should('have.attr', 'src')
|
||||
.and('include', 'youtube-nocookie.com/embed/dQw4w9WgXcQ')
|
||||
})
|
||||
|
||||
it('handles YouTube URL with extra parameters', () => {
|
||||
const content = '<https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=120>'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} />)
|
||||
|
||||
cy.get('iframe.video-embed').should('exist')
|
||||
cy.get('iframe.video-embed').should('have.attr', 'src').and('include', 'dQw4w9WgXcQ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rumble Video Parsing', () => {
|
||||
it('parses Rumble embed URL autolink to video embed', () => {
|
||||
const content = '<https://rumble.com/embed/v1abc23>'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} />)
|
||||
|
||||
cy.get('iframe.video-embed').should('exist')
|
||||
cy.get('iframe.video-embed')
|
||||
.should('have.attr', 'src')
|
||||
.and('include', 'rumble.com/embed/v1abc23')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multiple Videos', () => {
|
||||
it('renders multiple video embeds correctly', () => {
|
||||
const content = `<https://youtu.be/video1234567>
|
||||
|
||||
<https://youtu.be/video7654321>`
|
||||
|
||||
mount(<TestEditorWithRouter content={content} />)
|
||||
|
||||
cy.get('iframe.video-embed').should('have.length', 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Markdown Serialization (Roundtrip)', () => {
|
||||
it('serializes video embed back to autolink markdown', () => {
|
||||
const content = '<https://www.youtube.com/watch?v=dQw4w9WgXcQ>'
|
||||
let editorInstance: Editor
|
||||
|
||||
mount(
|
||||
<TestEditorWithRouter
|
||||
content={content}
|
||||
onReady={(editor) => {
|
||||
editorInstance = editor
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
cy.get('iframe.video-embed')
|
||||
.should('exist')
|
||||
.then(() => {
|
||||
const markdown = editorInstance.getMarkdown()
|
||||
expect(markdown).to.include('<https://www.youtube.com/watch?v=dQw4w9WgXcQ>')
|
||||
return null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Non-Video URLs', () => {
|
||||
it('does not convert regular URLs to video embeds', () => {
|
||||
const content = '<https://example.com>'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} />)
|
||||
|
||||
cy.get('iframe.video-embed').should('not.exist')
|
||||
// Should render as a link instead
|
||||
cy.get('a').should('contain.text', 'example.com')
|
||||
})
|
||||
|
||||
it('does not convert non-embed Rumble URLs', () => {
|
||||
// Regular Rumble video page, not embed URL
|
||||
const content = '<https://rumble.com/v1abc23-some-video.html>'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} />)
|
||||
|
||||
cy.get('iframe.video-embed').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Video Embed Attributes', () => {
|
||||
it('renders with correct iframe attributes', () => {
|
||||
const content = '<https://youtu.be/dQw4w9WgXcQ>'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} />)
|
||||
|
||||
cy.get('iframe.video-embed').should('have.attr', 'allowfullscreen').and('exist')
|
||||
cy.get('iframe.video-embed').should('have.attr', 'allow').and('include', 'fullscreen')
|
||||
})
|
||||
|
||||
it('wraps video in container div', () => {
|
||||
const content = '<https://youtu.be/dQw4w9WgXcQ>'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} />)
|
||||
|
||||
cy.get('.video-embed-wrapper').should('exist')
|
||||
cy.get('.video-embed-wrapper').find('iframe.video-embed').should('exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Adding Video Below Existing Video', () => {
|
||||
it('user clicks after video and presses Enter - first video should remain', () => {
|
||||
const content = '<https://youtu.be/dQw4w9WgXcQ>'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} editable={true} />)
|
||||
|
||||
cy.get('iframe.video-embed').should('exist')
|
||||
cy.get('.ProseMirror').click()
|
||||
cy.get('.ProseMirror').type('{enter}')
|
||||
|
||||
cy.get('iframe.video-embed').should('exist')
|
||||
cy.get('iframe.video-embed').should('have.attr', 'src').and('include', 'dQw4w9WgXcQ')
|
||||
})
|
||||
|
||||
it('user clicks after video and types some text - first video should remain', () => {
|
||||
const content = '<https://youtu.be/dQw4w9WgXcQ>'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} editable={true} />)
|
||||
|
||||
cy.get('iframe.video-embed').should('exist')
|
||||
cy.get('.ProseMirror').click()
|
||||
cy.get('.ProseMirror').type('{enter}Some text after the video')
|
||||
|
||||
cy.get('iframe.video-embed').should('exist')
|
||||
cy.get('iframe.video-embed').should('have.attr', 'src').and('include', 'dQw4w9WgXcQ')
|
||||
})
|
||||
|
||||
it('user pastes a URL directly after video (no Enter) - first video should remain', () => {
|
||||
const content = '<https://youtu.be/dQw4w9WgXcQ>'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} editable={true} />)
|
||||
|
||||
cy.get('iframe.video-embed').should('exist')
|
||||
cy.get('.ProseMirror').click()
|
||||
cy.get('.ProseMirror').trigger('paste', {
|
||||
clipboardData: {
|
||||
getData: () => 'https://www.youtube.com/watch?v=abc123xyz99',
|
||||
},
|
||||
})
|
||||
|
||||
cy.get('iframe.video-embed').should('exist')
|
||||
cy.get('iframe.video-embed').first().should('have.attr', 'src').and('include', 'dQw4w9WgXcQ')
|
||||
})
|
||||
|
||||
it('user presses Enter then pastes a URL - first video should remain', () => {
|
||||
const content = '<https://youtu.be/dQw4w9WgXcQ>'
|
||||
|
||||
mount(<TestEditorWithRouter content={content} editable={true} />)
|
||||
|
||||
cy.get('iframe.video-embed').should('exist')
|
||||
cy.get('.ProseMirror').click()
|
||||
cy.get('.ProseMirror').type('{enter}')
|
||||
|
||||
cy.get('iframe.video-embed').should('exist')
|
||||
|
||||
cy.get('.ProseMirror').trigger('paste', {
|
||||
clipboardData: {
|
||||
getData: () => 'https://www.youtube.com/watch?v=abc123xyz99',
|
||||
},
|
||||
})
|
||||
|
||||
cy.get('iframe.video-embed').should('exist')
|
||||
cy.get('iframe.video-embed').first().should('have.attr', 'src').and('include', 'dQw4w9WgXcQ')
|
||||
})
|
||||
|
||||
it('preserves first video when setting content with two videos', () => {
|
||||
const content = '<https://youtu.be/dQw4w9WgXcQ>'
|
||||
let editorInstance: Editor | undefined
|
||||
|
||||
mount(
|
||||
<TestEditorWithRouter
|
||||
content={content}
|
||||
editable={true}
|
||||
onReady={(editor) => {
|
||||
editorInstance = editor
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
cy.get('iframe.video-embed')
|
||||
.should('exist')
|
||||
.should(() => expect(editorInstance).to.exist)
|
||||
.then(() => {
|
||||
if (!editorInstance) return null
|
||||
const newContent = `<https://youtu.be/dQw4w9WgXcQ>
|
||||
|
||||
<https://youtu.be/second12345>`
|
||||
editorInstance.commands.setContent(newContent, { contentType: 'markdown' })
|
||||
return null
|
||||
})
|
||||
|
||||
cy.get('iframe.video-embed').should('have.length', 2)
|
||||
})
|
||||
|
||||
it('verifies markdown roundtrip with two videos', () => {
|
||||
const content = `<https://youtu.be/dQw4w9WgXcQ>
|
||||
|
||||
<https://youtu.be/second12345>`
|
||||
let editorInstance: Editor | undefined
|
||||
|
||||
mount(
|
||||
<TestEditorWithRouter
|
||||
content={content}
|
||||
editable={true}
|
||||
onReady={(editor) => {
|
||||
editorInstance = editor
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
cy.get('iframe.video-embed')
|
||||
.should('have.length', 2)
|
||||
.should(() => expect(editorInstance).to.exist)
|
||||
.then(() => {
|
||||
if (!editorInstance) return null
|
||||
const markdown = editorInstance.getMarkdown()
|
||||
expect(markdown).to.include('dQw4w9WgXcQ')
|
||||
expect(markdown).to.include('second12345')
|
||||
return null
|
||||
})
|
||||
})
|
||||
|
||||
it('handles keyboard navigation: Arrow down from video creates new paragraph', () => {
|
||||
const content = '<https://youtu.be/dQw4w9WgXcQ>'
|
||||
let editorInstance: Editor | undefined
|
||||
|
||||
mount(
|
||||
<TestEditorWithRouter
|
||||
content={content}
|
||||
editable={true}
|
||||
onReady={(editor) => {
|
||||
editorInstance = editor
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
cy.get('iframe.video-embed')
|
||||
.should('exist')
|
||||
.should(() => expect(editorInstance).to.exist)
|
||||
.then(() => {
|
||||
if (!editorInstance) return null
|
||||
editorInstance.commands.focus('end')
|
||||
editorInstance
|
||||
.chain()
|
||||
.insertContentAt(editorInstance.state.doc.content.size, {
|
||||
type: 'paragraph',
|
||||
})
|
||||
.run()
|
||||
editorInstance.commands.setVideoEmbed({ provider: 'youtube', videoId: 'newvideo1234' })
|
||||
return null
|
||||
})
|
||||
|
||||
cy.get('iframe.video-embed').should('have.length', 2)
|
||||
})
|
||||
})
|
||||
})
|
||||
106
lib/cypress/component/TipTap/roundtrip.cy.tsx
Normal file
106
lib/cypress/component/TipTap/roundtrip.cy.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
/// <reference types="cypress" />
|
||||
import { mount } from 'cypress/react'
|
||||
|
||||
import { TestEditorWithRouter } from './TestEditor'
|
||||
|
||||
import type { Editor } from '@tiptap/core'
|
||||
|
||||
/**
|
||||
* Roundtrip tests verify that Markdown -> TipTap -> Markdown preserves
|
||||
* key content elements. Note: TipTap may normalize markdown (whitespace,
|
||||
* list markers, etc.) so we test for semantic preservation, not exact match.
|
||||
*/
|
||||
describe('Markdown Roundtrip Tests', () => {
|
||||
function testRoundtripContains(originalMarkdown: string, expectedPatterns: string[]) {
|
||||
let editorInstance: Editor | undefined
|
||||
|
||||
mount(
|
||||
<TestEditorWithRouter
|
||||
content={originalMarkdown}
|
||||
onReady={(editor) => {
|
||||
editorInstance = editor
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
cy.get('[data-testid="test-editor"]')
|
||||
.should('exist')
|
||||
.should(() => expect(editorInstance).to.exist)
|
||||
.then(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const markdown = editorInstance!.getMarkdown()
|
||||
for (const pattern of expectedPatterns) {
|
||||
expect(markdown).to.include(pattern)
|
||||
}
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
describe('Custom Extensions Roundtrip', () => {
|
||||
// These are the critical tests - our custom extensions must serialize correctly
|
||||
|
||||
it('hashtag: #tag is preserved', () => {
|
||||
testRoundtripContains('Hello #world', ['#world'])
|
||||
})
|
||||
|
||||
it('hashtag: multiple hashtags preserved', () => {
|
||||
testRoundtripContains('#one #two #three', ['#one', '#two', '#three'])
|
||||
})
|
||||
|
||||
it('item mention: preserved with correct format', () => {
|
||||
// Using hex UUID as required by the regex
|
||||
testRoundtripContains('Thanks [@Alice](/item/abc-123-def)', ['[@Alice](/item/abc-123-def)'])
|
||||
})
|
||||
|
||||
it('item mention: with spaces in name', () => {
|
||||
testRoundtripContains('Contact [@Max Müller](/item/abc-123)', [
|
||||
'[@Max Müller](/item/abc-123)',
|
||||
])
|
||||
})
|
||||
|
||||
it('video embed: YouTube autolink preserved', () => {
|
||||
testRoundtripContains('<https://www.youtube.com/watch?v=dQw4w9WgXcQ>', [
|
||||
'<https://www.youtube.com/watch?v=dQw4w9WgXcQ>',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Standard Markdown Roundtrip', () => {
|
||||
it('plain text preserved', () => {
|
||||
testRoundtripContains('Hello, world!', ['Hello, world!'])
|
||||
})
|
||||
|
||||
it('bold text preserved', () => {
|
||||
testRoundtripContains('This is **bold** text.', ['**bold**'])
|
||||
})
|
||||
|
||||
it('italic text preserved', () => {
|
||||
testRoundtripContains('This is *italic* text.', ['*italic*'])
|
||||
})
|
||||
|
||||
it('inline code preserved', () => {
|
||||
testRoundtripContains('Use `code` here.', ['`code`'])
|
||||
})
|
||||
|
||||
it('links preserved', () => {
|
||||
testRoundtripContains('Visit [Example](https://example.com).', [
|
||||
'[Example](https://example.com)',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Complex Content Roundtrip', () => {
|
||||
it('mixed content with all custom extensions', () => {
|
||||
const content = 'Hello #world! Thanks [@Alice](/item/abc-123) for helping.'
|
||||
testRoundtripContains(content, ['#world', '[@Alice](/item/abc-123)', 'Hello', 'for helping'])
|
||||
})
|
||||
|
||||
it('formatting with hashtags', () => {
|
||||
testRoundtripContains('**Bold** text with #hashtag and *italic*.', [
|
||||
'**Bold**',
|
||||
'#hashtag',
|
||||
'*italic*',
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -29,7 +29,8 @@
|
||||
"setupTest.ts",
|
||||
"cypress.config.ts",
|
||||
"cypress/support/commands.ts",
|
||||
"cypress/support/component.ts"
|
||||
"cypress/support/component.ts",
|
||||
"cypress/component"
|
||||
],
|
||||
"exclude": ["node_modules", "dist", "example"],
|
||||
"typeRoots": ["./src/types", "./node_modules/@types/"]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user