Compare commits

...

3 Commits

17 changed files with 1529 additions and 0 deletions

View File

@@ -0,0 +1,144 @@
import type { Node as ReactFlowNode } from 'reactflow'
import type { NoteNodeType } from '../types'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import ReactFlow, { ReactFlowProvider } from 'reactflow'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { CUSTOM_NOTE_NODE } from '../constants'
import NoteNode from '../index'
import { NoteTheme } from '../types'
const {
mockHandleEditorChange,
mockHandleNodeDataUpdateWithSyncDraft,
mockHandleNodeDelete,
mockHandleNodesCopy,
mockHandleNodesDuplicate,
mockHandleShowAuthorChange,
mockHandleThemeChange,
mockSetShortcutsEnabled,
} = vi.hoisted(() => ({
mockHandleEditorChange: vi.fn(),
mockHandleNodeDataUpdateWithSyncDraft: vi.fn(),
mockHandleNodeDelete: vi.fn(),
mockHandleNodesCopy: vi.fn(),
mockHandleNodesDuplicate: vi.fn(),
mockHandleShowAuthorChange: vi.fn(),
mockHandleThemeChange: vi.fn(),
mockSetShortcutsEnabled: vi.fn(),
}))
vi.mock('../../hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../hooks')>()
return {
...actual,
useNodeDataUpdate: () => ({
handleNodeDataUpdateWithSyncDraft: mockHandleNodeDataUpdateWithSyncDraft,
}),
useNodesInteractions: () => ({
handleNodesCopy: mockHandleNodesCopy,
handleNodesDuplicate: mockHandleNodesDuplicate,
handleNodeDelete: mockHandleNodeDelete,
}),
}
})
vi.mock('../hooks', () => ({
useNote: () => ({
handleThemeChange: mockHandleThemeChange,
handleEditorChange: mockHandleEditorChange,
handleShowAuthorChange: mockHandleShowAuthorChange,
}),
}))
vi.mock('../../workflow-history-store', () => ({
useWorkflowHistoryStore: () => ({
setShortcutsEnabled: mockSetShortcutsEnabled,
}),
}))
const createNoteData = (overrides: Partial<NoteNodeType> = {}): NoteNodeType => ({
title: '',
desc: '',
type: '' as unknown as NoteNodeType['type'],
text: '',
theme: NoteTheme.blue,
author: 'Alice',
showAuthor: true,
width: 240,
height: 88,
selected: true,
...overrides,
})
const renderNoteNode = (dataOverrides: Partial<NoteNodeType> = {}) => {
const nodeData = createNoteData(dataOverrides)
const nodes: Array<ReactFlowNode<NoteNodeType>> = [
{
id: 'note-1',
type: CUSTOM_NOTE_NODE,
position: { x: 0, y: 0 },
data: nodeData,
selected: !!nodeData.selected,
},
]
return renderWorkflowComponent(
<div style={{ width: 800, height: 600 }}>
<ReactFlowProvider>
<ReactFlow
fitView
nodes={nodes}
edges={[]}
nodeTypes={{
[CUSTOM_NOTE_NODE]: NoteNode,
}}
/>
</ReactFlowProvider>
</div>,
{
initialStoreState: {
controlPromptEditorRerenderKey: 0,
},
},
)
}
describe('NoteNode', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the toolbar and author for a selected persistent note', async () => {
renderNoteNode()
expect(screen.getByText('Alice')).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByText('workflow.nodes.note.editor.small')).toBeInTheDocument()
})
})
it('should hide the toolbar for temporary notes', () => {
renderNoteNode({
_isTempNode: true,
showAuthor: false,
})
expect(screen.queryByText('workflow.nodes.note.editor.small')).not.toBeInTheDocument()
})
it('should clear the selected state when clicking outside the note', async () => {
renderNoteNode()
fireEvent.click(document.body)
await waitFor(() => {
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith({
id: 'note-1',
data: {
selected: false,
},
})
})
})
})

View File

