Skip to content
Merged
25 changes: 21 additions & 4 deletions packages/agentflow/src/__mocks__/@tiptap/react.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { createElement, forwardRef } from 'react'

/** Extract all text from a ProseMirror JSON node recursively. */
function extractPmText(node: { text?: string; content?: unknown[] }): string {
if (node.text) return node.text
if (node.content) return (node.content as { text?: string; content?: unknown[] }[]).map(extractPmText).join('')
return ''
}

export const useEditor = (config?: Record<string, unknown>) => {
// Track the current content so getMarkdown/getHTML reflect setContent() calls,
// mirroring real TipTap behaviour where setContent updates the editor state.
Expand All @@ -9,12 +16,22 @@ export const useEditor = (config?: Record<string, unknown>) => {
getMarkdown: () => currentContent,
isEmpty: !currentContent,
setEditable: jest.fn(),
// Returns a minimal ProseMirror JSON for the current string content.
// Allows unescapeXmlEntities() to walk and mutate the text node.
getJSON: () => ({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: currentContent }] }]
}),
commands: {
focus: jest.fn(),
// Capture the first argument (the content string) so getMarkdown/getHTML
// return the value that was last loaded into the editor.
setContent: jest.fn((content: string) => {
currentContent = content
// Accepts both string content and ProseMirror JSON objects (second setContent call
// in the two-step XML escape/unescape load sequence).
setContent: jest.fn((content: string | { text?: string; content?: unknown[] }) => {
if (typeof content === 'string') {
currentContent = content
} else if (content && typeof content === 'object') {
currentContent = extractPmText(content)
}
})
},
_onUpdate: config?.onUpdate
Expand Down
4 changes: 2 additions & 2 deletions packages/agentflow/src/atoms/ExpandTextDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Box, Button, Dialog, DialogActions, DialogContent, TextField, ToggleBut
import { IconCode, IconPencil } from '@tabler/icons-react'
import type { Editor } from '@tiptap/react'

import { getEditorMarkdown } from '@/atoms/utils/'
import { getEditorMarkdown, unescapeXmlTags } from '@/atoms/utils/'

import { CodeInput } from './CodeInput'
import { RichTextEditor } from './RichTextEditor.lazy'
Expand Down Expand Up @@ -73,7 +73,7 @@ export function ExpandTextDialog({
// When switching to Source, flush the editor's current state to markdown so the
// textarea shows markdown rather than a raw HTML string (Gap 3 fix — mirrors PR #6021).
if (newMode === 'source' && editorRef.current) {
setLocalValue(getEditorMarkdown(editorRef.current))
setLocalValue(unescapeXmlTags(getEditorMarkdown(editorRef.current)))
}
setMode(newMode)
}
Expand Down
87 changes: 80 additions & 7 deletions packages/agentflow/src/atoms/RichTextEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
import { render, screen } from '@testing-library/react'
import { Markdown } from '@tiptap/markdown'
import StarterKit from '@tiptap/starter-kit'

import { RichTextEditor } from './RichTextEditor'

// --- Mock TipTap ---
// editor.getMarkdown() is added directly on the Editor interface by @tiptap/markdown
// via module augmentation — it is NOT nested under storage.markdown.
let capturedOnUpdate: ((args: { editor: { getMarkdown: () => string } }) => void) | undefined
let capturedExtensions: unknown[] | undefined

jest.mock('@tiptap/react', () => ({
useEditor: (config: Record<string, unknown>) => {
// Capture the onUpdate callback so tests can simulate edits
capturedOnUpdate = config.onUpdate as typeof capturedOnUpdate
capturedExtensions = config.extensions as unknown[]
return {
setEditable: jest.fn(),
commands: { focus: jest.fn(), setContent: jest.fn() },
getMarkdown: () => 'mock markdown'
getMarkdown: () => 'mock markdown',
getJSON: () => ({ type: 'doc', content: [] })
}
},
EditorContent: ({ editor, ...rest }: { editor: unknown; [key: string]: unknown }) => (
Expand Down Expand Up @@ -49,14 +54,16 @@ jest.mock('lowlight', () => ({
createLowlight: jest.fn(() => ({ register: jest.fn() }))
}))

const mockOnChange = jest.fn()
describe('RichTextEditor', () => {
let mockOnChange: jest.Mock

beforeEach(() => {
jest.clearAllMocks()
capturedOnUpdate = undefined
})
beforeEach(() => {
jest.clearAllMocks()
capturedOnUpdate = undefined
capturedExtensions = undefined
mockOnChange = jest.fn()
})

describe('RichTextEditor', () => {
it('should render the editor container', () => {
render(<RichTextEditor value='Hello' onChange={mockOnChange} />)

Expand Down Expand Up @@ -100,4 +107,70 @@ describe('RichTextEditor', () => {

expect(screen.getByTestId('rich-text-editor')).toBeInTheDocument()
})

describe('buildExtensions — useMarkdown flag', () => {
it('includes Markdown extension when useMarkdown is true (default)', () => {
render(<RichTextEditor value='' onChange={mockOnChange} useMarkdown={true} />)

expect(capturedExtensions).toContain(Markdown)
})

it('excludes Markdown extension when useMarkdown is false', () => {
render(<RichTextEditor value='' onChange={mockOnChange} useMarkdown={false} />)

expect(capturedExtensions).not.toContain(Markdown)
})

it('passes link:false to StarterKit when useMarkdown is false', () => {
render(<RichTextEditor value='' onChange={mockOnChange} useMarkdown={false} />)

expect(StarterKit.configure).toHaveBeenCalledWith(expect.objectContaining({ link: false }))
})

it('does not pass link:false to StarterKit when useMarkdown is true (default)', () => {
render(<RichTextEditor value='' onChange={mockOnChange} useMarkdown={true} />)

expect(StarterKit.configure).not.toHaveBeenCalledWith(expect.objectContaining({ link: false }))
})
})

describe('XML tag preservation in onUpdate', () => {
it('should unescape entity-escaped XML tags before calling onChange', () => {
render(<RichTextEditor value='' onChange={mockOnChange} />)

// Simulate getMarkdown() returning entity-escaped tags (safety-net path)
capturedOnUpdate!({
editor: { getMarkdown: () => '&lt;instructions&gt;Be helpful&lt;/instructions&gt;' }
})

expect(mockOnChange).toHaveBeenCalledWith('<instructions>Be helpful</instructions>')
})

it('should pass through raw XML tags in markdown unchanged', () => {
render(<RichTextEditor value='' onChange={mockOnChange} />)

capturedOnUpdate!({
editor: { getMarkdown: () => '<instructions>Be helpful</instructions>' }
})

expect(mockOnChange).toHaveBeenCalledWith('<instructions>Be helpful</instructions>')
})

it('should preserve XML tags mixed with markdown', () => {
render(<RichTextEditor value='' onChange={mockOnChange} />)

capturedOnUpdate!({
editor: { getMarkdown: () => '# Title\n&lt;question&gt;{{input}}&lt;/question&gt;\n**bold**' }
})

expect(mockOnChange).toHaveBeenCalledWith('# Title\n<question>{{input}}</question>\n**bold**')
})

it('should render without error when useMarkdown is false', () => {
// HTML mode is exercised by other tests; this guards the component mounts cleanly.
render(<RichTextEditor value='' onChange={mockOnChange} useMarkdown={false} />)

expect(screen.getByTestId('rich-text-editor')).toBeInTheDocument()
})
})
})
34 changes: 22 additions & 12 deletions packages/agentflow/src/atoms/RichTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import python from 'highlight.js/lib/languages/python'
import typescript from 'highlight.js/lib/languages/typescript'
import { createLowlight } from 'lowlight'

import { getEditorMarkdown, isHtmlContent } from '@/atoms/utils/'
import { escapeXmlTags, getEditorMarkdown, isHtmlContent, unescapeXmlEntities, unescapeXmlTags } from '@/atoms/utils/'
import { tokens } from '@/core/theme/tokens'

const lowlight = createLowlight()
Expand All @@ -42,11 +42,22 @@ export interface RichTextEditorProps {
useMarkdown?: boolean
}

/* ── Helpers ── */

function loadContent(editor: Editor, value: string, markdown: boolean) {
if (!markdown || isHtmlContent(value)) {
editor.commands.setContent(value, { emitUpdate: false, contentType: 'html' })
} else {
editor.commands.setContent(escapeXmlTags(value), { emitUpdate: false, contentType: 'markdown' })
editor.commands.setContent(unescapeXmlEntities(editor.getJSON()), { emitUpdate: false })
}
}

/* ── TipTap extensions (no mention/variable support — that belongs in features/) ── */

const buildExtensions = (placeholder?: string) => [
Markdown,
StarterKit.configure({ codeBlock: false }),
const buildExtensions = (placeholder?: string, useMarkdown = true) => [
...(useMarkdown ? [Markdown] : []),
StarterKit.configure({ codeBlock: false, ...(!useMarkdown && { link: false }) }),
CodeBlockLowlight.configure({ lowlight, enableTabIndentation: true, tabSize: 2 }),
...(placeholder ? [Placeholder.configure({ placeholder })] : [])
]
Expand Down Expand Up @@ -174,17 +185,18 @@ export function RichTextEditor({
const initialValueRef = useRef(value)
const useMarkdownRef = useRef(useMarkdown)

const extensions = useMemo(() => buildExtensions(placeholder), [placeholder])
const extensions = useMemo(() => buildExtensions(placeholder, useMarkdown), [placeholder, useMarkdown])

const editor = useEditor({
extensions,
content: '',
editable: !disabled,
autofocus: autoFocus ? 'end' : false,
onUpdate: ({ editor: ed }) => {
const value = useMarkdown ? getEditorMarkdown(ed) : ed.getHTML()
lastEmittedRef.current = value
onChangeRef.current(value)
const raw = useMarkdown ? getEditorMarkdown(ed) : ed.getHTML()
const emitted = useMarkdown ? unescapeXmlTags(raw) : raw
lastEmittedRef.current = emitted
onChangeRef.current(emitted)
}
})

Expand All @@ -199,16 +211,14 @@ export function RichTextEditor({
// Reads from refs so only `editor` needs to be in the dep array.
useEffect(() => {
if (!editor || !initialValueRef.current) return
const contentType = !useMarkdownRef.current || isHtmlContent(initialValueRef.current) ? 'html' : 'markdown'
editor.commands.setContent(initialValueRef.current, { emitUpdate: false, contentType })
loadContent(editor, initialValueRef.current, useMarkdownRef.current)
lastEmittedRef.current = initialValueRef.current
}, [editor])

// Sync genuine external value changes (e.g. parent resets the field programmatically).
useEffect(() => {
if (editor && value !== lastEmittedRef.current) {
const contentType = !useMarkdown || isHtmlContent(value) ? 'html' : 'markdown'
editor.commands.setContent(value, { emitUpdate: false, contentType })
loadContent(editor, value, useMarkdown)
lastEmittedRef.current = value
}
}, [editor, value, useMarkdown])
Comment thread
j-sanaa marked this conversation as resolved.
Expand Down
89 changes: 89 additions & 0 deletions packages/agentflow/src/atoms/VariableInput.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createTheme, ThemeProvider } from '@mui/material/styles'
import { render, screen } from '@testing-library/react'
import * as TiptapReact from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'

import { VariableInput, type VariableInputProps } from './VariableInput'

Expand Down Expand Up @@ -98,4 +99,92 @@ describe('VariableInput', () => {
// The mock editor has getHTML/getMarkdown/commands etc.
expect(onEditorReady).toHaveBeenCalledWith(expect.objectContaining({ getHTML: expect.any(Function) }))
})

describe('useMarkdown derived from rows — StarterKit link extension', () => {
beforeEach(() => {
jest.clearAllMocks()
})

it('passes link:false to StarterKit when rows is not set (single-line field)', () => {
renderVariableInput({ rows: undefined })

expect(StarterKit.configure).toHaveBeenCalledWith(expect.objectContaining({ link: false }))
})

it('does not pass link:false to StarterKit when rows is set (multiline/markdown field)', () => {
renderVariableInput({ rows: 4 })

expect(StarterKit.configure).not.toHaveBeenCalledWith(expect.objectContaining({ link: false }))
})
})

describe('onUpdate always emits markdown regardless of rows', () => {
it('calls onChange with getEditorMarkdown output when rows is not set', () => {
const onChange = jest.fn()
const useEditorSpy = jest.spyOn(TiptapReact, 'useEditor')
renderVariableInput({ onChange, rows: undefined })

const config = useEditorSpy.mock.calls[0][0] as { onUpdate: (args: { editor: unknown }) => void }
const mockEditor = { getMarkdown: () => 'plain url text', getHTML: jest.fn() }
config.onUpdate({ editor: mockEditor })

expect(onChange).toHaveBeenCalledWith('plain url text')
expect(mockEditor.getHTML).not.toHaveBeenCalled()
useEditorSpy.mockRestore()
})

it('calls onChange with getEditorMarkdown output when rows is set', () => {
const onChange = jest.fn()
const useEditorSpy = jest.spyOn(TiptapReact, 'useEditor')
renderVariableInput({ onChange, rows: 4 })

const config = useEditorSpy.mock.calls[0][0] as { onUpdate: (args: { editor: unknown }) => void }
const mockEditor = { getMarkdown: () => '**bold**', getHTML: jest.fn() }
config.onUpdate({ editor: mockEditor })

expect(onChange).toHaveBeenCalledWith('**bold**')
expect(mockEditor.getHTML).not.toHaveBeenCalled()
useEditorSpy.mockRestore()
})
})

describe('XML tag preservation in onUpdate', () => {
it('should unescape entity-escaped XML tags before calling onChange', () => {
const onChange = jest.fn()
const useEditorSpy = jest.spyOn(TiptapReact, 'useEditor')
renderVariableInput({ onChange })

const config = useEditorSpy.mock.calls[0][0] as { onUpdate: (args: { editor: unknown }) => void }
config.onUpdate({ editor: { getMarkdown: () => '&lt;instructions&gt;Be helpful&lt;/instructions&gt;', getHTML: jest.fn() } })

expect(onChange).toHaveBeenCalledWith('<instructions>Be helpful</instructions>')
useEditorSpy.mockRestore()
})

it('should pass through raw XML tags unchanged', () => {
const onChange = jest.fn()
const useEditorSpy = jest.spyOn(TiptapReact, 'useEditor')
renderVariableInput({ onChange })

const config = useEditorSpy.mock.calls[0][0] as { onUpdate: (args: { editor: unknown }) => void }
config.onUpdate({ editor: { getMarkdown: () => '<question>What?</question>', getHTML: jest.fn() } })

expect(onChange).toHaveBeenCalledWith('<question>What?</question>')
useEditorSpy.mockRestore()
})

it('should preserve XML tags mixed with variables', () => {
const onChange = jest.fn()
const useEditorSpy = jest.spyOn(TiptapReact, 'useEditor')
renderVariableInput({ onChange })

const config = useEditorSpy.mock.calls[0][0] as { onUpdate: (args: { editor: unknown }) => void }
config.onUpdate({
editor: { getMarkdown: () => '&lt;context&gt;{{question}}&lt;/context&gt;', getHTML: jest.fn() }
})

expect(onChange).toHaveBeenCalledWith('<context>{{question}}</context>')
useEditorSpy.mockRestore()
})
})
})
Loading
Loading