diff --git a/lib/cypress.config.ts b/lib/cypress.config.ts index 5db7fe24..b0334c4e 100644 --- a/lib/cypress.config.ts +++ b/lib/cypress.config.ts @@ -6,6 +6,6 @@ export default defineConfig({ framework: 'react', bundler: 'vite', }, - specPattern: ['**/**/*.cy.{ts,tsx}'], + specPattern: 'cypress/component/**/*.cy.{ts,tsx}', }, }) diff --git a/lib/cypress/component/TipTap/Hashtag.cy.tsx b/lib/cypress/component/TipTap/Hashtag.cy.tsx new file mode 100644 index 00000000..83753c07 --- /dev/null +++ b/lib/cypress/component/TipTap/Hashtag.cy.tsx @@ -0,0 +1,172 @@ +/// +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() + + cy.get('.hashtag').should('exist') + cy.get('.hashtag').should('contain.text', '#world') + }) + + it('parses multiple hashtags', () => { + const content = '#one #two #three' + + mount() + + cy.get('.hashtag').should('have.length', 3) + }) + + it('parses hashtag with numbers', () => { + const content = 'Check out #tag123' + + mount() + + 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() + + // 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() + + cy.get('.hashtag').should('contain.text', '#my-tag') + }) + + it('parses unicode hashtag (German umlauts)', () => { + const content = 'Visit #München' + + mount() + + 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() + + 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() + + 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() + + 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( + , + ) + + 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( + , + ) + + cy.get('.hashtag').click() + cy.get('@tagClickHandler').should('not.have.been.called') + }) + + it('shows pointer cursor in view mode', () => { + const content = '#test' + + mount() + + cy.get('.hashtag').should('have.css', 'cursor', 'pointer') + }) + + it('shows text cursor in edit mode', () => { + const content = '#test' + + mount() + + 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( + { + 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 + }) + }) + }) +}) diff --git a/lib/cypress/component/TipTap/ItemMention.cy.tsx b/lib/cypress/component/TipTap/ItemMention.cy.tsx new file mode 100644 index 00000000..19599b4d --- /dev/null +++ b/lib/cypress/component/TipTap/ItemMention.cy.tsx @@ -0,0 +1,168 @@ +/// +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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + // Should still render, using fallback color + cy.get('.item-mention').should('exist') + }) + + it('has bold font weight', () => { + const content = '[@Test](/item/123)' + + mount() + + 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() + + cy.get('.item-mention').should('have.css', 'cursor', 'pointer') + }) + + it('shows text cursor in edit mode', () => { + const content = '[@Alice](/item/123)' + + mount() + + 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( + { + 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() + + // 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() + + // Should be a regular link, not an item mention + cy.get('.item-mention').should('not.exist') + }) + }) +}) diff --git a/lib/cypress/component/TipTap/TestEditor.tsx b/lib/cypress/component/TipTap/TestEditor.tsx new file mode 100644 index 00000000..391a97a5 --- /dev/null +++ b/lib/cypress/component/TipTap/TestEditor.tsx @@ -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 ( +
+ +
+ ) +} + +export function TestEditorWithRouter(props: TestEditorProps) { + return ( + + + + ) +} + +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 +} diff --git a/lib/cypress/component/TipTap/VideoEmbed.cy.tsx b/lib/cypress/component/TipTap/VideoEmbed.cy.tsx new file mode 100644 index 00000000..8f5cc05f --- /dev/null +++ b/lib/cypress/component/TipTap/VideoEmbed.cy.tsx @@ -0,0 +1,288 @@ +/// +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 = '' + + mount() + + // 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 = '' + + mount() + + 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 = '' + + mount() + + 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 = '' + + mount() + + 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 = ` + +` + + mount() + + cy.get('iframe.video-embed').should('have.length', 2) + }) + }) + + describe('Markdown Serialization (Roundtrip)', () => { + it('serializes video embed back to autolink markdown', () => { + const content = '' + let editorInstance: Editor + + mount( + { + editorInstance = editor + }} + />, + ) + + cy.get('iframe.video-embed') + .should('exist') + .then(() => { + const markdown = editorInstance.getMarkdown() + expect(markdown).to.include('') + return null + }) + }) + }) + + describe('Non-Video URLs', () => { + it('does not convert regular URLs to video embeds', () => { + const content = '' + + mount() + + 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 = '' + + mount() + + cy.get('iframe.video-embed').should('not.exist') + }) + }) + + describe('Video Embed Attributes', () => { + it('renders with correct iframe attributes', () => { + const content = '' + + mount() + + 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 = '' + + mount() + + 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 = '' + + mount() + + 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 = '' + + mount() + + 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 = '' + + mount() + + 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 = '' + + mount() + + 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 = '' + let editorInstance: Editor | undefined + + mount( + { + editorInstance = editor + }} + />, + ) + + cy.get('iframe.video-embed') + .should('exist') + .should(() => expect(editorInstance).to.exist) + .then(() => { + if (!editorInstance) return null + const newContent = ` + +` + 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 = ` + +` + let editorInstance: Editor | undefined + + mount( + { + 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 = '' + let editorInstance: Editor | undefined + + mount( + { + 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) + }) + }) +}) diff --git a/lib/cypress/component/TipTap/roundtrip.cy.tsx b/lib/cypress/component/TipTap/roundtrip.cy.tsx new file mode 100644 index 00000000..4533ceb6 --- /dev/null +++ b/lib/cypress/component/TipTap/roundtrip.cy.tsx @@ -0,0 +1,106 @@ +/// +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( + { + 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('', [ + '', + ]) + }) + }) + + 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*', + ]) + }) + }) +}) diff --git a/lib/tsconfig.json b/lib/tsconfig.json index 001999dc..8e4ac629 100644 --- a/lib/tsconfig.json +++ b/lib/tsconfig.json @@ -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/"]