@@ -0,0 +1,138 @@
import type { LexicalEditor } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { render, screen, waitFor } from '@testing-library/react'
import { $getRoot } from 'lexical'
import { useEffect } from 'react'
import { NoteEditorContextProvider } from '../context'
import { useStore } from '../store'
const emptyValue = JSON.stringify({ root: { children: [] } })
const populatedValue = JSON.stringify({
root: {
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'hello',
type: 'text',
version: 1,
},
],
direction: null,
format: '',
indent: 0,
textFormat: 0,
textStyle: '',
type: 'paragraph',
version: 1,
},
],
direction: null,
format: '',
indent: 0,
type: 'root',
version: 1,
},
})
const readEditorText = (editor: LexicalEditor) => {
let text = ''
editor.getEditorState().read(() => {
text = $getRoot().getTextContent()
})
return text
}
const ContextProbe = ({
onReady,
}: {
onReady?: (editor: LexicalEditor) => void
}) => {
const [editor] = useLexicalComposerContext()
const selectedIsBold = useStore(state => state.selectedIsBold)
useEffect(() => {
onReady?.(editor)
}, [editor, onReady])
return <div>{selectedIsBold ? 'bold' : 'not-bold'}</div>
}
describe('NoteEditorContextProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Provider should expose the store and render the wrapped editor tree.
describe('Rendering', () => {
it('should render children with the note editor store defaults', async () => {
let editor: LexicalEditor | null = null
render(
<NoteEditorContextProvider value={emptyValue}>
<ContextProbe onReady={instance => (editor = instance)} />
</NoteEditorContextProvider>,
)
expect(screen.getByText('not-bold')).toBeInTheDocument()
await waitFor(() => {
expect(editor).not.toBeNull()
})
expect(editor!.isEditable()).toBe(true)
expect(readEditorText(editor!)).toBe('')
})
})
// Invalid or empty editor state should fall back to an empty lexical state.
describe('Editor State Initialization', () => {
it.each([
{
name: 'value is malformed json',
value: '{invalid',
},
{
name: 'root has no children',
value: emptyValue,
},
])('should use an empty editor state when $name', async ({ value }) => {
let editor: LexicalEditor | null = null
render(
<NoteEditorContextProvider value={value}>
<ContextProbe onReady={instance => (editor = instance)} />
</NoteEditorContextProvider>,
)
await waitFor(() => {
expect(editor).not.toBeNull()
})
expect(readEditorText(editor!)).toBe('')
})
it('should restore lexical content and forward editable prop', async () => {
let editor: LexicalEditor | null = null
render(
<NoteEditorContextProvider value={populatedValue} editable={false}>
<ContextProbe onReady={instance => (editor = instance)} />
</NoteEditorContextProvider>,
)
await waitFor(() => {
expect(editor).not.toBeNull()
expect(readEditorText(editor!)).toBe('hello')
})
expect(editor!.isEditable()).toBe(false)
})
})
})

View File

