mirror of
https://github.com/langgenius/dify.git
synced 2026-03-17 21:37:03 +00:00
Compare commits
1 Commits
3-18-dev-w
...
test/workf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2ad47463d |
@@ -1,6 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import type { editor as MonacoEditor } from 'modern-monaco/editor-core'
|
||||
import type {
|
||||
IEditorOptions,
|
||||
IStandaloneCodeEditor,
|
||||
ITextModel,
|
||||
} from 'modern-monaco/editor-core'
|
||||
import type { FC } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -13,11 +17,11 @@ type ModernMonacoEditorProps = {
|
||||
value: string
|
||||
language: string
|
||||
readOnly?: boolean
|
||||
options?: MonacoEditor.IEditorOptions
|
||||
options?: IEditorOptions
|
||||
onChange?: (value: string) => void
|
||||
onFocus?: () => void
|
||||
onBlur?: () => void
|
||||
onReady?: (editor: MonacoEditor.IStandaloneCodeEditor, monaco: typeof import('modern-monaco/editor-core')) => void
|
||||
onReady?: (editor: IStandaloneCodeEditor, monaco: typeof import('modern-monaco/editor-core')) => void
|
||||
loading?: React.ReactNode
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
@@ -26,15 +30,15 @@ type ModernMonacoEditorProps = {
|
||||
type MonacoModule = typeof import('modern-monaco/editor-core')
|
||||
type EditorCallbacks = Pick<ModernMonacoEditorProps, 'onBlur' | 'onChange' | 'onFocus' | 'onReady'>
|
||||
type EditorSetup = {
|
||||
editorOptions: MonacoEditor.IEditorOptions
|
||||
editorOptions: IEditorOptions
|
||||
language: string
|
||||
resolvedTheme: string
|
||||
}
|
||||
|
||||
const syncEditorValue = (
|
||||
editor: MonacoEditor.IStandaloneCodeEditor,
|
||||
editor: IStandaloneCodeEditor,
|
||||
monaco: MonacoModule,
|
||||
model: MonacoEditor.ITextModel,
|
||||
model: ITextModel,
|
||||
value: string,
|
||||
preventTriggerChangeEventRef: React.RefObject<boolean>,
|
||||
) => {
|
||||
@@ -62,7 +66,7 @@ const syncEditorValue = (
|
||||
}
|
||||
|
||||
const bindEditorCallbacks = (
|
||||
editor: MonacoEditor.IStandaloneCodeEditor,
|
||||
editor: IStandaloneCodeEditor,
|
||||
monaco: MonacoModule,
|
||||
callbacksRef: React.RefObject<EditorCallbacks>,
|
||||
preventTriggerChangeEventRef: React.RefObject<boolean>,
|
||||
@@ -109,14 +113,14 @@ export const ModernMonacoEditor: FC<ModernMonacoEditorProps> = ({
|
||||
const resolvedTheme = appTheme === Theme.light ? LIGHT_THEME_ID : DARK_THEME_ID
|
||||
const [isEditorReady, setIsEditorReady] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const editorRef = useRef<MonacoEditor.IStandaloneCodeEditor | null>(null)
|
||||
const modelRef = useRef<MonacoEditor.ITextModel | null>(null)
|
||||
const editorRef = useRef<IStandaloneCodeEditor | null>(null)
|
||||
const modelRef = useRef<ITextModel | null>(null)
|
||||
const monacoRef = useRef<MonacoModule | null>(null)
|
||||
const preventTriggerChangeEventRef = useRef(false)
|
||||
const valueRef = useRef(value)
|
||||
const callbacksRef = useRef<EditorCallbacks>({ onChange, onFocus, onBlur, onReady })
|
||||
|
||||
const editorOptions = useMemo<MonacoEditor.IEditorOptions>(() => ({
|
||||
const editorOptions = useMemo<IEditorOptions>(() => ({
|
||||
automaticLayout: true,
|
||||
readOnly,
|
||||
domReadOnly: true,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Node } from '../types'
|
||||
import { screen } from '@testing-library/react'
|
||||
import CandidateNode from '../candidate-node'
|
||||
import { BlockEnum } from '../types'
|
||||
import { renderWorkflowComponent } from './workflow-test-env'
|
||||
|
||||
vi.mock('../candidate-node-main', () => ({
|
||||
default: ({ candidateNode }: { candidateNode: Node }) => (
|
||||
<div data-testid="candidate-node-main">{candidateNode.id}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createCandidateNode = (): Node => ({
|
||||
id: 'candidate-node-1',
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
type: BlockEnum.Start,
|
||||
title: 'Candidate node',
|
||||
desc: 'candidate',
|
||||
},
|
||||
})
|
||||
|
||||
describe('CandidateNode', () => {
|
||||
it('should not render when candidateNode is missing from the workflow store', () => {
|
||||
renderWorkflowComponent(<CandidateNode />)
|
||||
|
||||
expect(screen.queryByTestId('candidate-node-main')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render CandidateNodeMain with the stored candidate node', () => {
|
||||
renderWorkflowComponent(<CandidateNode />, {
|
||||
initialStoreState: {
|
||||
candidateNode: createCandidateNode(),
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('candidate-node-main')).toHaveTextContent('candidate-node-1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { render } from '@testing-library/react'
|
||||
import { getBezierPath, Position } from 'reactflow'
|
||||
import CustomConnectionLine from '../custom-connection-line'
|
||||
|
||||
const createConnectionLineProps = (
|
||||
overrides: Partial<ComponentProps<typeof CustomConnectionLine>> = {},
|
||||
): ComponentProps<typeof CustomConnectionLine> => ({
|
||||
fromX: 10,
|
||||
fromY: 20,
|
||||
toX: 70,
|
||||
toY: 80,
|
||||
fromPosition: Position.Right,
|
||||
toPosition: Position.Left,
|
||||
connectionLineType: undefined,
|
||||
connectionStatus: null,
|
||||
...overrides,
|
||||
} as ComponentProps<typeof CustomConnectionLine>)
|
||||
|
||||
describe('CustomConnectionLine', () => {
|
||||
it('should render the bezier path and target marker', () => {
|
||||
const [expectedPath] = getBezierPath({
|
||||
sourceX: 10,
|
||||
sourceY: 20,
|
||||
sourcePosition: Position.Right,
|
||||
targetX: 70,
|
||||
targetY: 80,
|
||||
targetPosition: Position.Left,
|
||||
curvature: 0.16,
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<CustomConnectionLine {...createConnectionLineProps()} />
|
||||
</svg>,
|
||||
)
|
||||
|
||||
const path = container.querySelector('path')
|
||||
const marker = container.querySelector('rect')
|
||||
|
||||
expect(path).toHaveAttribute('fill', 'none')
|
||||
expect(path).toHaveAttribute('stroke', '#D0D5DD')
|
||||
expect(path).toHaveAttribute('stroke-width', '2')
|
||||
expect(path).toHaveAttribute('d', expectedPath)
|
||||
|
||||
expect(marker).toHaveAttribute('x', '70')
|
||||
expect(marker).toHaveAttribute('y', '76')
|
||||
expect(marker).toHaveAttribute('width', '2')
|
||||
expect(marker).toHaveAttribute('height', '8')
|
||||
expect(marker).toHaveAttribute('fill', '#2970FF')
|
||||
})
|
||||
|
||||
it('should update the path when the endpoints change', () => {
|
||||
const [expectedPath] = getBezierPath({
|
||||
sourceX: 30,
|
||||
sourceY: 40,
|
||||
sourcePosition: Position.Right,
|
||||
targetX: 160,
|
||||
targetY: 200,
|
||||
targetPosition: Position.Left,
|
||||
curvature: 0.16,
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<CustomConnectionLine
|
||||
{...createConnectionLineProps({
|
||||
fromX: 30,
|
||||
fromY: 40,
|
||||
toX: 160,
|
||||
toY: 200,
|
||||
})}
|
||||
/>
|
||||
</svg>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('path')).toHaveAttribute('d', expectedPath)
|
||||
expect(container.querySelector('rect')).toHaveAttribute('x', '160')
|
||||
expect(container.querySelector('rect')).toHaveAttribute('y', '196')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import CustomEdgeLinearGradientRender from '../custom-edge-linear-gradient-render'
|
||||
|
||||
describe('CustomEdgeLinearGradientRender', () => {
|
||||
it('should render gradient definition with the provided id and positions', () => {
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<CustomEdgeLinearGradientRender
|
||||
id="edge-gradient"
|
||||
startColor="#123456"
|
||||
stopColor="#abcdef"
|
||||
position={{
|
||||
x1: 10,
|
||||
y1: 20,
|
||||
x2: 30,
|
||||
y2: 40,
|
||||
}}
|
||||
/>
|
||||
</svg>,
|
||||
)
|
||||
|
||||
const gradient = container.querySelector('linearGradient')
|
||||
expect(gradient).toHaveAttribute('id', 'edge-gradient')
|
||||
expect(gradient).toHaveAttribute('gradientUnits', 'userSpaceOnUse')
|
||||
expect(gradient).toHaveAttribute('x1', '10')
|
||||
expect(gradient).toHaveAttribute('y1', '20')
|
||||
expect(gradient).toHaveAttribute('x2', '30')
|
||||
expect(gradient).toHaveAttribute('y2', '40')
|
||||
})
|
||||
|
||||
it('should render start and stop colors at both ends of the gradient', () => {
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<CustomEdgeLinearGradientRender
|
||||
id="gradient-colors"
|
||||
startColor="#111111"
|
||||
stopColor="#222222"
|
||||
position={{
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: 100,
|
||||
y2: 100,
|
||||
}}
|
||||
/>
|
||||
</svg>,
|
||||
)
|
||||
|
||||
const stops = container.querySelectorAll('stop')
|
||||
expect(stops).toHaveLength(2)
|
||||
expect(stops[0]).toHaveAttribute('offset', '0%')
|
||||
expect(stops[0].getAttribute('style')).toContain('stop-color: rgb(17, 17, 17)')
|
||||
expect(stops[0].getAttribute('style')).toContain('stop-opacity: 1')
|
||||
expect(stops[1]).toHaveAttribute('offset', '100%')
|
||||
expect(stops[1].getAttribute('style')).toContain('stop-color: rgb(34, 34, 34)')
|
||||
expect(stops[1].getAttribute('style')).toContain('stop-opacity: 1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import DSLExportConfirmModal from '../dsl-export-confirm-modal'
|
||||
|
||||
const envList = [
|
||||
{
|
||||
id: 'env-1',
|
||||
name: 'SECRET_TOKEN',
|
||||
value: 'masked-value',
|
||||
value_type: 'secret' as const,
|
||||
description: 'secret token',
|
||||
},
|
||||
]
|
||||
|
||||
const multiEnvList = [
|
||||
...envList,
|
||||
{
|
||||
id: 'env-2',
|
||||
name: 'SERVICE_KEY',
|
||||
value: 'another-secret',
|
||||
value_type: 'secret' as const,
|
||||
description: 'service key',
|
||||
},
|
||||
]
|
||||
|
||||
describe('DSLExportConfirmModal', () => {
|
||||
it('should render environment rows and close when cancel is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onConfirm = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<DSLExportConfirmModal
|
||||
envList={envList}
|
||||
onConfirm={onConfirm}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('SECRET_TOKEN')).toBeInTheDocument()
|
||||
expect(screen.getByText('masked-value')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should confirm with exportSecrets=false by default', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onConfirm = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<DSLExportConfirmModal
|
||||
envList={envList}
|
||||
onConfirm={onConfirm}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.env.export.ignore' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(false)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should confirm with exportSecrets=true after toggling the checkbox', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onConfirm = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<DSLExportConfirmModal
|
||||
envList={envList}
|
||||
onConfirm={onConfirm}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('checkbox'))
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.env.export.export' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(true)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should also toggle exportSecrets when the label text is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onConfirm = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<DSLExportConfirmModal
|
||||
envList={envList}
|
||||
onConfirm={onConfirm}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('workflow.env.export.checkbox'))
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.env.export.export' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(true)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render border separators for all rows except the last one', () => {
|
||||
render(
|
||||
<DSLExportConfirmModal
|
||||
envList={multiEnvList}
|
||||
onConfirm={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const firstNameCell = screen.getByText('SECRET_TOKEN').closest('td')
|
||||
const lastNameCell = screen.getByText('SERVICE_KEY').closest('td')
|
||||
const firstValueCell = screen.getByText('masked-value').closest('td')
|
||||
const lastValueCell = screen.getByText('another-secret').closest('td')
|
||||
|
||||
expect(firstNameCell).toHaveClass('border-b')
|
||||
expect(firstValueCell).toHaveClass('border-b')
|
||||
expect(lastNameCell).not.toHaveClass('border-b')
|
||||
expect(lastValueCell).not.toHaveClass('border-b')
|
||||
})
|
||||
})
|
||||
153
web/app/components/workflow/__tests__/features.spec.tsx
Normal file
153
web/app/components/workflow/__tests__/features.spec.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import Features from '../features'
|
||||
import { InputVarType } from '../types'
|
||||
import { createStartNode } from './fixtures'
|
||||
import { resetReactFlowMockState, rfState } from './reactflow-mock-state'
|
||||
import { renderWorkflowComponent } from './workflow-test-env'
|
||||
|
||||
const mockHandleSyncWorkflowDraft = vi.fn()
|
||||
const mockHandleAddVariable = vi.fn()
|
||||
const mockUseIsChatMode = vi.fn()
|
||||
const mockUseNodesReadOnly = vi.fn()
|
||||
|
||||
vi.mock('reactflow', async () =>
|
||||
(await import('./reactflow-mock-state')).createReactFlowModuleMock())
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useIsChatMode: () => mockUseIsChatMode(),
|
||||
useNodesReadOnly: () => mockUseNodesReadOnly(),
|
||||
useNodesSyncDraft: () => ({
|
||||
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../nodes/start/use-config', () => ({
|
||||
default: () => ({
|
||||
handleAddVariable: mockHandleAddVariable,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/features/new-feature-panel', () => ({
|
||||
default: ({
|
||||
isChatMode,
|
||||
disabled,
|
||||
onChange,
|
||||
onClose,
|
||||
onAutoAddPromptVariable,
|
||||
workflowVariables,
|
||||
}: {
|
||||
isChatMode: boolean
|
||||
disabled: boolean
|
||||
onChange: () => void
|
||||
onClose: () => void
|
||||
onAutoAddPromptVariable: (variables: Array<Record<string, unknown>>) => void
|
||||
workflowVariables: Array<Record<string, unknown>>
|
||||
}) => (
|
||||
<div>
|
||||
<div data-testid="features-props">
|
||||
{JSON.stringify({
|
||||
isChatMode,
|
||||
disabled,
|
||||
workflowVariables,
|
||||
})}
|
||||
</div>
|
||||
<button type="button" onClick={onChange}>change</button>
|
||||
<button type="button" onClick={onClose}>close</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAutoAddPromptVariable([{
|
||||
key: 'opening_statement',
|
||||
name: 'Opening Statement',
|
||||
max_length: 200,
|
||||
required: true,
|
||||
}])}
|
||||
>
|
||||
add-variable
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAutoAddPromptVariable([{
|
||||
key: 'optional_statement',
|
||||
name: 'Optional Statement',
|
||||
max_length: 120,
|
||||
}])}
|
||||
>
|
||||
add-variable-optional
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('Features', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetReactFlowMockState()
|
||||
mockUseIsChatMode.mockReturnValue(true)
|
||||
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false })
|
||||
rfState.nodes = [
|
||||
createStartNode({
|
||||
id: 'start-node',
|
||||
data: {
|
||||
variables: [{ variable: 'existing_variable', label: 'Existing Variable' }],
|
||||
},
|
||||
}),
|
||||
]
|
||||
})
|
||||
|
||||
it('should pass chat mode, disabled state, and workflow variables to NewFeaturePanel', () => {
|
||||
renderWorkflowComponent(<Features />)
|
||||
|
||||
expect(screen.getByTestId('features-props')).toHaveTextContent('"isChatMode":true')
|
||||
expect(screen.getByTestId('features-props')).toHaveTextContent('"disabled":false')
|
||||
expect(screen.getByTestId('features-props')).toHaveTextContent('"variable":"existing_variable"')
|
||||
})
|
||||
|
||||
it('should sync draft and open the features panel when the panel changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { store } = renderWorkflowComponent(<Features />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'change' }))
|
||||
|
||||
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().showFeaturesPanel).toBe(true)
|
||||
})
|
||||
|
||||
it('should close the features panel through the workflow store and transform prompt variables', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { store } = renderWorkflowComponent(<Features />, {
|
||||
initialStoreState: {
|
||||
showFeaturesPanel: true,
|
||||
},
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'close' }))
|
||||
expect(store.getState().showFeaturesPanel).toBe(false)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'add-variable' }))
|
||||
expect(mockHandleAddVariable).toHaveBeenCalledWith({
|
||||
variable: 'opening_statement',
|
||||
label: 'Opening Statement',
|
||||
type: InputVarType.textInput,
|
||||
max_length: 200,
|
||||
required: true,
|
||||
options: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('should default required to false when the prompt variable does not provide it', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderWorkflowComponent(<Features />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'add-variable-optional' }))
|
||||
expect(mockHandleAddVariable).toHaveBeenCalledWith({
|
||||
variable: 'optional_statement',
|
||||
label: 'Optional Statement',
|
||||
type: InputVarType.textInput,
|
||||
max_length: 120,
|
||||
required: false,
|
||||
options: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -16,8 +16,8 @@ import * as React from 'react'
|
||||
type MockNode = {
|
||||
id: string
|
||||
position: { x: number, y: number }
|
||||
width?: number
|
||||
height?: number
|
||||
width?: number | null
|
||||
height?: number | null
|
||||
parentId?: string
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import SyncingDataModal from '../syncing-data-modal'
|
||||
import { renderWorkflowComponent } from './workflow-test-env'
|
||||
|
||||
describe('SyncingDataModal', () => {
|
||||
it('should not render when workflow draft syncing is disabled', () => {
|
||||
const { container } = renderWorkflowComponent(<SyncingDataModal />)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should render the fullscreen overlay when workflow draft syncing is enabled', () => {
|
||||
const { container } = renderWorkflowComponent(<SyncingDataModal />, {
|
||||
initialStoreState: {
|
||||
isSyncingWorkflowDraft: true,
|
||||
},
|
||||
})
|
||||
|
||||
const overlay = container.firstElementChild
|
||||
expect(overlay).toHaveClass('absolute', 'inset-0')
|
||||
expect(overlay).toHaveClass('z-[9999]')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import type * as React from 'react'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import useCheckVerticalScrollbar from '../use-check-vertical-scrollbar'
|
||||
|
||||
const resizeObserve = vi.fn()
|
||||
const resizeDisconnect = vi.fn()
|
||||
const mutationObserve = vi.fn()
|
||||
const mutationDisconnect = vi.fn()
|
||||
|
||||
let resizeCallback: ResizeObserverCallback | null = null
|
||||
let mutationCallback: MutationCallback | null = null
|
||||
|
||||
class MockResizeObserver implements ResizeObserver {
|
||||
observe = resizeObserve
|
||||
unobserve = vi.fn()
|
||||
disconnect = resizeDisconnect
|
||||
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeCallback = callback
|
||||
}
|
||||
}
|
||||
|
||||
class MockMutationObserver implements MutationObserver {
|
||||
observe = mutationObserve
|
||||
disconnect = mutationDisconnect
|
||||
takeRecords = vi.fn(() => [])
|
||||
|
||||
constructor(callback: MutationCallback) {
|
||||
mutationCallback = callback
|
||||
}
|
||||
}
|
||||
|
||||
const setElementHeights = (element: HTMLElement, scrollHeight: number, clientHeight: number) => {
|
||||
Object.defineProperty(element, 'scrollHeight', {
|
||||
configurable: true,
|
||||
value: scrollHeight,
|
||||
})
|
||||
Object.defineProperty(element, 'clientHeight', {
|
||||
configurable: true,
|
||||
value: clientHeight,
|
||||
})
|
||||
}
|
||||
|
||||
describe('useCheckVerticalScrollbar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resizeCallback = null
|
||||
mutationCallback = null
|
||||
globalThis.ResizeObserver = MockResizeObserver
|
||||
globalThis.MutationObserver = MockMutationObserver
|
||||
})
|
||||
|
||||
it('should return false when the element ref is empty', () => {
|
||||
const ref = { current: null } as React.RefObject<HTMLElement | null>
|
||||
|
||||
const { result } = renderHook(() => useCheckVerticalScrollbar(ref))
|
||||
|
||||
expect(result.current).toBe(false)
|
||||
expect(resizeObserve).not.toHaveBeenCalled()
|
||||
expect(mutationObserve).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should detect the initial scrollbar state and react to observer updates', () => {
|
||||
const element = document.createElement('div')
|
||||
setElementHeights(element, 200, 100)
|
||||
const ref = { current: element } as React.RefObject<HTMLElement | null>
|
||||
|
||||
const { result } = renderHook(() => useCheckVerticalScrollbar(ref))
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
expect(resizeObserve).toHaveBeenCalledWith(element)
|
||||
expect(mutationObserve).toHaveBeenCalledWith(element, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
})
|
||||
|
||||
setElementHeights(element, 100, 100)
|
||||
act(() => {
|
||||
resizeCallback?.([] as ResizeObserverEntry[], new MockResizeObserver(() => {}))
|
||||
})
|
||||
|
||||
expect(result.current).toBe(false)
|
||||
|
||||
setElementHeights(element, 180, 100)
|
||||
act(() => {
|
||||
mutationCallback?.([] as MutationRecord[], new MockMutationObserver(() => {}))
|
||||
})
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('should disconnect observers on unmount', () => {
|
||||
const element = document.createElement('div')
|
||||
setElementHeights(element, 120, 100)
|
||||
const ref = { current: element } as React.RefObject<HTMLElement | null>
|
||||
|
||||
const { unmount } = renderHook(() => useCheckVerticalScrollbar(ref))
|
||||
unmount()
|
||||
|
||||
expect(resizeDisconnect).toHaveBeenCalledTimes(1)
|
||||
expect(mutationDisconnect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import type * as React from 'react'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import useStickyScroll, { ScrollPosition } from '../use-sticky-scroll'
|
||||
|
||||
const setRect = (element: HTMLElement, top: number, height: number) => {
|
||||
element.getBoundingClientRect = vi.fn(() => new DOMRect(0, top, 100, height))
|
||||
}
|
||||
|
||||
describe('useStickyScroll', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const runScroll = (handleScroll: () => void) => {
|
||||
act(() => {
|
||||
handleScroll()
|
||||
vi.advanceTimersByTime(120)
|
||||
})
|
||||
}
|
||||
|
||||
it('should keep the default state when refs are missing', () => {
|
||||
const wrapElemRef = { current: null } as React.RefObject<HTMLElement | null>
|
||||
const nextToStickyELemRef = { current: null } as React.RefObject<HTMLElement | null>
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useStickyScroll({
|
||||
wrapElemRef,
|
||||
nextToStickyELemRef,
|
||||
}),
|
||||
)
|
||||
|
||||
runScroll(result.current.handleScroll)
|
||||
|
||||
expect(result.current.scrollPosition).toBe(ScrollPosition.belowTheWrap)
|
||||
})
|
||||
|
||||
it('should mark the sticky element as below the wrapper when it is outside the visible area', () => {
|
||||
const wrapElement = document.createElement('div')
|
||||
const nextElement = document.createElement('div')
|
||||
setRect(wrapElement, 100, 200)
|
||||
setRect(nextElement, 320, 20)
|
||||
|
||||
const wrapElemRef = { current: wrapElement } as React.RefObject<HTMLElement | null>
|
||||
const nextToStickyELemRef = { current: nextElement } as React.RefObject<HTMLElement | null>
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useStickyScroll({
|
||||
wrapElemRef,
|
||||
nextToStickyELemRef,
|
||||
}),
|
||||
)
|
||||
|
||||
runScroll(result.current.handleScroll)
|
||||
|
||||
expect(result.current.scrollPosition).toBe(ScrollPosition.belowTheWrap)
|
||||
})
|
||||
|
||||
it('should mark the sticky element as showing when it is within the wrapper', () => {
|
||||
const wrapElement = document.createElement('div')
|
||||
const nextElement = document.createElement('div')
|
||||
setRect(wrapElement, 100, 200)
|
||||
setRect(nextElement, 220, 20)
|
||||
|
||||
const wrapElemRef = { current: wrapElement } as React.RefObject<HTMLElement | null>
|
||||
const nextToStickyELemRef = { current: nextElement } as React.RefObject<HTMLElement | null>
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useStickyScroll({
|
||||
wrapElemRef,
|
||||
nextToStickyELemRef,
|
||||
}),
|
||||
)
|
||||
|
||||
runScroll(result.current.handleScroll)
|
||||
|
||||
expect(result.current.scrollPosition).toBe(ScrollPosition.showing)
|
||||
})
|
||||
|
||||
it('should mark the sticky element as above the wrapper when it has scrolled past the top', () => {
|
||||
const wrapElement = document.createElement('div')
|
||||
const nextElement = document.createElement('div')
|
||||
setRect(wrapElement, 100, 200)
|
||||
setRect(nextElement, 90, 20)
|
||||
|
||||
const wrapElemRef = { current: wrapElement } as React.RefObject<HTMLElement | null>
|
||||
const nextToStickyELemRef = { current: nextElement } as React.RefObject<HTMLElement | null>
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useStickyScroll({
|
||||
wrapElemRef,
|
||||
nextToStickyELemRef,
|
||||
}),
|
||||
)
|
||||
|
||||
runScroll(result.current.handleScroll)
|
||||
|
||||
expect(result.current.scrollPosition).toBe(ScrollPosition.aboveTheWrap)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { DataSourceItem } from '../types'
|
||||
import { transformDataSourceToTool } from '../utils'
|
||||
|
||||
const createLocalizedText = (text: string) => ({
|
||||
en_US: text,
|
||||
zh_Hans: text,
|
||||
})
|
||||
|
||||
const createDataSourceItem = (overrides: Partial<DataSourceItem> = {}): DataSourceItem => ({
|
||||
plugin_id: 'plugin-1',
|
||||
plugin_unique_identifier: 'plugin-1@provider',
|
||||
provider: 'provider-a',
|
||||
declaration: {
|
||||
credentials_schema: [{ name: 'api_key' }],
|
||||
provider_type: 'hosted',
|
||||
identity: {
|
||||
author: 'Dify',
|
||||
description: createLocalizedText('Datasource provider'),
|
||||
icon: 'provider-icon',
|
||||
label: createLocalizedText('Provider A'),
|
||||
name: 'provider-a',
|
||||
tags: ['retrieval', 'storage'],
|
||||
},
|
||||
datasources: [
|
||||
{
|
||||
description: createLocalizedText('Search in documents'),
|
||||
identity: {
|
||||
author: 'Dify',
|
||||
label: createLocalizedText('Document Search'),
|
||||
name: 'document_search',
|
||||
provider: 'provider-a',
|
||||
},
|
||||
parameters: [{ name: 'query', type: 'string' }],
|
||||
output_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
result: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
is_authorized: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('transformDataSourceToTool', () => {
|
||||
it('should map datasource provider fields to tool shape', () => {
|
||||
const dataSourceItem = createDataSourceItem()
|
||||
|
||||
const result = transformDataSourceToTool(dataSourceItem)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id: 'plugin-1',
|
||||
provider: 'provider-a',
|
||||
name: 'provider-a',
|
||||
author: 'Dify',
|
||||
description: createLocalizedText('Datasource provider'),
|
||||
icon: 'provider-icon',
|
||||
label: createLocalizedText('Provider A'),
|
||||
type: 'hosted',
|
||||
allow_delete: true,
|
||||
is_authorized: true,
|
||||
is_team_authorization: true,
|
||||
labels: ['retrieval', 'storage'],
|
||||
plugin_id: 'plugin-1',
|
||||
plugin_unique_identifier: 'plugin-1@provider',
|
||||
credentialsSchema: [{ name: 'api_key' }],
|
||||
meta: { version: '' },
|
||||
})
|
||||
expect(result.team_credentials).toEqual({})
|
||||
expect(result.tools).toEqual([
|
||||
{
|
||||
name: 'document_search',
|
||||
author: 'Dify',
|
||||
label: createLocalizedText('Document Search'),
|
||||
description: createLocalizedText('Search in documents'),
|
||||
parameters: [{ name: 'query', type: 'string' }],
|
||||
labels: [],
|
||||
output_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
result: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should fallback to empty arrays when tags and credentials schema are missing', () => {
|
||||
const baseDataSourceItem = createDataSourceItem()
|
||||
const dataSourceItem = createDataSourceItem({
|
||||
declaration: {
|
||||
...baseDataSourceItem.declaration,
|
||||
credentials_schema: undefined as unknown as DataSourceItem['declaration']['credentials_schema'],
|
||||
identity: {
|
||||
...baseDataSourceItem.declaration.identity,
|
||||
tags: undefined as unknown as DataSourceItem['declaration']['identity']['tags'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result = transformDataSourceToTool(dataSourceItem)
|
||||
|
||||
expect(result.labels).toEqual([])
|
||||
expect(result.credentialsSchema).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { fireEvent, render } from '@testing-library/react'
|
||||
import ViewTypeSelect, { ViewType } from '../view-type-select'
|
||||
|
||||
const getViewOptions = (container: HTMLElement) => {
|
||||
const options = container.firstElementChild?.children
|
||||
if (!options || options.length !== 2)
|
||||
throw new Error('Expected two view options')
|
||||
return [options[0] as HTMLDivElement, options[1] as HTMLDivElement]
|
||||
}
|
||||
|
||||
describe('ViewTypeSelect', () => {
|
||||
it('should highlight the active view type', () => {
|
||||
const onChange = vi.fn()
|
||||
const { container } = render(
|
||||
<ViewTypeSelect
|
||||
viewType={ViewType.flat}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const [flatOption, treeOption] = getViewOptions(container)
|
||||
|
||||
expect(flatOption).toHaveClass('bg-components-segmented-control-item-active-bg')
|
||||
expect(treeOption).toHaveClass('cursor-pointer')
|
||||
})
|
||||
|
||||
it('should call onChange when switching to a different view type', () => {
|
||||
const onChange = vi.fn()
|
||||
const { container } = render(
|
||||
<ViewTypeSelect
|
||||
viewType={ViewType.flat}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const [, treeOption] = getViewOptions(container)
|
||||
fireEvent.click(treeOption)
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(ViewType.tree)
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should ignore clicks on the current view type', () => {
|
||||
const onChange = vi.fn()
|
||||
const { container } = render(
|
||||
<ViewTypeSelect
|
||||
viewType={ViewType.tree}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const [, treeOption] = getViewOptions(container)
|
||||
fireEvent.click(treeOption)
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const useCheckVerticalScrollbar = (ref: React.RefObject<HTMLElement>) => {
|
||||
const useCheckVerticalScrollbar = (ref: React.RefObject<HTMLElement | null>) => {
|
||||
const [hasVerticalScrollbar, setHasVerticalScrollbar] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
|
||||
import EditingTitle from '../editing-title'
|
||||
|
||||
const mockFormatTime = vi.fn()
|
||||
const mockFormatTimeFromNow = vi.fn()
|
||||
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({
|
||||
formatTime: mockFormatTime,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: mockFormatTimeFromNow,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('EditingTitle', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFormatTime.mockReturnValue('08:00:00')
|
||||
mockFormatTimeFromNow.mockReturnValue('2 hours ago')
|
||||
})
|
||||
|
||||
it('should render autosave, published time, and syncing status when the draft has metadata', () => {
|
||||
const { container } = renderWorkflowComponent(<EditingTitle />, {
|
||||
initialStoreState: {
|
||||
draftUpdatedAt: 1_710_000_000_000,
|
||||
publishedAt: 1_710_003_600_000,
|
||||
isSyncingWorkflowDraft: true,
|
||||
maximizeCanvas: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockFormatTime).toHaveBeenCalledWith(1_710_000_000, 'HH:mm:ss')
|
||||
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(1_710_003_600_000)
|
||||
expect(container.firstChild).toHaveClass('ml-2')
|
||||
expect(container).toHaveTextContent('workflow.common.autoSaved')
|
||||
expect(container).toHaveTextContent('08:00:00')
|
||||
expect(container).toHaveTextContent('workflow.common.published')
|
||||
expect(container).toHaveTextContent('2 hours ago')
|
||||
expect(container).toHaveTextContent('workflow.common.syncingData')
|
||||
})
|
||||
|
||||
it('should render unpublished status without autosave metadata when the workflow has not been published', () => {
|
||||
const { container } = renderWorkflowComponent(<EditingTitle />, {
|
||||
initialStoreState: {
|
||||
draftUpdatedAt: 0,
|
||||
publishedAt: 0,
|
||||
isSyncingWorkflowDraft: false,
|
||||
maximizeCanvas: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockFormatTime).not.toHaveBeenCalled()
|
||||
expect(mockFormatTimeFromNow).not.toHaveBeenCalled()
|
||||
expect(container.firstChild).not.toHaveClass('ml-2')
|
||||
expect(container).toHaveTextContent('workflow.common.unpublished')
|
||||
expect(container).not.toHaveTextContent('workflow.common.autoSaved')
|
||||
expect(container).not.toHaveTextContent('workflow.common.syncingData')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
|
||||
import EnvButton from '../env-button'
|
||||
|
||||
const mockCloseAllInputFieldPanels = vi.fn()
|
||||
let mockTheme: 'light' | 'dark' = 'light'
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({
|
||||
theme: mockTheme,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/rag-pipeline/hooks', () => ({
|
||||
useInputFieldPanel: () => ({
|
||||
closeAllInputFieldPanels: mockCloseAllInputFieldPanels,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('EnvButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTheme = 'light'
|
||||
})
|
||||
|
||||
it('should open the environment panel and close the other panels when clicked', () => {
|
||||
const { store } = renderWorkflowComponent(<EnvButton disabled={false} />, {
|
||||
initialStoreState: {
|
||||
showChatVariablePanel: true,
|
||||
showGlobalVariablePanel: true,
|
||||
showDebugAndPreviewPanel: true,
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(store.getState().showEnvPanel).toBe(true)
|
||||
expect(store.getState().showChatVariablePanel).toBe(false)
|
||||
expect(store.getState().showGlobalVariablePanel).toBe(false)
|
||||
expect(store.getState().showDebugAndPreviewPanel).toBe(false)
|
||||
expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should apply the active dark theme styles when the environment panel is visible', () => {
|
||||
mockTheme = 'dark'
|
||||
renderWorkflowComponent(<EnvButton disabled={false} />, {
|
||||
initialStoreState: {
|
||||
showEnvPanel: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-sm')
|
||||
})
|
||||
|
||||
it('should keep the button disabled when the disabled prop is true', () => {
|
||||
const { store } = renderWorkflowComponent(<EnvButton disabled />, {
|
||||
initialStoreState: {
|
||||
showEnvPanel: false,
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
expect(store.getState().showEnvPanel).toBe(false)
|
||||
expect(mockCloseAllInputFieldPanels).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
|
||||
import GlobalVariableButton from '../global-variable-button'
|
||||
|
||||
const mockCloseAllInputFieldPanels = vi.fn()
|
||||
let mockTheme: 'light' | 'dark' = 'light'
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({
|
||||
theme: mockTheme,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/rag-pipeline/hooks', () => ({
|
||||
useInputFieldPanel: () => ({
|
||||
closeAllInputFieldPanels: mockCloseAllInputFieldPanels,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('GlobalVariableButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTheme = 'light'
|
||||
})
|
||||
|
||||
it('should open the global variable panel and close the other panels when clicked', () => {
|
||||
const { store } = renderWorkflowComponent(<GlobalVariableButton disabled={false} />, {
|
||||
initialStoreState: {
|
||||
showEnvPanel: true,
|
||||
showChatVariablePanel: true,
|
||||
showDebugAndPreviewPanel: true,
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(store.getState().showGlobalVariablePanel).toBe(true)
|
||||
expect(store.getState().showEnvPanel).toBe(false)
|
||||
expect(store.getState().showChatVariablePanel).toBe(false)
|
||||
expect(store.getState().showDebugAndPreviewPanel).toBe(false)
|
||||
expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should apply the active dark theme styles when the global variable panel is visible', () => {
|
||||
mockTheme = 'dark'
|
||||
renderWorkflowComponent(<GlobalVariableButton disabled={false} />, {
|
||||
initialStoreState: {
|
||||
showGlobalVariablePanel: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-sm')
|
||||
})
|
||||
|
||||
it('should keep the button disabled when the disabled prop is true', () => {
|
||||
const { store } = renderWorkflowComponent(<GlobalVariableButton disabled />, {
|
||||
initialStoreState: {
|
||||
showGlobalVariablePanel: false,
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
expect(store.getState().showGlobalVariablePanel).toBe(false)
|
||||
expect(mockCloseAllInputFieldPanels).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { VersionHistory } from '@/types/workflow'
|
||||
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
|
||||
import { WorkflowVersion } from '../../types'
|
||||
import RestoringTitle from '../restoring-title'
|
||||
|
||||
const mockFormatTime = vi.fn()
|
||||
const mockFormatTimeFromNow = vi.fn()
|
||||
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({
|
||||
formatTime: mockFormatTime,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: mockFormatTimeFromNow,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createVersion = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({
|
||||
id: 'version-1',
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
},
|
||||
created_at: 1_700_000_000,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
},
|
||||
hash: 'hash-1',
|
||||
updated_at: 1_700_000_100,
|
||||
updated_by: {
|
||||
id: 'user-2',
|
||||
name: 'Bob',
|
||||
email: 'bob@example.com',
|
||||
},
|
||||
tool_published: false,
|
||||
version: 'v1',
|
||||
marked_name: 'Release 1',
|
||||
marked_comment: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('RestoringTitle', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFormatTime.mockReturnValue('09:30:00')
|
||||
mockFormatTimeFromNow.mockReturnValue('3 hours ago')
|
||||
})
|
||||
|
||||
it('should render draft metadata when the current version is a draft', () => {
|
||||
const currentVersion = createVersion({
|
||||
version: WorkflowVersion.Draft,
|
||||
})
|
||||
|
||||
const { container } = renderWorkflowComponent(<RestoringTitle />, {
|
||||
initialStoreState: {
|
||||
currentVersion,
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(currentVersion.updated_at * 1000)
|
||||
expect(mockFormatTime).toHaveBeenCalledWith(currentVersion.created_at, 'HH:mm:ss')
|
||||
expect(container).toHaveTextContent('workflow.versionHistory.currentDraft')
|
||||
expect(container).toHaveTextContent('workflow.common.viewOnly')
|
||||
expect(container).toHaveTextContent('workflow.common.unpublished')
|
||||
expect(container).toHaveTextContent('3 hours ago 09:30:00')
|
||||
expect(container).toHaveTextContent('Alice')
|
||||
})
|
||||
|
||||
it('should render published metadata and fallback version name when the marked name is empty', () => {
|
||||
const currentVersion = createVersion({
|
||||
marked_name: '',
|
||||
})
|
||||
|
||||
const { container } = renderWorkflowComponent(<RestoringTitle />, {
|
||||
initialStoreState: {
|
||||
currentVersion,
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(currentVersion.created_at * 1000)
|
||||
expect(container).toHaveTextContent('workflow.versionHistory.defaultName')
|
||||
expect(container).toHaveTextContent('workflow.common.published')
|
||||
expect(container).toHaveTextContent('Alice')
|
||||
})
|
||||
|
||||
it('should render an empty creator name when the version creator name is missing', () => {
|
||||
const currentVersion = createVersion({
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: '',
|
||||
email: 'alice@example.com',
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = renderWorkflowComponent(<RestoringTitle />, {
|
||||
initialStoreState: {
|
||||
currentVersion,
|
||||
},
|
||||
})
|
||||
|
||||
expect(container).toHaveTextContent('workflow.common.published')
|
||||
expect(container).not.toHaveTextContent('Alice')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
|
||||
import RunningTitle from '../running-title'
|
||||
|
||||
let mockIsChatMode = false
|
||||
const mockFormatWorkflowRunIdentifier = vi.fn()
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
useIsChatMode: () => mockIsChatMode,
|
||||
}))
|
||||
|
||||
vi.mock('../../utils', () => ({
|
||||
formatWorkflowRunIdentifier: (finishedAt?: number) => mockFormatWorkflowRunIdentifier(finishedAt),
|
||||
}))
|
||||
|
||||
describe('RunningTitle', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsChatMode = false
|
||||
mockFormatWorkflowRunIdentifier.mockReturnValue(' (14:30:25)')
|
||||
})
|
||||
|
||||
it('should render the test run title in workflow mode', () => {
|
||||
const { container } = renderWorkflowComponent(<RunningTitle />, {
|
||||
initialStoreState: {
|
||||
historyWorkflowData: {
|
||||
id: 'history-1',
|
||||
status: 'succeeded',
|
||||
finished_at: 1_700_000_000,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockFormatWorkflowRunIdentifier).toHaveBeenCalledWith(1_700_000_000)
|
||||
expect(container).toHaveTextContent('Test Run (14:30:25)')
|
||||
expect(container).toHaveTextContent('workflow.common.viewOnly')
|
||||
})
|
||||
|
||||
it('should render the test chat title in chat mode', () => {
|
||||
mockIsChatMode = true
|
||||
|
||||
const { container } = renderWorkflowComponent(<RunningTitle />, {
|
||||
initialStoreState: {
|
||||
historyWorkflowData: {
|
||||
id: 'history-2',
|
||||
status: 'running',
|
||||
finished_at: undefined,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockFormatWorkflowRunIdentifier).toHaveBeenCalledWith(undefined)
|
||||
expect(container).toHaveTextContent('Test Chat (14:30:25)')
|
||||
})
|
||||
|
||||
it('should handle missing workflow history data', () => {
|
||||
const { container } = renderWorkflowComponent(<RunningTitle />)
|
||||
|
||||
expect(mockFormatWorkflowRunIdentifier).toHaveBeenCalledWith(undefined)
|
||||
expect(container).toHaveTextContent('Test Run (14:30:25)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { createNode } from '../../__tests__/fixtures'
|
||||
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
|
||||
import ScrollToSelectedNodeButton from '../scroll-to-selected-node-button'
|
||||
|
||||
const mockScrollToWorkflowNode = vi.fn()
|
||||
|
||||
vi.mock('reactflow', async () =>
|
||||
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
|
||||
|
||||
vi.mock('../../utils/node-navigation', () => ({
|
||||
scrollToWorkflowNode: (nodeId: string) => mockScrollToWorkflowNode(nodeId),
|
||||
}))
|
||||
|
||||
describe('ScrollToSelectedNodeButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetReactFlowMockState()
|
||||
})
|
||||
|
||||
it('should render nothing when there is no selected node', () => {
|
||||
rfState.nodes = [
|
||||
createNode({
|
||||
id: 'node-1',
|
||||
data: { selected: false },
|
||||
}),
|
||||
]
|
||||
|
||||
const { container } = render(<ScrollToSelectedNodeButton />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should render the action and scroll to the selected node when clicked', () => {
|
||||
rfState.nodes = [
|
||||
createNode({
|
||||
id: 'node-1',
|
||||
data: { selected: false },
|
||||
}),
|
||||
createNode({
|
||||
id: 'node-2',
|
||||
data: { selected: true },
|
||||
}),
|
||||
]
|
||||
|
||||
render(<ScrollToSelectedNodeButton />)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.panel.scrollToSelectedNode'))
|
||||
|
||||
expect(mockScrollToWorkflowNode).toHaveBeenCalledWith('node-2')
|
||||
expect(mockScrollToWorkflowNode).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import VersionHistoryButton from '../version-history-button'
|
||||
|
||||
let mockTheme: 'light' | 'dark' = 'light'
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({
|
||||
theme: mockTheme,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../utils')>()
|
||||
return {
|
||||
...actual,
|
||||
getKeyboardKeyCodeBySystem: () => 'ctrl',
|
||||
}
|
||||
})
|
||||
|
||||
describe('VersionHistoryButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTheme = 'light'
|
||||
})
|
||||
|
||||
it('should call onClick when the button is clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
render(<VersionHistoryButton onClick={onClick} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should trigger onClick when the version history shortcut is pressed', () => {
|
||||
const onClick = vi.fn()
|
||||
render(<VersionHistoryButton onClick={onClick} />)
|
||||
|
||||
const keyboardEvent = new KeyboardEvent('keydown', {
|
||||
key: 'H',
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
Object.defineProperty(keyboardEvent, 'keyCode', { value: 72 })
|
||||
Object.defineProperty(keyboardEvent, 'which', { value: 72 })
|
||||
window.dispatchEvent(keyboardEvent)
|
||||
|
||||
expect(keyboardEvent.defaultPrevented).toBe(true)
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render the tooltip popup content on hover', async () => {
|
||||
render(<VersionHistoryButton onClick={vi.fn()} />)
|
||||
|
||||
fireEvent.mouseEnter(screen.getByRole('button'))
|
||||
|
||||
expect(await screen.findByText('workflow.common.versionHistory')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply dark theme styles when the theme is dark', () => {
|
||||
mockTheme = 'dark'
|
||||
render(<VersionHistoryButton onClick={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-sm')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { FC } from 'react'
|
||||
import type { CommonNodeType } from '../types'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNodes } from 'reactflow'
|
||||
import { cn } from '@/utils/classnames'
|
||||
@@ -11,21 +10,15 @@ const ScrollToSelectedNodeButton: FC = () => {
|
||||
const nodes = useNodes<CommonNodeType>()
|
||||
const selectedNode = nodes.find(node => node.data.selected)
|
||||
|
||||
const handleScrollToSelectedNode = useCallback(() => {
|
||||
if (!selectedNode)
|
||||
return
|
||||
scrollToWorkflowNode(selectedNode.id)
|
||||
}, [selectedNode])
|
||||
|
||||
if (!selectedNode)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'system-xs-medium flex h-6 cursor-pointer items-center justify-center whitespace-nowrap rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-3 text-text-tertiary shadow-lg backdrop-blur-sm transition-colors duration-200 hover:text-text-accent',
|
||||
'flex h-6 cursor-pointer items-center justify-center whitespace-nowrap rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-3 text-text-tertiary shadow-lg backdrop-blur-sm transition-colors duration-200 system-xs-medium hover:text-text-accent',
|
||||
)}
|
||||
onClick={handleScrollToSelectedNode}
|
||||
onClick={() => scrollToWorkflowNode(selectedNode.id)}
|
||||
>
|
||||
{t('panel.scrollToSelectedNode', { ns: 'workflow' })}
|
||||
</div>
|
||||
|
||||
71
web/types/modern-monaco-shim.ts
Normal file
71
web/types/modern-monaco-shim.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
declare module 'modern-monaco/editor-core' {
|
||||
export type IRange = unknown
|
||||
|
||||
export type IEditorOptions = {
|
||||
automaticLayout?: boolean
|
||||
readOnly?: boolean
|
||||
domReadOnly?: boolean
|
||||
minimap?: {
|
||||
enabled?: boolean
|
||||
}
|
||||
wordWrap?: 'off' | 'on' | 'wordWrapColumn' | 'bounded'
|
||||
fixedOverflowWidgets?: boolean
|
||||
tabFocusMode?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type ITextModel = {
|
||||
getValue: () => string
|
||||
getFullModelRange: () => IRange
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
export type IKeyboardEvent = {
|
||||
browserEvent: KeyboardEvent
|
||||
stopPropagation: () => void
|
||||
}
|
||||
|
||||
export type IDisposable = {
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
export type IStandaloneCodeEditor = {
|
||||
getOption: (option: unknown) => boolean
|
||||
setValue: (value: string) => void
|
||||
executeEdits: (source: string, edits: Array<{
|
||||
range: IRange
|
||||
text: string
|
||||
forceMoveMarkers?: boolean
|
||||
}>) => void
|
||||
pushUndoStop: () => void
|
||||
onDidChangeModelContent: (listener: () => void) => IDisposable
|
||||
onKeyDown: (listener: (event: IKeyboardEvent) => void) => IDisposable
|
||||
onDidFocusEditorText: (listener: () => void) => IDisposable
|
||||
onDidBlurEditorText: (listener: () => void) => IDisposable
|
||||
getValue: () => string
|
||||
updateOptions: (options: IEditorOptions) => void
|
||||
setModel: (model: ITextModel | null) => void
|
||||
layout: () => void
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
export const editor: {
|
||||
EditorOption: {
|
||||
readOnly: unknown
|
||||
}
|
||||
create: (container: HTMLElement, options?: IEditorOptions) => IStandaloneCodeEditor
|
||||
createModel: (value: string, language?: string) => ITextModel
|
||||
setTheme: (theme: string) => void
|
||||
setModelLanguage: (model: ITextModel, language: string) => void
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'modern-monaco' {
|
||||
export type InitOptions = {
|
||||
defaultTheme: string
|
||||
themes: string[]
|
||||
langs: string[]
|
||||
}
|
||||
|
||||
export const init: (options: InitOptions) => Promise<typeof import('modern-monaco/editor-core') | null>
|
||||
}
|
||||
Reference in New Issue
Block a user