test(tiptap): add Cypress component tests for TipTap extensions

This commit is contained in:
mahula 2026-01-15 18:21:56 +01:00
parent 64b6e60951
commit 5e2d19fb46
7 changed files with 843 additions and 2 deletions

View File

@ -6,6 +6,6 @@ export default defineConfig({
framework: 'react',
bundler: 'vite',
},
specPattern: ['**/**/*.cy.{ts,tsx}'],
specPattern: 'cypress/component/**/*.cy.{ts,tsx}',
},
})

View 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
})
})
})
})

View 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')
})
})
})

View 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
}

View 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)
})
})
})

View 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*',
])
})
})
})

View File

@ -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/"]