@@ -0,0 +1,120 @@
import type { EditorState, LexicalEditor } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { $createParagraphNode, $createTextNode, $getRoot } from 'lexical'
import { useEffect } from 'react'
import { NoteEditorContextProvider } from '../context'
import Editor from '../editor'
const emptyValue = JSON.stringify({ root: { children: [] } })
const EditorProbe = ({
onReady,
}: {
onReady?: (editor: LexicalEditor) => void
}) => {
const [editor] = useLexicalComposerContext()
useEffect(() => {
onReady?.(editor)
}, [editor, onReady])
return null
}
const renderEditor = (
props: Partial<React.ComponentProps<typeof Editor>> = {},
onEditorReady?: (editor: LexicalEditor) => void,
) => {
return render(
<NoteEditorContextProvider value={emptyValue}>
<>
<Editor
containerElement={document.createElement('div')}
{...props}
/>
<EditorProbe onReady={onEditorReady} />
</>
</NoteEditorContextProvider>,
)
}
describe('Editor', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Editor should render the lexical surface with the provided placeholder.
describe('Rendering', () => {
it('should render the placeholder text and content editable surface', () => {
renderEditor({ placeholder: 'Type note' })
expect(screen.getByText('Type note')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
})
// Focus and blur should toggle workflow shortcuts while editing content.
describe('Focus Management', () => {
it('should disable shortcuts on focus and re-enable them on blur', () => {
const setShortcutsEnabled = vi.fn()
renderEditor({ setShortcutsEnabled })
const contentEditable = screen.getByRole('textbox')
fireEvent.focus(contentEditable)
fireEvent.blur(contentEditable)
expect(setShortcutsEnabled).toHaveBeenNthCalledWith(1, false)
expect(setShortcutsEnabled).toHaveBeenNthCalledWith(2, true)
})
})
// Lexical change events should be forwarded to the external onChange callback.
describe('Change Handling', () => {
it('should pass editor updates through onChange', async () => {
const changes: string[] = []
let editor: LexicalEditor | null = null
const handleChange = (editorState: EditorState) => {
editorState.read(() => {
changes.push($getRoot().getTextContent())
})
}
renderEditor({ onChange: handleChange }, instance => (editor = instance))
await waitFor(() => {
expect(editor).not.toBeNull()
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
act(() => {
editor!.update(() => {
const root = $getRoot()
root.clear()
const paragraph = $createParagraphNode()
paragraph.append($createTextNode('hello'))
root.append(paragraph)
}, { discrete: true })
})
act(() => {
editor!.update(() => {
const root = $getRoot()
root.clear()
const paragraph = $createParagraphNode()
paragraph.append($createTextNode('hello world'))
root.append(paragraph)
}, { discrete: true })
})
await waitFor(() => {
expect(changes).toContain('hello world')
})
})
})
})

View File

@@ -0,0 +1,24 @@
import { render } from '@testing-library/react'
import { NoteEditorContextProvider } from '../../../context'
import FormatDetectorPlugin from '../index'
const emptyValue = JSON.stringify({ root: { children: [] } })
describe('FormatDetectorPlugin', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// The plugin should register its observers without rendering extra UI.
describe('Rendering', () => {
it('should mount inside the real note editor context without visible output', () => {
const { container } = render(
<NoteEditorContextProvider value={emptyValue}>
<FormatDetectorPlugin />
</NoteEditorContextProvider>,
)
expect(container).toBeEmptyDOMElement()
})
})
})

View File

@@ -0,0 +1,71 @@
import type { createNoteEditorStore } from '../../../store'
import { act, render, screen, waitFor } from '@testing-library/react'
import { useEffect } from 'react'
import { NoteEditorContextProvider } from '../../../context'
import { useNoteEditorStore } from '../../../store'
import LinkEditorPlugin from '../index'
type NoteEditorStore = ReturnType<typeof createNoteEditorStore>
const emptyValue = JSON.stringify({ root: { children: [] } })
const StoreProbe = ({
onReady,
}: {
onReady?: (store: NoteEditorStore) => void
}) => {
const store = useNoteEditorStore()
useEffect(() => {
onReady?.(store)
}, [onReady, store])
return null
}
describe('LinkEditorPlugin', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Without an anchor element the plugin should stay hidden.
describe('Visibility', () => {
it('should render nothing when no link anchor is selected', () => {
const { container } = render(
<NoteEditorContextProvider value={emptyValue}>
<LinkEditorPlugin containerElement={null} />
</NoteEditorContextProvider>,
)
expect(container).toBeEmptyDOMElement()
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should render the link editor when the store has an anchor element', async () => {
let store: NoteEditorStore | null = null
render(
<NoteEditorContextProvider value={emptyValue}>
<StoreProbe onReady={instance => (store = instance)} />
<LinkEditorPlugin containerElement={document.createElement('div')} />
</NoteEditorContextProvider>,
)
await waitFor(() => {
expect(store).not.toBeNull()
})
act(() => {
store!.setState({
linkAnchorElement: document.createElement('a'),
linkOperatorShow: false,
selectedLinkUrl: 'https://example.com',
})
})
await waitFor(() => {
expect(screen.getByDisplayValue('https://example.com')).toBeInTheDocument()
})
})
})
})

View File

@@ -0,0 +1,32 @@
import { fireEvent, render, waitFor } from '@testing-library/react'
import { NoteTheme } from '../../../types'
import ColorPicker, { COLOR_LIST } from '../color-picker'
describe('NoteEditor ColorPicker', () => {
it('should open the palette and apply the selected theme', async () => {
const onThemeChange = vi.fn()
const { container } = render(
<ColorPicker theme={NoteTheme.blue} onThemeChange={onThemeChange} />,
)
const trigger = container.querySelector('[data-state="closed"]') as HTMLElement
fireEvent.click(trigger)
const popup = document.body.querySelector('[role="tooltip"]')
expect(popup).toBeInTheDocument()
const options = popup?.querySelectorAll('.group.relative')
expect(options).toHaveLength(COLOR_LIST.length)
fireEvent.click(options?.[COLOR_LIST.length - 1] as Element)
expect(onThemeChange).toHaveBeenCalledWith(NoteTheme.violet)
await waitFor(() => {
expect(document.body.querySelector('[role="tooltip"]')).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,62 @@
import { fireEvent, render } from '@testing-library/react'
import Command from '../command'
const { mockHandleCommand } = vi.hoisted(() => ({
mockHandleCommand: vi.fn(),
}))
let mockSelectedState = {
selectedIsBold: false,
selectedIsItalic: false,
selectedIsStrikeThrough: false,
selectedIsLink: false,
selectedIsBullet: false,
}
vi.mock('../../store', () => ({
useStore: (selector: (state: typeof mockSelectedState) => unknown) => selector(mockSelectedState),
}))
vi.mock('../hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../hooks')>()
return {
...actual,
useCommand: () => ({
handleCommand: mockHandleCommand,
}),
}
})
describe('NoteEditor Command', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSelectedState = {
selectedIsBold: false,
selectedIsItalic: false,
selectedIsStrikeThrough: false,
selectedIsLink: false,
selectedIsBullet: false,
}
})
it('should highlight the active command and dispatch it on click', () => {
mockSelectedState.selectedIsBold = true
const { container } = render(<Command type="bold" />)
const trigger = container.querySelector('.cursor-pointer') as HTMLElement
expect(trigger).toHaveClass('bg-state-accent-active')
fireEvent.click(trigger)
expect(mockHandleCommand).toHaveBeenCalledWith('bold')
})
it('should keep inactive commands unhighlighted', () => {
const { container } = render(<Command type="link" />)
const trigger = container.querySelector('.cursor-pointer') as HTMLElement
expect(trigger).not.toHaveClass('bg-state-accent-active')
})
})

View File

@@ -0,0 +1,55 @@
import { fireEvent, render, screen } from '@testing-library/react'
import FontSizeSelector from '../font-size-selector'
const {
mockHandleFontSize,
mockHandleOpenFontSizeSelector,
} = vi.hoisted(() => ({
mockHandleFontSize: vi.fn(),
mockHandleOpenFontSizeSelector: vi.fn(),
}))
let mockFontSizeSelectorShow = false
let mockFontSize = '12px'
vi.mock('../hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../hooks')>()
return {
...actual,
useFontSize: () => ({
fontSize: mockFontSize,
fontSizeSelectorShow: mockFontSizeSelectorShow,
handleFontSize: mockHandleFontSize,
handleOpenFontSizeSelector: mockHandleOpenFontSizeSelector,
}),
}
})
describe('NoteEditor FontSizeSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFontSizeSelectorShow = false
mockFontSize = '12px'
})
it('should show the current font size label and request opening when clicked', () => {
render(<FontSizeSelector />)
fireEvent.click(screen.getByText('workflow.nodes.note.editor.small'))
expect(mockHandleOpenFontSizeSelector).toHaveBeenCalledWith(true)
})
it('should select a new font size and close the popup', () => {
mockFontSizeSelectorShow = true
mockFontSize = '14px'
render(<FontSizeSelector />)
fireEvent.click(screen.getByText('workflow.nodes.note.editor.large'))
expect(screen.getAllByText('workflow.nodes.note.editor.medium').length).toBeGreaterThan(0)
expect(mockHandleFontSize).toHaveBeenCalledWith('16px')
expect(mockHandleOpenFontSizeSelector).toHaveBeenCalledWith(false)
})
})

View File

@@ -0,0 +1,101 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { NoteTheme } from '../../../types'
import Toolbar from '../index'
const {
mockHandleCommand,
mockHandleFontSize,
mockHandleOpenFontSizeSelector,
} = vi.hoisted(() => ({
mockHandleCommand: vi.fn(),
mockHandleFontSize: vi.fn(),
mockHandleOpenFontSizeSelector: vi.fn(),
}))
let mockFontSizeSelectorShow = false
let mockFontSize = '14px'
let mockSelectedState = {
selectedIsBold: false,
selectedIsItalic: false,
selectedIsStrikeThrough: false,
selectedIsLink: false,
selectedIsBullet: false,
}
vi.mock('../../store', () => ({
useStore: (selector: (state: typeof mockSelectedState) => unknown) => selector(mockSelectedState),
}))
vi.mock('../hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../hooks')>()
return {
...actual,
useCommand: () => ({
handleCommand: mockHandleCommand,
}),
useFontSize: () => ({
fontSize: mockFontSize,
fontSizeSelectorShow: mockFontSizeSelectorShow,
handleFontSize: mockHandleFontSize,
handleOpenFontSizeSelector: mockHandleOpenFontSizeSelector,
}),
}
})
describe('NoteEditor Toolbar', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFontSizeSelectorShow = false
mockFontSize = '14px'
mockSelectedState = {
selectedIsBold: false,
selectedIsItalic: false,
selectedIsStrikeThrough: false,
selectedIsLink: false,
selectedIsBullet: false,
}
})
it('should compose the toolbar controls and forward callbacks from color and operator actions', async () => {
const onCopy = vi.fn()
const onDelete = vi.fn()
const onDuplicate = vi.fn()
const onShowAuthorChange = vi.fn()
const onThemeChange = vi.fn()
const { container } = render(
<Toolbar
theme={NoteTheme.blue}
onThemeChange={onThemeChange}
onCopy={onCopy}
onDuplicate={onDuplicate}
onDelete={onDelete}
showAuthor={false}
onShowAuthorChange={onShowAuthorChange}
/>,
)
expect(screen.getByText('workflow.nodes.note.editor.medium')).toBeInTheDocument()
const triggers = container.querySelectorAll('[data-state="closed"]')
fireEvent.click(triggers[0] as HTMLElement)
const colorOptions = document.body.querySelectorAll('[role="tooltip"] .group.relative')
fireEvent.click(colorOptions[colorOptions.length - 1] as Element)
expect(onThemeChange).toHaveBeenCalledWith(NoteTheme.violet)
fireEvent.click(container.querySelectorAll('[data-state="closed"]')[container.querySelectorAll('[data-state="closed"]').length - 1] as HTMLElement)
fireEvent.click(screen.getByText('workflow.common.copy'))
expect(onCopy).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(document.body.querySelector('[role="tooltip"]')).not.toBeInTheDocument()
})
expect(onDelete).not.toHaveBeenCalled()
expect(onDuplicate).not.toHaveBeenCalled()
expect(onShowAuthorChange).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,67 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Operator from '../operator'
const renderOperator = (showAuthor = false) => {
const onCopy = vi.fn()
const onDuplicate = vi.fn()
const onDelete = vi.fn()
const onShowAuthorChange = vi.fn()
const renderResult = render(
<Operator
onCopy={onCopy}
onDuplicate={onDuplicate}
onDelete={onDelete}
showAuthor={showAuthor}
onShowAuthorChange={onShowAuthorChange}
/>,
)
return {
...renderResult,
onCopy,
onDelete,
onDuplicate,
onShowAuthorChange,
}
}
describe('NoteEditor Toolbar Operator', () => {
it('should trigger copy, duplicate, and delete from the opened menu', () => {
const {
container,
onCopy,
onDelete,
onDuplicate,
} = renderOperator()
const trigger = container.querySelector('[data-state="closed"]') as HTMLElement
fireEvent.click(trigger)
fireEvent.click(screen.getByText('workflow.common.copy'))
expect(onCopy).toHaveBeenCalledTimes(1)
fireEvent.click(container.querySelector('[data-state="closed"]') as HTMLElement)
fireEvent.click(screen.getByText('workflow.common.duplicate'))
expect(onDuplicate).toHaveBeenCalledTimes(1)
fireEvent.click(container.querySelector('[data-state="closed"]') as HTMLElement)
fireEvent.click(screen.getByText('common.operation.delete'))
expect(onDelete).toHaveBeenCalledTimes(1)
})
it('should forward the switch state through onShowAuthorChange', () => {
const {
container,
onShowAuthorChange,
} = renderOperator(true)
fireEvent.click(container.querySelector('[data-state="closed"]') as HTMLElement)
fireEvent.click(screen.getByRole('switch'))
expect(onShowAuthorChange).toHaveBeenCalledWith(false)
})
})

View File

@@ -0,0 +1,145 @@
import type { Node as ReactFlowNode } from 'reactflow'
import type { CommonNodeType } from '../../types'
import { act, screen } from '@testing-library/react'
import ReactFlow, { ReactFlowProvider } from 'reactflow'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { BlockEnum } from '../../types'
import Operator from '../index'
const mockEmit = vi.fn()
const mockDeleteAllInspectorVars = vi.fn()
vi.mock('../../hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../hooks')>()
return {
...actual,
useNodesSyncDraft: () => ({
handleSyncWorkflowDraft: vi.fn(),
}),
useWorkflowReadOnly: () => ({
workflowReadOnly: false,
getWorkflowReadOnly: () => false,
}),
}
})
vi.mock('../../hooks/use-inspect-vars-crud', () => ({
default: () => ({
conversationVars: [],
systemVars: [],
nodesWithInspectVars: [],
deleteAllInspectorVars: mockDeleteAllInspectorVars,
}),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
emit: mockEmit,
},
}),
}))
const createNode = (): ReactFlowNode<CommonNodeType> => ({
id: 'node-1',
type: 'custom',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.Code,
title: 'Code',
desc: '',
},
})
const originalResizeObserver = globalThis.ResizeObserver
let resizeObserverCallback: ResizeObserverCallback | undefined
const observeSpy = vi.fn()
const disconnectSpy = vi.fn()
class MockResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeObserverCallback = callback
}
observe(...args: Parameters<ResizeObserver['observe']>) {
observeSpy(...args)
}
unobserve() {
return undefined
}
disconnect() {
disconnectSpy()
}
}
const renderOperator = (initialStoreState: Record<string, unknown> = {}) => {
return renderWorkflowComponent(
<div style={{ width: 800, height: 600 }}>
<ReactFlowProvider>
<ReactFlow fitView nodes={[createNode()]} edges={[]} />
<Operator handleUndo={vi.fn()} handleRedo={vi.fn()} />
</ReactFlowProvider>
</div>,
{
initialStoreState,
historyStore: {
nodes: [],
edges: [],
},
},
)
}
describe('Operator', () => {
beforeEach(() => {
vi.clearAllMocks()
resizeObserverCallback = undefined
vi.stubGlobal('ResizeObserver', MockResizeObserver as unknown as typeof ResizeObserver)
})
afterEach(() => {
globalThis.ResizeObserver = originalResizeObserver
})
it('should keep the operator width on the 400px floor when the available width is smaller', () => {
const { container } = renderOperator({
workflowCanvasWidth: 620,
rightPanelWidth: 350,
})
expect(screen.getByText('workflow.debug.variableInspect.trigger.normal')).toBeInTheDocument()
expect(container.querySelector('div[style*="width: 400px"]')).toBeInTheDocument()
})
it('should fall back to auto width before layout metrics are ready', () => {
const { container } = renderOperator()
expect(container.querySelector('div[style*="width: auto"]')).toBeInTheDocument()
})
it('should sync the observed panel size back into the workflow store and disconnect on unmount', () => {
const { store, unmount } = renderOperator({
workflowCanvasWidth: 900,
rightPanelWidth: 260,
})
expect(observeSpy).toHaveBeenCalled()
act(() => {
resizeObserverCallback?.([
{
borderBoxSize: [{ inlineSize: 512, blockSize: 188 }],
} as unknown as ResizeObserverEntry,
], {} as ResizeObserver)
})
expect(store.getState().bottomPanelWidth).toBe(512)
expect(store.getState().bottomPanelHeight).toBe(188)
unmount()
expect(disconnectSpy).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,26 @@
import { render, screen } from '@testing-library/react'
import Empty from '../empty'
const mockDocLink = vi.fn((path: string) => `https://docs.example.com${path}`)
vi.mock('@/context/i18n', () => ({
useDocLink: () => mockDocLink,
}))
describe('VariableInspect Empty', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the empty-state copy and docs link', () => {
render(<Empty />)
const link = screen.getByRole('link', { name: 'workflow.debug.variableInspect.emptyLink' })
expect(screen.getByText('workflow.debug.variableInspect.title')).toBeInTheDocument()
expect(screen.getByText('workflow.debug.variableInspect.emptyTip')).toBeInTheDocument()
expect(link).toHaveAttribute('href', 'https://docs.example.com/use-dify/debug/variable-inspect')
expect(link).toHaveAttribute('target', '_blank')
expect(mockDocLink).toHaveBeenCalledWith('/use-dify/debug/variable-inspect')
})
})

View File

@@ -0,0 +1,131 @@
import type { NodeWithVar, VarInInspect } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import { VarInInspectType } from '@/types/workflow'
import { BlockEnum, VarType } from '../../types'
import Group from '../group'
const mockUseToolIcon = vi.fn(() => '')
vi.mock('../../hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../hooks')>()
return {
...actual,
useToolIcon: () => mockUseToolIcon(),
}
})
const createVar = (overrides: Partial<VarInInspect> = {}): VarInInspect => ({
id: 'var-1',
type: VarInInspectType.node,
name: 'message',
description: '',
selector: ['node-1', 'message'],
value_type: VarType.string,
value: 'hello',
edited: false,
visible: true,
is_truncated: false,
full_content: {
size_bytes: 0,
download_url: '',
},
...overrides,
})
const createNodeData = (overrides: Partial<NodeWithVar> = {}): NodeWithVar => ({
nodeId: 'node-1',
nodePayload: {
type: BlockEnum.Code,
title: 'Code',
desc: '',
},
nodeType: BlockEnum.Code,
title: 'Code',
vars: [],
...overrides,
})
describe('VariableInspect Group', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should mask secret environment variables before selecting them', () => {
const handleSelect = vi.fn()
render(
<Group
varType={VarInInspectType.environment}
varList={[
createVar({
id: 'env-secret',
type: VarInInspectType.environment,
name: 'API_KEY',
value_type: VarType.secret,
value: 'plain-secret',
}),
]}
handleSelect={handleSelect}
/>,
)
fireEvent.click(screen.getByText('API_KEY'))
expect(screen.getByText('workflow.debug.variableInspect.envNode')).toBeInTheDocument()
expect(handleSelect).toHaveBeenCalledWith({
nodeId: VarInInspectType.environment,
nodeType: VarInInspectType.environment,
title: VarInInspectType.environment,
var: expect.objectContaining({
id: 'env-secret',
type: VarInInspectType.environment,
value: '******************',
}),
})
})
it('should hide invisible variables and collapse the list when the group header is clicked', () => {
render(
<Group
nodeData={createNodeData()}
varType={VarInInspectType.node}
varList={[
createVar({ id: 'visible-var', name: 'visible_var' }),
createVar({ id: 'hidden-var', name: 'hidden_var', visible: false }),
]}
handleSelect={vi.fn()}
/>,
)
expect(screen.getByText('visible_var')).toBeInTheDocument()
expect(screen.queryByText('hidden_var')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Code'))
expect(screen.queryByText('visible_var')).not.toBeInTheDocument()
})
it('should expose node view and clear actions for node groups', () => {
const handleView = vi.fn()
const handleClear = vi.fn()
render(
<Group
nodeData={createNodeData()}
varType={VarInInspectType.node}
varList={[createVar()]}
handleSelect={vi.fn()}
handleView={handleView}
handleClear={handleClear}
/>,
)
const actionButtons = screen.getAllByRole('button')
fireEvent.click(actionButtons[0])
fireEvent.click(actionButtons[1])
expect(handleView).toHaveBeenCalledTimes(1)
expect(handleClear).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,19 @@
import { render, screen } from '@testing-library/react'
import LargeDataAlert from '../large-data-alert'
describe('LargeDataAlert', () => {
it('should render the default message and export action when a download URL exists', () => {
const { container } = render(<LargeDataAlert downloadUrl="https://example.com/export.json" className="extra-alert" />)
expect(screen.getByText('workflow.debug.variableInspect.largeData')).toBeInTheDocument()
expect(screen.getByText('workflow.debug.variableInspect.export')).toBeInTheDocument()
expect(container.firstChild).toHaveClass('extra-alert')
})
it('should render the no-export message and omit the export action when the URL is missing', () => {
render(<LargeDataAlert textHasNoExport />)
expect(screen.getByText('workflow.debug.variableInspect.largeDataNoExport')).toBeInTheDocument()
expect(screen.queryByText('workflow.debug.variableInspect.export')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,177 @@
import type { EnvironmentVariable } from '../../types'
import type { NodeWithVar, VarInInspect } from '@/types/workflow'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import ReactFlow, { ReactFlowProvider } from 'reactflow'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { BlockEnum } from '../../types'
import Panel from '../panel'
import { EVENT_WORKFLOW_STOP } from '../types'
type InspectVarsState = {
conversationVars: VarInInspect[]
systemVars: VarInInspect[]
nodesWithInspectVars: NodeWithVar[]
}
const {
mockEditInspectVarValue,
mockEmit,
mockFetchInspectVarValue,
mockHandleNodeSelect,
mockResetConversationVar,
mockResetToLastRunVar,
mockSetInputs,
} = vi.hoisted(() => ({
mockEditInspectVarValue: vi.fn(),
mockEmit: vi.fn(),
mockFetchInspectVarValue: vi.fn(),
mockHandleNodeSelect: vi.fn(),
mockResetConversationVar: vi.fn(),
mockResetToLastRunVar: vi.fn(),
mockSetInputs: vi.fn(),
}))
let inspectVarsState: InspectVarsState
vi.mock('../../hooks/use-inspect-vars-crud', () => ({
default: () => ({
...inspectVarsState,
deleteAllInspectorVars: vi.fn(),
deleteNodeInspectorVars: vi.fn(),
editInspectVarValue: mockEditInspectVarValue,
fetchInspectVarValue: mockFetchInspectVarValue,
resetConversationVar: mockResetConversationVar,
resetToLastRunVar: mockResetToLastRunVar,
}),
}))
vi.mock('../../nodes/_base/components/variable/use-match-schema-type', () => ({
default: () => ({
isLoading: false,
schemaTypeDefinitions: {},
}),
}))
vi.mock('../../hooks/use-nodes-interactions', () => ({
useNodesInteractions: () => ({
handleNodeSelect: mockHandleNodeSelect,
}),
}))
vi.mock('../../hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../hooks')>()
return {
...actual,
useNodesInteractions: () => ({
handleNodeSelect: mockHandleNodeSelect,
}),
useToolIcon: () => '',
}
})
vi.mock('../../nodes/_base/hooks/use-node-crud', () => ({
default: () => ({
setInputs: mockSetInputs,
}),
}))
vi.mock('../../nodes/_base/hooks/use-node-info', () => ({
default: () => ({
node: undefined,
}),
}))
vi.mock('../../hooks-store', () => ({
useHooksStore: <T,>(selector: (state: { configsMap?: { flowId: string } }) => T) =>
selector({
configsMap: {
flowId: 'flow-1',
},
}),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
emit: mockEmit,
},
}),
}))
const createEnvironmentVariable = (overrides: Partial<EnvironmentVariable> = {}): EnvironmentVariable => ({
id: 'env-1',
name: 'API_KEY',
value: 'env-value',
value_type: 'string',
description: '',
...overrides,
})
const renderPanel = (initialStoreState: Record<string, unknown> = {}) => {
return renderWorkflowComponent(
<div style={{ width: 800, height: 600 }}>
<ReactFlowProvider>
<ReactFlow fitView nodes={[]} edges={[]} />
<Panel />
</ReactFlowProvider>
</div>,
{
initialStoreState,
historyStore: {
nodes: [],
edges: [],
},
},
)
}
describe('VariableInspect Panel', () => {
beforeEach(() => {
vi.clearAllMocks()
inspectVarsState = {
conversationVars: [],
systemVars: [],
nodesWithInspectVars: [],
}
})
it('should render the listening state and stop the workflow on demand', () => {
renderPanel({
isListening: true,
listeningTriggerType: BlockEnum.TriggerWebhook,
})
fireEvent.click(screen.getByRole('button', { name: 'workflow.debug.variableInspect.listening.stopButton' }))
expect(screen.getByText('workflow.debug.variableInspect.listening.title')).toBeInTheDocument()
expect(mockEmit).toHaveBeenCalledWith({
type: EVENT_WORKFLOW_STOP,
})
})
it('should render the empty state and close the panel from the header action', () => {
const { store } = renderPanel({
showVariableInspectPanel: true,
})
fireEvent.click(screen.getAllByRole('button')[0])
expect(screen.getByText('workflow.debug.variableInspect.emptyTip')).toBeInTheDocument()
expect(store.getState().showVariableInspectPanel).toBe(false)
})
it('should select an environment variable and show its details in the right panel', async () => {
renderPanel({
environmentVariables: [createEnvironmentVariable()],
bottomPanelWidth: 560,
})
fireEvent.click(screen.getByText('API_KEY'))
await waitFor(() => expect(screen.getAllByText('API_KEY').length).toBeGreaterThan(1))
expect(screen.getByText('workflow.debug.variableInspect.envNode')).toBeInTheDocument()
expect(screen.getAllByText('string').length).toBeGreaterThan(0)
expect(screen.getByText('env-value')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,170 @@
import type { Node as ReactFlowNode } from 'reactflow'
import type { CommonNodeType } from '../../types'
import type { NodeWithVar, VarInInspect } from '@/types/workflow'
import { fireEvent, screen } from '@testing-library/react'
import ReactFlow, { ReactFlowProvider } from 'reactflow'
import { VarInInspectType } from '@/types/workflow'
import { baseRunningData, renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { BlockEnum, NodeRunningStatus, VarType, WorkflowRunningStatus } from '../../types'
import VariableInspectTrigger from '../trigger'
type InspectVarsState = {
conversationVars: VarInInspect[]
systemVars: VarInInspect[]
nodesWithInspectVars: NodeWithVar[]
}
const {
mockDeleteAllInspectorVars,
mockEmit,
} = vi.hoisted(() => ({
mockDeleteAllInspectorVars: vi.fn(),
mockEmit: vi.fn(),
}))
let inspectVarsState: InspectVarsState
vi.mock('../../hooks/use-inspect-vars-crud', () => ({
default: () => ({
...inspectVarsState,
deleteAllInspectorVars: mockDeleteAllInspectorVars,
}),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
emit: mockEmit,
},
}),
}))
const createVariable = (overrides: Partial<VarInInspect> = {}): VarInInspect => ({
id: 'var-1',
type: VarInInspectType.node,
name: 'result',
description: '',
selector: ['node-1', 'result'],
value_type: VarType.string,
value: 'cached',
edited: false,
visible: true,
is_truncated: false,
full_content: {
size_bytes: 0,
download_url: '',
},
...overrides,
})
const createNode = (overrides: Partial<CommonNodeType> = {}): ReactFlowNode<CommonNodeType> => ({
id: 'node-1',
type: 'custom',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.Code,
title: 'Code',
desc: '',
...overrides,
},
})
const renderTrigger = ({
nodes = [createNode()],
initialStoreState = {},
}: {
nodes?: Array<ReactFlowNode<CommonNodeType>>
initialStoreState?: Record<string, unknown>
} = {}) => {
return renderWorkflowComponent(
<div style={{ width: 800, height: 600 }}>
<ReactFlowProvider>
<ReactFlow fitView nodes={nodes} edges={[]} />
<VariableInspectTrigger />
</ReactFlowProvider>
</div>,
{
initialStoreState,
},
)
}
describe('VariableInspectTrigger', () => {
beforeEach(() => {
vi.clearAllMocks()
inspectVarsState = {
conversationVars: [],
systemVars: [],
nodesWithInspectVars: [],
}
})
it('should stay hidden when the variable-inspect panel is already open', () => {
renderTrigger({
initialStoreState: {
showVariableInspectPanel: true,
},
})
expect(screen.queryByText('workflow.debug.variableInspect.trigger.normal')).not.toBeInTheDocument()
})
it('should open the panel from the normal trigger state', () => {
const { store } = renderTrigger()
fireEvent.click(screen.getByText('workflow.debug.variableInspect.trigger.normal'))
expect(store.getState().showVariableInspectPanel).toBe(true)
})
it('should block opening while the workflow is read only', () => {
const { store } = renderTrigger({
initialStoreState: {
isRestoring: true,
},
})
fireEvent.click(screen.getByText('workflow.debug.variableInspect.trigger.normal'))
expect(store.getState().showVariableInspectPanel).toBe(false)
})
it('should clear cached variables and reset the focused node', () => {
inspectVarsState = {
conversationVars: [createVariable({
id: 'conversation-var',
type: VarInInspectType.conversation,
})],
systemVars: [],
nodesWithInspectVars: [],
}
const { store } = renderTrigger({
initialStoreState: {
currentFocusNodeId: 'node-2',
},
})
fireEvent.click(screen.getByText('workflow.debug.variableInspect.trigger.clear'))
expect(screen.getByText('workflow.debug.variableInspect.trigger.cached')).toBeInTheDocument()
expect(mockDeleteAllInspectorVars).toHaveBeenCalledTimes(1)
expect(store.getState().currentFocusNodeId).toBe('')
})
it('should show the running state and open the panel while running', () => {
const { store } = renderTrigger({
nodes: [createNode({ _singleRunningStatus: NodeRunningStatus.Running })],
initialStoreState: {
workflowRunningData: baseRunningData({
result: { status: WorkflowRunningStatus.Running },
}),
},
})
fireEvent.click(screen.getByText('workflow.debug.variableInspect.trigger.running'))
expect(screen.queryByText('workflow.debug.variableInspect.trigger.clear')).not.toBeInTheDocument()
expect(store.getState().showVariableInspectPanel).toBe(true)
})
})

View File

@@ -0,0 +1,47 @@
import { render, waitFor } from '@testing-library/react'
import WorkflowPreview from '../index'
const defaultViewport = {
x: 0,
y: 0,
zoom: 1,
}
describe('WorkflowPreview', () => {
it('should render the preview container with the default left minimap placement', async () => {
const { container } = render(
<div style={{ width: 800, height: 600 }}>
<WorkflowPreview
nodes={[]}
edges={[]}
viewport={defaultViewport}
className="preview-shell"
/>
</div>,
)
await waitFor(() => expect(container.querySelector('.react-flow__minimap')).toBeInTheDocument())
expect(container.querySelector('#workflow-container')).toHaveClass('preview-shell')
expect(container.querySelector('.react-flow__background')).toBeInTheDocument()
expect(container.querySelector('.react-flow__minimap')).toHaveClass('!left-4')
})
it('should move the minimap to the right when requested', async () => {
const { container } = render(
<div style={{ width: 800, height: 600 }}>
<WorkflowPreview
nodes={[]}
edges={[]}
viewport={defaultViewport}
miniMapToRight
/>
</div>,
)
await waitFor(() => expect(container.querySelector('.react-flow__minimap')).toBeInTheDocument())
expect(container.querySelector('.react-flow__minimap')).toHaveClass('!right-4')
expect(container.querySelector('.react-flow__minimap')).not.toHaveClass('!left-4')
})
})