mirror of
https://github.com/langgenius/dify.git
synced 2026-03-18 13:57:03 +00:00
Compare commits
6 Commits
optional-p
...
test/workf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37da9fde67 | ||
|
|
1a043c8406 | ||
|
|
509b8dd1b7 | ||
|
|
db4deb1d6b | ||
|
|
387e5a345f | ||
|
|
116cc22019 |
@@ -70,7 +70,14 @@ def handle_webhook(webhook_id: str):
|
||||
|
||||
@bp.route("/webhook-debug/<string:webhook_id>", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"])
|
||||
def handle_webhook_debug(webhook_id: str):
|
||||
"""Handle webhook debug calls without triggering production workflow execution."""
|
||||
"""Handle webhook debug calls without triggering production workflow execution.
|
||||
|
||||
The debug webhook endpoint is only for draft inspection flows. It never enqueues
|
||||
Celery work for the published workflow; instead it dispatches an in-memory debug
|
||||
event to an active Variable Inspector listener. Returning a clear error when no
|
||||
listener is registered prevents a misleading 200 response for requests that are
|
||||
effectively dropped.
|
||||
"""
|
||||
try:
|
||||
webhook_trigger, _, node_config, webhook_data, error = _prepare_webhook_execution(webhook_id, is_debug=True)
|
||||
if error:
|
||||
@@ -94,11 +101,32 @@ def handle_webhook_debug(webhook_id: str):
|
||||
"method": webhook_data.get("method"),
|
||||
},
|
||||
)
|
||||
TriggerDebugEventBus.dispatch(
|
||||
dispatch_count = TriggerDebugEventBus.dispatch(
|
||||
tenant_id=webhook_trigger.tenant_id,
|
||||
event=event,
|
||||
pool_key=pool_key,
|
||||
)
|
||||
if dispatch_count == 0:
|
||||
logger.warning(
|
||||
"Webhook debug request dropped without an active listener for webhook %s (tenant=%s, app=%s, node=%s)",
|
||||
webhook_trigger.webhook_id,
|
||||
webhook_trigger.tenant_id,
|
||||
webhook_trigger.app_id,
|
||||
webhook_trigger.node_id,
|
||||
)
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": "No active debug listener",
|
||||
"message": (
|
||||
"The webhook debug URL only works while the Variable Inspector is listening. "
|
||||
"Use the published webhook URL to execute the workflow in Celery."
|
||||
),
|
||||
"execution_url": webhook_trigger.webhook_url,
|
||||
}
|
||||
),
|
||||
409,
|
||||
)
|
||||
response_data, status_code = WebhookService.generate_webhook_response(node_config)
|
||||
return jsonify(response_data), status_code
|
||||
|
||||
|
||||
@@ -11,6 +11,13 @@ class CreatorUserRole(StrEnum):
|
||||
ACCOUNT = "account"
|
||||
END_USER = "end_user"
|
||||
|
||||
@classmethod
|
||||
def _missing_(cls, value):
|
||||
if value == "end-user":
|
||||
return cls.END_USER
|
||||
else:
|
||||
return super()._missing_(value)
|
||||
|
||||
|
||||
class WorkflowRunTriggeredFrom(StrEnum):
|
||||
DEBUGGING = "debugging"
|
||||
|
||||
@@ -23,6 +23,7 @@ def mock_jsonify():
|
||||
|
||||
class DummyWebhookTrigger:
|
||||
webhook_id = "wh-1"
|
||||
webhook_url = "http://localhost:5001/triggers/webhook/wh-1"
|
||||
tenant_id = "tenant-1"
|
||||
app_id = "app-1"
|
||||
node_id = "node-1"
|
||||
@@ -104,7 +105,32 @@ class TestHandleWebhookDebug:
|
||||
@patch.object(module.WebhookService, "get_webhook_trigger_and_workflow")
|
||||
@patch.object(module.WebhookService, "extract_and_validate_webhook_data")
|
||||
@patch.object(module.WebhookService, "build_workflow_inputs", return_value={"x": 1})
|
||||
@patch.object(module.TriggerDebugEventBus, "dispatch")
|
||||
@patch.object(module.TriggerDebugEventBus, "dispatch", return_value=0)
|
||||
def test_debug_requires_active_listener(
|
||||
self,
|
||||
mock_dispatch,
|
||||
mock_build_inputs,
|
||||
mock_extract,
|
||||
mock_get,
|
||||
):
|
||||
mock_get.return_value = (DummyWebhookTrigger(), None, "node_config")
|
||||
mock_extract.return_value = {"method": "POST"}
|
||||
|
||||
response, status = module.handle_webhook_debug("wh-1")
|
||||
|
||||
assert status == 409
|
||||
assert response["error"] == "No active debug listener"
|
||||
assert response["message"] == (
|
||||
"The webhook debug URL only works while the Variable Inspector is listening. "
|
||||
"Use the published webhook URL to execute the workflow in Celery."
|
||||
)
|
||||
assert response["execution_url"] == DummyWebhookTrigger.webhook_url
|
||||
mock_dispatch.assert_called_once()
|
||||
|
||||
@patch.object(module.WebhookService, "get_webhook_trigger_and_workflow")
|
||||
@patch.object(module.WebhookService, "extract_and_validate_webhook_data")
|
||||
@patch.object(module.WebhookService, "build_workflow_inputs", return_value={"x": 1})
|
||||
@patch.object(module.TriggerDebugEventBus, "dispatch", return_value=1)
|
||||
@patch.object(module.WebhookService, "generate_webhook_response")
|
||||
def test_debug_success(
|
||||
self,
|
||||
|
||||
19
api/tests/unit_tests/models/test_enums_creator_user_role.py
Normal file
19
api/tests/unit_tests/models/test_enums_creator_user_role.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import pytest
|
||||
|
||||
from models.enums import CreatorUserRole
|
||||
|
||||
|
||||
def test_creator_user_role_missing_maps_hyphen_to_enum():
|
||||
# given an alias with hyphen
|
||||
value = "end-user"
|
||||
|
||||
# when converting to enum (invokes StrEnum._missing_ override)
|
||||
role = CreatorUserRole(value)
|
||||
|
||||
# then it should map to END_USER
|
||||
assert role is CreatorUserRole.END_USER
|
||||
|
||||
|
||||
def test_creator_user_role_missing_raises_for_unknown():
|
||||
with pytest.raises(ValueError):
|
||||
CreatorUserRole("unknown")
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
193
web/app/components/workflow/__tests__/features.spec.tsx
Normal file
193
web/app/components/workflow/__tests__/features.spec.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import type { InputVar } from '../types'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ReactFlow, { ReactFlowProvider, useNodes } from 'reactflow'
|
||||
import Features from '../features'
|
||||
import { InputVarType } from '../types'
|
||||
import { createStartNode } from './fixtures'
|
||||
import { renderWorkflowComponent } from './workflow-test-env'
|
||||
|
||||
const mockHandleSyncWorkflowDraft = vi.fn()
|
||||
const mockHandleAddVariable = vi.fn()
|
||||
|
||||
let mockIsChatMode = true
|
||||
let mockNodesReadOnly = false
|
||||
|
||||
vi.mock('../hooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
|
||||
return {
|
||||
...actual,
|
||||
useIsChatMode: () => mockIsChatMode,
|
||||
useNodesReadOnly: () => ({
|
||||
nodesReadOnly: mockNodesReadOnly,
|
||||
}),
|
||||
useNodesSyncDraft: () => ({
|
||||
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../nodes/start/use-config', () => ({
|
||||
default: () => ({
|
||||
handleAddVariable: mockHandleAddVariable,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/features/new-feature-panel', () => ({
|
||||
default: ({
|
||||
show,
|
||||
isChatMode,
|
||||
disabled,
|
||||
onChange,
|
||||
onClose,
|
||||
onAutoAddPromptVariable,
|
||||
workflowVariables,
|
||||
}: {
|
||||
show: boolean
|
||||
isChatMode: boolean
|
||||
disabled: boolean
|
||||
onChange: () => void
|
||||
onClose: () => void
|
||||
onAutoAddPromptVariable: (variables: PromptVariable[]) => void
|
||||
workflowVariables: InputVar[]
|
||||
}) => {
|
||||
if (!show)
|
||||
return null
|
||||
|
||||
return (
|
||||
<section aria-label="new feature panel">
|
||||
<div>{isChatMode ? 'chat mode' : 'completion mode'}</div>
|
||||
<div>{disabled ? 'panel disabled' : 'panel enabled'}</div>
|
||||
<ul aria-label="workflow variables">
|
||||
{workflowVariables.map(variable => (
|
||||
<li key={variable.variable}>
|
||||
{`${variable.label}:${variable.variable}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button type="button" onClick={onChange}>open features</button>
|
||||
<button type="button" onClick={onClose}>close features</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAutoAddPromptVariable([{
|
||||
key: 'opening_statement',
|
||||
name: 'Opening Statement',
|
||||
type: 'string',
|
||||
max_length: 200,
|
||||
required: true,
|
||||
}])}
|
||||
>
|
||||
add required variable
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAutoAddPromptVariable([{
|
||||
key: 'optional_statement',
|
||||
name: 'Optional Statement',
|
||||
type: 'string',
|
||||
max_length: 120,
|
||||
}])}
|
||||
>
|
||||
add optional variable
|
||||
</button>
|
||||
</section>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const startNode = createStartNode({
|
||||
id: 'start-node',
|
||||
data: {
|
||||
variables: [{ variable: 'existing_variable', label: 'Existing Variable' }],
|
||||
},
|
||||
})
|
||||
|
||||
const DelayedFeatures = () => {
|
||||
const nodes = useNodes()
|
||||
|
||||
if (!nodes.length)
|
||||
return null
|
||||
|
||||
return <Features />
|
||||
}
|
||||
|
||||
const renderFeatures = (options?: Parameters<typeof renderWorkflowComponent>[1]) => {
|
||||
return renderWorkflowComponent(
|
||||
<div style={{ width: 800, height: 600 }}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow nodes={[startNode]} edges={[]} fitView />
|
||||
<DelayedFeatures />
|
||||
</ReactFlowProvider>
|
||||
</div>,
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Features', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsChatMode = true
|
||||
mockNodesReadOnly = false
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should pass workflow context to the feature panel', () => {
|
||||
renderFeatures()
|
||||
|
||||
expect(screen.getByText('chat mode')).toBeInTheDocument()
|
||||
expect(screen.getByText('panel enabled')).toBeInTheDocument()
|
||||
expect(screen.getByRole('list', { name: 'workflow variables' })).toHaveTextContent('Existing Variable:existing_variable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should sync the draft and open the workflow feature panel when users change features', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { store } = renderFeatures()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'open features' }))
|
||||
|
||||
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().showFeaturesPanel).toBe(true)
|
||||
})
|
||||
|
||||
it('should close the workflow feature panel and transform required prompt variables', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { store } = renderFeatures({
|
||||
initialStoreState: {
|
||||
showFeaturesPanel: true,
|
||||
},
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'close features' }))
|
||||
expect(store.getState().showFeaturesPanel).toBe(false)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'add required variable' }))
|
||||
expect(mockHandleAddVariable).toHaveBeenCalledWith({
|
||||
variable: 'opening_statement',
|
||||
label: 'Opening Statement',
|
||||
type: InputVarType.textInput,
|
||||
max_length: 200,
|
||||
required: true,
|
||||
options: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('should default prompt variables to optional when required is omitted', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderFeatures()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'add optional variable' }))
|
||||
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,108 @@
|
||||
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
|
||||
vi.stubGlobal('ResizeObserver', MockResizeObserver)
|
||||
vi.stubGlobal('MutationObserver', MockMutationObserver)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
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,59 @@
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
|
||||
import ChatVariableButton from '../chat-variable-button'
|
||||
|
||||
let mockTheme: 'light' | 'dark' = 'light'
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({
|
||||
theme: mockTheme,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('ChatVariableButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTheme = 'light'
|
||||
})
|
||||
|
||||
it('opens the chat variable panel and closes the other workflow panels', () => {
|
||||
const { store } = renderWorkflowComponent(<ChatVariableButton disabled={false} />, {
|
||||
initialStoreState: {
|
||||
showEnvPanel: true,
|
||||
showGlobalVariablePanel: true,
|
||||
showDebugAndPreviewPanel: true,
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(store.getState().showChatVariablePanel).toBe(true)
|
||||
expect(store.getState().showEnvPanel).toBe(false)
|
||||
expect(store.getState().showGlobalVariablePanel).toBe(false)
|
||||
expect(store.getState().showDebugAndPreviewPanel).toBe(false)
|
||||
})
|
||||
|
||||
it('applies the active dark theme styles when the chat variable panel is visible', () => {
|
||||
mockTheme = 'dark'
|
||||
renderWorkflowComponent(<ChatVariableButton disabled={false} />, {
|
||||
initialStoreState: {
|
||||
showChatVariablePanel: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-sm')
|
||||
})
|
||||
|
||||
it('stays disabled without mutating panel state', () => {
|
||||
const { store } = renderWorkflowComponent(<ChatVariableButton disabled />, {
|
||||
initialStoreState: {
|
||||
showChatVariablePanel: false,
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
expect(store.getState().showChatVariablePanel).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
118
web/app/components/workflow/header/__tests__/undo-redo.spec.tsx
Normal file
118
web/app/components/workflow/header/__tests__/undo-redo.spec.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import UndoRedo from '../undo-redo'
|
||||
|
||||
type TemporalSnapshot = {
|
||||
pastStates: unknown[]
|
||||
futureStates: unknown[]
|
||||
}
|
||||
|
||||
const mockUnsubscribe = vi.fn()
|
||||
const mockTemporalSubscribe = vi.fn()
|
||||
const mockHandleUndo = vi.fn()
|
||||
const mockHandleRedo = vi.fn()
|
||||
|
||||
let latestTemporalListener: ((state: TemporalSnapshot) => void) | undefined
|
||||
let mockNodesReadOnly = false
|
||||
|
||||
vi.mock('@/app/components/workflow/header/view-workflow-history', () => ({
|
||||
default: () => <div data-testid="view-workflow-history" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useNodesReadOnly: () => ({
|
||||
nodesReadOnly: mockNodesReadOnly,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/workflow-history-store', () => ({
|
||||
useWorkflowHistoryStore: () => ({
|
||||
store: {
|
||||
temporal: {
|
||||
subscribe: mockTemporalSubscribe,
|
||||
},
|
||||
},
|
||||
shortcutsEnabled: true,
|
||||
setShortcutsEnabled: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/divider', () => ({
|
||||
default: () => <div data-testid="divider" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/operator/tip-popup', () => ({
|
||||
default: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
describe('UndoRedo', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockNodesReadOnly = false
|
||||
latestTemporalListener = undefined
|
||||
mockTemporalSubscribe.mockImplementation((listener: (state: TemporalSnapshot) => void) => {
|
||||
latestTemporalListener = listener
|
||||
return mockUnsubscribe
|
||||
})
|
||||
})
|
||||
|
||||
it('enables undo and redo when history exists and triggers the callbacks', () => {
|
||||
render(<UndoRedo handleRedo={mockHandleRedo} handleUndo={mockHandleUndo} />)
|
||||
|
||||
act(() => {
|
||||
latestTemporalListener?.({
|
||||
pastStates: [{}],
|
||||
futureStates: [{}],
|
||||
})
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.undo' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.redo' }))
|
||||
|
||||
expect(mockHandleUndo).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleRedo).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the buttons disabled before history is available', () => {
|
||||
render(<UndoRedo handleRedo={mockHandleRedo} handleUndo={mockHandleUndo} />)
|
||||
const undoButton = screen.getByRole('button', { name: 'workflow.common.undo' })
|
||||
const redoButton = screen.getByRole('button', { name: 'workflow.common.redo' })
|
||||
|
||||
fireEvent.click(undoButton)
|
||||
fireEvent.click(redoButton)
|
||||
|
||||
expect(undoButton).toBeDisabled()
|
||||
expect(redoButton).toBeDisabled()
|
||||
expect(mockHandleUndo).not.toHaveBeenCalled()
|
||||
expect(mockHandleRedo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not trigger callbacks when the canvas is read only', () => {
|
||||
mockNodesReadOnly = true
|
||||
render(<UndoRedo handleRedo={mockHandleRedo} handleUndo={mockHandleUndo} />)
|
||||
const undoButton = screen.getByRole('button', { name: 'workflow.common.undo' })
|
||||
const redoButton = screen.getByRole('button', { name: 'workflow.common.redo' })
|
||||
|
||||
act(() => {
|
||||
latestTemporalListener?.({
|
||||
pastStates: [{}],
|
||||
futureStates: [{}],
|
||||
})
|
||||
})
|
||||
|
||||
fireEvent.click(undoButton)
|
||||
fireEvent.click(redoButton)
|
||||
|
||||
expect(undoButton).toBeDisabled()
|
||||
expect(redoButton).toBeDisabled()
|
||||
expect(mockHandleUndo).not.toHaveBeenCalled()
|
||||
expect(mockHandleRedo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('unsubscribes from the temporal store on unmount', () => {
|
||||
const { unmount } = render(<UndoRedo handleRedo={mockHandleRedo} handleUndo={mockHandleUndo} />)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockUnsubscribe).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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,276 @@
|
||||
import type { WorkflowRunHistory, WorkflowRunHistoryResponse } from '@/types/workflow'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
|
||||
import { ControlMode, WorkflowRunningStatus } from '../../types'
|
||||
import ViewHistory from '../view-history'
|
||||
|
||||
const mockUseWorkflowRunHistory = vi.fn()
|
||||
const mockFormatTimeFromNow = vi.fn((value: number) => `from-now:${value}`)
|
||||
const mockCloseAllInputFieldPanels = vi.fn()
|
||||
const mockHandleNodesCancelSelected = vi.fn()
|
||||
const mockHandleCancelDebugAndPreviewPanel = vi.fn()
|
||||
const mockFormatWorkflowRunIdentifier = vi.fn((finishedAt?: number, status?: string) => ` (${status || finishedAt || 'unknown'})`)
|
||||
|
||||
let mockIsChatMode = false
|
||||
|
||||
vi.mock('../../hooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../hooks')>('../../hooks')
|
||||
return {
|
||||
...actual,
|
||||
useIsChatMode: () => mockIsChatMode,
|
||||
useNodesInteractions: () => ({
|
||||
handleNodesCancelSelected: mockHandleNodesCancelSelected,
|
||||
}),
|
||||
useWorkflowInteractions: () => ({
|
||||
handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/use-workflow', () => ({
|
||||
useWorkflowRunHistory: (url?: string, enabled?: boolean) => mockUseWorkflowRunHistory(url, enabled),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: mockFormatTimeFromNow,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/rag-pipeline/hooks', () => ({
|
||||
useInputFieldPanel: () => ({
|
||||
closeAllInputFieldPanels: mockCloseAllInputFieldPanels,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/loading', () => ({
|
||||
default: () => <div data-testid="loading" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
|
||||
const PortalContext = React.createContext({ open: false })
|
||||
|
||||
return {
|
||||
PortalToFollowElem: ({
|
||||
children,
|
||||
open,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
open: boolean
|
||||
}) => <PortalContext.Provider value={{ open }}>{children}</PortalContext.Provider>,
|
||||
PortalToFollowElemTrigger: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
onClick?: () => void
|
||||
}) => <div data-testid="portal-trigger" onClick={onClick}>{children}</div>,
|
||||
PortalToFollowElemContent: ({
|
||||
children,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
}) => {
|
||||
const { open } = React.useContext(PortalContext)
|
||||
return open ? <div data-testid="portal-content">{children}</div> : null
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../utils')>('../../utils')
|
||||
return {
|
||||
...actual,
|
||||
formatWorkflowRunIdentifier: (finishedAt?: number, status?: string) => mockFormatWorkflowRunIdentifier(finishedAt, status),
|
||||
}
|
||||
})
|
||||
|
||||
const createHistoryItem = (overrides: Partial<WorkflowRunHistory> = {}): WorkflowRunHistory => ({
|
||||
id: 'run-1',
|
||||
version: 'v1',
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
},
|
||||
inputs: {},
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
outputs: {},
|
||||
elapsed_time: 1,
|
||||
total_tokens: 2,
|
||||
total_steps: 3,
|
||||
created_at: 100,
|
||||
finished_at: 120,
|
||||
created_by_account: {
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('ViewHistory', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsChatMode = false
|
||||
mockUseWorkflowRunHistory.mockReturnValue({
|
||||
data: { data: [] } satisfies WorkflowRunHistoryResponse,
|
||||
isLoading: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('defers fetching until the history popup is opened and renders the empty state', () => {
|
||||
renderWorkflowComponent(<ViewHistory historyUrl="/history" withText />, {
|
||||
hooksStoreProps: {
|
||||
handleBackupDraft: vi.fn(),
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockUseWorkflowRunHistory).toHaveBeenCalledWith('/history', false)
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
|
||||
|
||||
expect(mockUseWorkflowRunHistory).toHaveBeenLastCalledWith('/history', true)
|
||||
expect(screen.getByText('workflow.common.notRunning')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.common.showRunHistory')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the icon trigger variant and loading state, and clears log modals on trigger click', () => {
|
||||
const onClearLogAndMessageModal = vi.fn()
|
||||
mockUseWorkflowRunHistory.mockReturnValue({
|
||||
data: { data: [] } satisfies WorkflowRunHistoryResponse,
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
renderWorkflowComponent(
|
||||
<ViewHistory
|
||||
historyUrl="/history"
|
||||
onClearLogAndMessageModal={onClearLogAndMessageModal}
|
||||
/>,
|
||||
{
|
||||
hooksStoreProps: {
|
||||
handleBackupDraft: vi.fn(),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.viewRunHistory' }))
|
||||
|
||||
expect(onClearLogAndMessageModal).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByTestId('loading')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders workflow run history items and updates the workflow store when one is selected', () => {
|
||||
const handleBackupDraft = vi.fn()
|
||||
const pausedRun = createHistoryItem({
|
||||
id: 'run-paused',
|
||||
status: WorkflowRunningStatus.Paused,
|
||||
created_at: 101,
|
||||
finished_at: 0,
|
||||
})
|
||||
const failedRun = createHistoryItem({
|
||||
id: 'run-failed',
|
||||
status: WorkflowRunningStatus.Failed,
|
||||
created_at: 102,
|
||||
finished_at: 130,
|
||||
})
|
||||
const succeededRun = createHistoryItem({
|
||||
id: 'run-succeeded',
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
created_at: 103,
|
||||
finished_at: 140,
|
||||
})
|
||||
|
||||
mockUseWorkflowRunHistory.mockReturnValue({
|
||||
data: {
|
||||
data: [pausedRun, failedRun, succeededRun],
|
||||
} satisfies WorkflowRunHistoryResponse,
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const { store } = renderWorkflowComponent(<ViewHistory historyUrl="/history" withText />, {
|
||||
initialStoreState: {
|
||||
historyWorkflowData: failedRun,
|
||||
showInputsPanel: true,
|
||||
showEnvPanel: true,
|
||||
controlMode: ControlMode.Pointer,
|
||||
},
|
||||
hooksStoreProps: {
|
||||
handleBackupDraft,
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
|
||||
|
||||
expect(screen.getByText('Test Run (paused)')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test Run (failed)')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test Run (succeeded)')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Test Run (succeeded)'))
|
||||
|
||||
expect(store.getState().historyWorkflowData).toEqual(succeededRun)
|
||||
expect(store.getState().showInputsPanel).toBe(false)
|
||||
expect(store.getState().showEnvPanel).toBe(false)
|
||||
expect(store.getState().controlMode).toBe(ControlMode.Hand)
|
||||
expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
|
||||
expect(handleBackupDraft).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleNodesCancelSelected).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders chat history labels without workflow status icons in chat mode', () => {
|
||||
mockIsChatMode = true
|
||||
const chatRun = createHistoryItem({
|
||||
id: 'chat-run',
|
||||
status: WorkflowRunningStatus.Failed,
|
||||
})
|
||||
|
||||
mockUseWorkflowRunHistory.mockReturnValue({
|
||||
data: {
|
||||
data: [chatRun],
|
||||
} satisfies WorkflowRunHistoryResponse,
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
renderWorkflowComponent(<ViewHistory historyUrl="/history" withText />, {
|
||||
hooksStoreProps: {
|
||||
handleBackupDraft: vi.fn(),
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
|
||||
|
||||
expect(screen.getByText('Test Chat (failed)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes the popup from the close button and clears log modals', () => {
|
||||
const onClearLogAndMessageModal = vi.fn()
|
||||
mockUseWorkflowRunHistory.mockReturnValue({
|
||||
data: { data: [] } satisfies WorkflowRunHistoryResponse,
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
renderWorkflowComponent(
|
||||
<ViewHistory
|
||||
historyUrl="/history"
|
||||
withText
|
||||
onClearLogAndMessageModal={onClearLogAndMessageModal}
|
||||
/>,
|
||||
{
|
||||
hooksStoreProps: {
|
||||
handleBackupDraft: vi.fn(),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
|
||||
|
||||
expect(onClearLogAndMessageModal).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
RiArrowGoBackLine,
|
||||
RiArrowGoForwardFill,
|
||||
} from '@remixicon/react'
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ViewWorkflowHistory from '@/app/components/workflow/header/view-workflow-history'
|
||||
@@ -33,28 +29,34 @@ const UndoRedo: FC<UndoRedoProps> = ({ handleUndo, handleRedo }) => {
|
||||
return (
|
||||
<div className="flex items-center space-x-0.5 rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg backdrop-blur-[5px]">
|
||||
<TipPopup title={t('common.undo', { ns: 'workflow' })!} shortcuts={['ctrl', 'z']}>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.undo', { ns: 'workflow' })!}
|
||||
data-tooltip-id="workflow.undo"
|
||||
disabled={nodesReadOnly || buttonsDisabled.undo}
|
||||
className={
|
||||
cn('system-sm-medium flex h-8 w-8 cursor-pointer select-none items-center rounded-md px-1.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', (nodesReadOnly || buttonsDisabled.undo)
|
||||
&& 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')
|
||||
}
|
||||
onClick={() => !nodesReadOnly && !buttonsDisabled.undo && handleUndo()}
|
||||
onClick={handleUndo}
|
||||
>
|
||||
<RiArrowGoBackLine className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="i-ri-arrow-go-back-line h-4 w-4" />
|
||||
</button>
|
||||
</TipPopup>
|
||||
<TipPopup title={t('common.redo', { ns: 'workflow' })!} shortcuts={['ctrl', 'y']}>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.redo', { ns: 'workflow' })!}
|
||||
data-tooltip-id="workflow.redo"
|
||||
disabled={nodesReadOnly || buttonsDisabled.redo}
|
||||
className={
|
||||
cn('system-sm-medium flex h-8 w-8 cursor-pointer select-none items-center rounded-md px-1.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', (nodesReadOnly || buttonsDisabled.redo)
|
||||
&& 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')
|
||||
}
|
||||
onClick={() => !nodesReadOnly && !buttonsDisabled.redo && handleRedo()}
|
||||
onClick={handleRedo}
|
||||
>
|
||||
<RiArrowGoForwardFill className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="i-ri-arrow-go-forward-fill h-4 w-4" />
|
||||
</button>
|
||||
</TipPopup>
|
||||
<Divider type="vertical" className="mx-0.5 h-3.5" />
|
||||
<ViewWorkflowHistory />
|
||||
|
||||
@@ -73,15 +73,18 @@ const ViewHistory = ({
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
{
|
||||
withText && (
|
||||
<div className={cn(
|
||||
'flex h-8 items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 shadow-xs',
|
||||
'cursor-pointer text-[13px] font-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover',
|
||||
open && 'bg-components-button-secondary-bg-hover',
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.showRunHistory', { ns: 'workflow' })}
|
||||
className={cn(
|
||||
'flex h-8 items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 shadow-xs',
|
||||
'cursor-pointer text-[13px] font-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover',
|
||||
open && 'bg-components-button-secondary-bg-hover',
|
||||
)}
|
||||
>
|
||||
<span className="i-custom-vender-line-time-clock-play mr-1 h-4 w-4" />
|
||||
{t('common.showRunHistory', { ns: 'workflow' })}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
{
|
||||
@@ -89,14 +92,16 @@ const ViewHistory = ({
|
||||
<Tooltip
|
||||
popupContent={t('common.viewRunHistory', { ns: 'workflow' })}
|
||||
>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.viewRunHistory', { ns: 'workflow' })}
|
||||
className={cn('group flex h-7 w-7 cursor-pointer items-center justify-center rounded-md hover:bg-state-accent-hover', open && 'bg-state-accent-hover')}
|
||||
onClick={() => {
|
||||
onClearLogAndMessageModal?.()
|
||||
}}
|
||||
>
|
||||
<span className={cn('i-custom-vender-line-time-clock-play', 'h-4 w-4 group-hover:text-components-button-secondary-accent-text', open ? 'text-components-button-secondary-accent-text' : 'text-components-button-ghost-text')} />
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -110,7 +115,9 @@ const ViewHistory = ({
|
||||
>
|
||||
<div className="sticky top-0 flex items-center justify-between bg-components-panel-bg px-4 pt-3 text-base font-semibold text-text-primary">
|
||||
<div className="grow">{t('common.runHistory', { ns: 'workflow' })}</div>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.close', { ns: 'common' })}
|
||||
className="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center"
|
||||
onClick={() => {
|
||||
onClearLogAndMessageModal?.()
|
||||
@@ -118,7 +125,7 @@ const ViewHistory = ({
|
||||
}}
|
||||
>
|
||||
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{
|
||||
isLoading && (
|
||||
|
||||
@@ -1,54 +1,36 @@
|
||||
import type { CommonNodeType } from '../../../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { renderWorkflowComponent } from '../../../__tests__/workflow-test-env'
|
||||
import { BlockEnum, NodeRunningStatus } from '../../../types'
|
||||
import NodeControl from './node-control'
|
||||
|
||||
const {
|
||||
mockHandleNodeSelect,
|
||||
mockSetInitShowLastRunTab,
|
||||
mockSetPendingSingleRun,
|
||||
mockCanRunBySingle,
|
||||
} = vi.hoisted(() => ({
|
||||
mockHandleNodeSelect: vi.fn(),
|
||||
mockSetInitShowLastRunTab: vi.fn(),
|
||||
mockSetPendingSingleRun: vi.fn(),
|
||||
mockCanRunBySingle: vi.fn(() => true),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
let mockPluginInstallLocked = false
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
|
||||
<div data-testid="tooltip" data-content={popupContent}>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/vender/line/mediaAndDevices', () => ({
|
||||
Stop: ({ className }: { className?: string }) => <div data-testid="stop-icon" className={className} />,
|
||||
}))
|
||||
|
||||
vi.mock('../../../hooks', () => ({
|
||||
useNodesInteractions: () => ({
|
||||
handleNodeSelect: mockHandleNodeSelect,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
setInitShowLastRunTab: mockSetInitShowLastRunTab,
|
||||
setPendingSingleRun: mockSetPendingSingleRun,
|
||||
vi.mock('../../../hooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../hooks')>('../../../hooks')
|
||||
return {
|
||||
...actual,
|
||||
useNodesInteractions: () => ({
|
||||
handleNodeSelect: mockHandleNodeSelect,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../../utils', () => ({
|
||||
canRunBySingle: mockCanRunBySingle,
|
||||
}))
|
||||
vi.mock('../../../utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../utils')>('../../../utils')
|
||||
return {
|
||||
...actual,
|
||||
canRunBySingle: mockCanRunBySingle,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./panel-operator', () => ({
|
||||
default: ({ onOpenChange }: { onOpenChange: (open: boolean) => void }) => (
|
||||
@@ -59,6 +41,16 @@ vi.mock('./panel-operator', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
function NodeControlHarness({ id, data }: { id: string, data: CommonNodeType, selected?: boolean }) {
|
||||
return (
|
||||
<NodeControl
|
||||
id={id}
|
||||
data={data}
|
||||
pluginInstallLocked={mockPluginInstallLocked}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const makeData = (overrides: Partial<CommonNodeType> = {}): CommonNodeType => ({
|
||||
type: BlockEnum.Code,
|
||||
title: 'Node',
|
||||
@@ -73,65 +65,71 @@ const makeData = (overrides: Partial<CommonNodeType> = {}): CommonNodeType => ({
|
||||
describe('NodeControl', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPluginInstallLocked = false
|
||||
mockCanRunBySingle.mockReturnValue(true)
|
||||
})
|
||||
|
||||
it('should trigger a single run and show the hover control when plugins are not locked', () => {
|
||||
const { container } = render(
|
||||
<NodeControl
|
||||
id="node-1"
|
||||
data={makeData()}
|
||||
/>,
|
||||
)
|
||||
// Run/stop behavior should be driven by the workflow store, not CSS classes.
|
||||
describe('Single Run Actions', () => {
|
||||
it('should trigger a single run through the workflow store', () => {
|
||||
const { store } = renderWorkflowComponent(
|
||||
<NodeControlHarness id="node-1" data={makeData()} />,
|
||||
)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper.className).toContain('group-hover:flex')
|
||||
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'panel.runThisStep')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.panel.runThisStep' }))
|
||||
|
||||
fireEvent.click(screen.getByTestId('tooltip').parentElement!)
|
||||
expect(store.getState().initShowLastRunTab).toBe(true)
|
||||
expect(store.getState().pendingSingleRun).toEqual({ nodeId: 'node-1', action: 'run' })
|
||||
expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1')
|
||||
})
|
||||
|
||||
expect(mockSetInitShowLastRunTab).toHaveBeenCalledWith(true)
|
||||
expect(mockSetPendingSingleRun).toHaveBeenCalledWith({ nodeId: 'node-1', action: 'run' })
|
||||
expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1')
|
||||
it('should trigger stop when the node is already single-running', () => {
|
||||
const { store } = renderWorkflowComponent(
|
||||
<NodeControlHarness
|
||||
id="node-2"
|
||||
data={makeData({
|
||||
selected: true,
|
||||
_singleRunningStatus: NodeRunningStatus.Running,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.debug.variableInspect.trigger.stop' }))
|
||||
|
||||
expect(store.getState().pendingSingleRun).toEqual({ nodeId: 'node-2', action: 'stop' })
|
||||
expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-2')
|
||||
})
|
||||
})
|
||||
|
||||
it('should render the stop action, keep locked controls hidden by default, and stay open when panel operator opens', () => {
|
||||
const { container } = render(
|
||||
<NodeControl
|
||||
id="node-2"
|
||||
pluginInstallLocked
|
||||
data={makeData({
|
||||
selected: true,
|
||||
_singleRunningStatus: NodeRunningStatus.Running,
|
||||
isInIteration: true,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
// Capability gating should hide the run control while leaving panel actions available.
|
||||
describe('Availability', () => {
|
||||
it('should keep the panel operator available when the plugin is install-locked', () => {
|
||||
mockPluginInstallLocked = true
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper.className).not.toContain('group-hover:flex')
|
||||
expect(wrapper.className).toContain('!flex')
|
||||
expect(screen.getByTestId('stop-icon')).toBeInTheDocument()
|
||||
renderWorkflowComponent(
|
||||
<NodeControlHarness
|
||||
id="node-3"
|
||||
data={makeData({
|
||||
selected: true,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('stop-icon').parentElement!)
|
||||
expect(screen.getByRole('button', { name: 'open panel' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(mockSetPendingSingleRun).toHaveBeenCalledWith({ nodeId: 'node-2', action: 'stop' })
|
||||
it('should hide the run control when single-node execution is not supported', () => {
|
||||
mockCanRunBySingle.mockReturnValue(false)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open panel' }))
|
||||
expect(wrapper.className).toContain('!flex')
|
||||
})
|
||||
renderWorkflowComponent(
|
||||
<NodeControlHarness
|
||||
id="node-4"
|
||||
data={makeData()}
|
||||
/>,
|
||||
)
|
||||
|
||||
it('should hide the run control when single-node execution is not supported', () => {
|
||||
mockCanRunBySingle.mockReturnValue(false)
|
||||
|
||||
render(
|
||||
<NodeControl
|
||||
id="node-3"
|
||||
data={makeData()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'open panel' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'workflow.panel.runThisStep' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'open panel' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import type { FC } from 'react'
|
||||
import type { Node } from '../../../types'
|
||||
import {
|
||||
RiPlayLargeLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
@@ -54,7 +51,9 @@ const NodeControl: FC<NodeControlProps> = ({
|
||||
>
|
||||
{
|
||||
canRunBySingle(data.type, isChildNode) && (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isSingleRunning ? t('debug.variableInspect.trigger.stop', { ns: 'workflow' }) : t('panel.runThisStep', { ns: 'workflow' })}
|
||||
className={`flex h-5 w-5 items-center justify-center rounded-md ${isSingleRunning && 'cursor-pointer hover:bg-state-base-hover'}`}
|
||||
onClick={() => {
|
||||
const action = isSingleRunning ? 'stop' : 'run'
|
||||
@@ -76,11 +75,11 @@ const NodeControl: FC<NodeControlProps> = ({
|
||||
popupContent={t('panel.runThisStep', { ns: 'workflow' })}
|
||||
asChild={false}
|
||||
>
|
||||
<RiPlayLargeLine className="h-3 w-3" />
|
||||
<span className="i-ri-play-large-line h-3 w-3" />
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
<PanelOperator
|
||||
|
||||
@@ -1,90 +1,68 @@
|
||||
import type { WebhookTriggerNodeType } from '../types'
|
||||
import type { NodePanelProps } from '@/app/components/workflow/types'
|
||||
import type { PanelProps } from '@/types/workflow'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Panel from '../panel'
|
||||
|
||||
const {
|
||||
mockHandleStatusCodeChange,
|
||||
mockGenerateWebhookUrl,
|
||||
mockHandleMethodChange,
|
||||
mockHandleContentTypeChange,
|
||||
mockHandleHeadersChange,
|
||||
mockHandleParamsChange,
|
||||
mockHandleBodyChange,
|
||||
mockHandleResponseBodyChange,
|
||||
} = vi.hoisted(() => ({
|
||||
mockHandleStatusCodeChange: vi.fn(),
|
||||
mockGenerateWebhookUrl: vi.fn(),
|
||||
mockHandleMethodChange: vi.fn(),
|
||||
mockHandleContentTypeChange: vi.fn(),
|
||||
mockHandleHeadersChange: vi.fn(),
|
||||
mockHandleParamsChange: vi.fn(),
|
||||
mockHandleBodyChange: vi.fn(),
|
||||
mockHandleResponseBodyChange: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockConfigState = {
|
||||
readOnly: false,
|
||||
inputs: {
|
||||
method: 'POST',
|
||||
webhook_url: 'https://example.com/webhook',
|
||||
webhook_debug_url: '',
|
||||
content_type: 'application/json',
|
||||
headers: [],
|
||||
params: [],
|
||||
body: [],
|
||||
status_code: 200,
|
||||
response_body: 'ok',
|
||||
variables: [],
|
||||
},
|
||||
}
|
||||
|
||||
vi.mock('../use-config', () => ({
|
||||
DEFAULT_STATUS_CODE: 200,
|
||||
MAX_STATUS_CODE: 399,
|
||||
normalizeStatusCode: (statusCode: number) => Math.min(Math.max(statusCode, 200), 399),
|
||||
useConfig: () => ({
|
||||
readOnly: false,
|
||||
inputs: {
|
||||
method: 'POST',
|
||||
webhook_url: 'https://example.com/webhook',
|
||||
webhook_debug_url: '',
|
||||
content_type: 'application/json',
|
||||
headers: [],
|
||||
params: [],
|
||||
body: [],
|
||||
status_code: 200,
|
||||
response_body: '',
|
||||
},
|
||||
handleMethodChange: vi.fn(),
|
||||
handleContentTypeChange: vi.fn(),
|
||||
handleHeadersChange: vi.fn(),
|
||||
handleParamsChange: vi.fn(),
|
||||
handleBodyChange: vi.fn(),
|
||||
readOnly: mockConfigState.readOnly,
|
||||
inputs: mockConfigState.inputs,
|
||||
handleMethodChange: mockHandleMethodChange,
|
||||
handleContentTypeChange: mockHandleContentTypeChange,
|
||||
handleHeadersChange: mockHandleHeadersChange,
|
||||
handleParamsChange: mockHandleParamsChange,
|
||||
handleBodyChange: mockHandleBodyChange,
|
||||
handleStatusCodeChange: mockHandleStatusCodeChange,
|
||||
handleResponseBodyChange: vi.fn(),
|
||||
handleResponseBodyChange: mockHandleResponseBodyChange,
|
||||
generateWebhookUrl: mockGenerateWebhookUrl,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/input-with-copy', () => ({
|
||||
default: () => <div data-testid="input-with-copy" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/select', () => ({
|
||||
SimpleSelect: () => <div data-testid="simple-select" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
|
||||
default: ({ title, children }: { title: React.ReactNode, children: React.ReactNode }) => (
|
||||
<section>
|
||||
<div>{title}</div>
|
||||
{children}
|
||||
</section>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
|
||||
default: () => <div data-testid="output-vars" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
|
||||
default: () => <div data-testid="split" />,
|
||||
}))
|
||||
|
||||
vi.mock('../components/header-table', () => ({
|
||||
default: () => <div data-testid="header-table" />,
|
||||
}))
|
||||
|
||||
vi.mock('../components/parameter-table', () => ({
|
||||
default: () => <div data-testid="parameter-table" />,
|
||||
}))
|
||||
|
||||
vi.mock('../components/paragraph-input', () => ({
|
||||
default: () => <div data-testid="paragraph-input" />,
|
||||
}))
|
||||
|
||||
vi.mock('../utils/render-output-vars', () => ({
|
||||
OutputVariablesContent: () => <div data-testid="output-variables-content" />,
|
||||
}))
|
||||
const getStatusCodeInput = () => {
|
||||
return screen.getAllByDisplayValue('200')
|
||||
.find(element => element.getAttribute('aria-hidden') !== 'true') as HTMLInputElement
|
||||
}
|
||||
|
||||
describe('WebhookTriggerPanel', () => {
|
||||
const panelProps: NodePanelProps<WebhookTriggerNodeType> = {
|
||||
@@ -100,7 +78,7 @@ describe('WebhookTriggerPanel', () => {
|
||||
body: [],
|
||||
async_mode: false,
|
||||
status_code: 200,
|
||||
response_body: '',
|
||||
response_body: 'ok',
|
||||
variables: [],
|
||||
},
|
||||
panelProps: {} as PanelProps,
|
||||
@@ -108,26 +86,65 @@ describe('WebhookTriggerPanel', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockConfigState.readOnly = false
|
||||
mockConfigState.inputs = {
|
||||
method: 'POST',
|
||||
webhook_url: 'https://example.com/webhook',
|
||||
webhook_debug_url: '',
|
||||
content_type: 'application/json',
|
||||
headers: [],
|
||||
params: [],
|
||||
body: [],
|
||||
status_code: 200,
|
||||
response_body: 'ok',
|
||||
variables: [],
|
||||
}
|
||||
})
|
||||
|
||||
it('should update the status code when users enter a parseable value', () => {
|
||||
render(<Panel {...panelProps} />)
|
||||
describe('Rendering', () => {
|
||||
it('should render the real panel fields without generating a new webhook url when one already exists', () => {
|
||||
render(<Panel {...panelProps} />)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '201' } })
|
||||
expect(screen.getByDisplayValue('https://example.com/webhook')).toBeInTheDocument()
|
||||
expect(screen.getByText('application/json')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('ok')).toBeInTheDocument()
|
||||
expect(mockGenerateWebhookUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(mockHandleStatusCodeChange).toHaveBeenCalledWith(201)
|
||||
it('should request a webhook url when the node is writable and missing one', async () => {
|
||||
mockConfigState.inputs = {
|
||||
...mockConfigState.inputs,
|
||||
webhook_url: '',
|
||||
}
|
||||
|
||||
render(<Panel {...panelProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGenerateWebhookUrl).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore clear changes until the value is committed', () => {
|
||||
render(<Panel {...panelProps} />)
|
||||
describe('Status Code Input', () => {
|
||||
it('should update the status code when users enter a parseable value', () => {
|
||||
render(<Panel {...panelProps} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
fireEvent.change(getStatusCodeInput(), { target: { value: '201' } })
|
||||
|
||||
expect(mockHandleStatusCodeChange).not.toHaveBeenCalled()
|
||||
expect(mockHandleStatusCodeChange).toHaveBeenCalledWith(201)
|
||||
})
|
||||
|
||||
fireEvent.blur(input)
|
||||
it('should ignore clear changes until the value is committed', () => {
|
||||
render(<Panel {...panelProps} />)
|
||||
|
||||
expect(mockHandleStatusCodeChange).toHaveBeenCalledWith(200)
|
||||
const input = getStatusCodeInput()
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
|
||||
expect(mockHandleStatusCodeChange).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(mockHandleStatusCodeChange).toHaveBeenCalledWith(200)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
144
web/app/components/workflow/note-node/__tests__/index.spec.tsx
Normal file
144
web/app/components/workflow/note-node/__tests__/index.spec.tsx
Normal 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,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,225 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import ReactFlow, { ReactFlowProvider } from 'reactflow'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { BlockEnum } from '../../types'
|
||||
import AddBlock from '../add-block'
|
||||
|
||||
type BlockSelectorMockProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
disabled: boolean
|
||||
onSelect: (type: BlockEnum, pluginDefaultValue?: Record<string, unknown>) => void
|
||||
placement: string
|
||||
offset: {
|
||||
mainAxis: number
|
||||
crossAxis: number
|
||||
}
|
||||
trigger: (open: boolean) => ReactNode
|
||||
popupClassName: string
|
||||
availableBlocksTypes: BlockEnum[]
|
||||
showStartTab: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
mockHandlePaneContextmenuCancel,
|
||||
mockWorkflowStoreSetState,
|
||||
mockGenerateNewNode,
|
||||
mockGetNodeCustomTypeByNodeDataType,
|
||||
} = vi.hoisted(() => ({
|
||||
mockHandlePaneContextmenuCancel: vi.fn(),
|
||||
mockWorkflowStoreSetState: vi.fn(),
|
||||
mockGenerateNewNode: vi.fn(({ type, data }: { type: string, data: Record<string, unknown> }) => ({
|
||||
newNode: {
|
||||
id: 'generated-node',
|
||||
type,
|
||||
data,
|
||||
},
|
||||
})),
|
||||
mockGetNodeCustomTypeByNodeDataType: vi.fn((type: string) => `${type}-custom`),
|
||||
}))
|
||||
|
||||
let latestBlockSelectorProps: BlockSelectorMockProps | null = null
|
||||
let mockNodesReadOnly = false
|
||||
let mockIsChatMode = false
|
||||
let mockFlowType: FlowType = FlowType.appFlow
|
||||
|
||||
const mockAvailableNextBlocks = [BlockEnum.Answer, BlockEnum.Code]
|
||||
const mockNodesMetaDataMap = {
|
||||
[BlockEnum.Answer]: {
|
||||
defaultValue: {
|
||||
title: 'Answer',
|
||||
desc: '',
|
||||
type: BlockEnum.Answer,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/workflow/block-selector', () => ({
|
||||
default: (props: BlockSelectorMockProps) => {
|
||||
latestBlockSelectorProps = props
|
||||
return (
|
||||
<div data-testid="block-selector">
|
||||
{props.trigger(props.open)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
useAvailableBlocks: () => ({
|
||||
availableNextBlocks: mockAvailableNextBlocks,
|
||||
}),
|
||||
useIsChatMode: () => mockIsChatMode,
|
||||
useNodesMetaData: () => ({
|
||||
nodesMap: mockNodesMetaDataMap,
|
||||
}),
|
||||
useNodesReadOnly: () => ({
|
||||
nodesReadOnly: mockNodesReadOnly,
|
||||
}),
|
||||
usePanelInteractions: () => ({
|
||||
handlePaneContextmenuCancel: mockHandlePaneContextmenuCancel,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks-store', () => ({
|
||||
useHooksStore: (selector: (state: { configsMap?: { flowType?: FlowType } }) => unknown) =>
|
||||
selector({ configsMap: { flowType: mockFlowType } }),
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
setState: mockWorkflowStoreSetState,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils', () => ({
|
||||
generateNewNode: mockGenerateNewNode,
|
||||
getNodeCustomTypeByNodeDataType: mockGetNodeCustomTypeByNodeDataType,
|
||||
}))
|
||||
|
||||
vi.mock('../tip-popup', () => ({
|
||||
default: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
const renderWithReactFlow = (nodes: Array<{ id: string, position: { x: number, y: number }, data: { type: BlockEnum } }>) => {
|
||||
return render(
|
||||
<div style={{ width: 800, height: 600 }}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow nodes={nodes} edges={[]} fitView />
|
||||
<AddBlock />
|
||||
</ReactFlowProvider>
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('AddBlock', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
latestBlockSelectorProps = null
|
||||
mockNodesReadOnly = false
|
||||
mockIsChatMode = false
|
||||
mockFlowType = FlowType.appFlow
|
||||
})
|
||||
|
||||
// Rendering and selector configuration.
|
||||
describe('Rendering', () => {
|
||||
it('should pass the selector props for a writable app workflow', async () => {
|
||||
renderWithReactFlow([])
|
||||
|
||||
await waitFor(() => expect(latestBlockSelectorProps).not.toBeNull())
|
||||
|
||||
expect(screen.getByTestId('block-selector')).toBeInTheDocument()
|
||||
expect(latestBlockSelectorProps).toMatchObject({
|
||||
disabled: false,
|
||||
availableBlocksTypes: mockAvailableNextBlocks,
|
||||
showStartTab: true,
|
||||
placement: 'right-start',
|
||||
popupClassName: '!min-w-[256px]',
|
||||
})
|
||||
expect(latestBlockSelectorProps?.offset).toEqual({
|
||||
mainAxis: 4,
|
||||
crossAxis: -8,
|
||||
})
|
||||
})
|
||||
|
||||
it('should hide the start tab for chat mode and rag pipeline flows', async () => {
|
||||
mockIsChatMode = true
|
||||
const { rerender } = renderWithReactFlow([])
|
||||
|
||||
await waitFor(() => expect(latestBlockSelectorProps).not.toBeNull())
|
||||
|
||||
expect(latestBlockSelectorProps?.showStartTab).toBe(false)
|
||||
|
||||
mockIsChatMode = false
|
||||
mockFlowType = FlowType.ragPipeline
|
||||
rerender(
|
||||
<div style={{ width: 800, height: 600 }}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow nodes={[]} edges={[]} fitView />
|
||||
<AddBlock />
|
||||
</ReactFlowProvider>
|
||||
</div>,
|
||||
)
|
||||
|
||||
expect(latestBlockSelectorProps?.showStartTab).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// User interactions that bridge selector state and workflow state.
|
||||
describe('User Interactions', () => {
|
||||
it('should cancel the pane context menu when the selector closes', async () => {
|
||||
renderWithReactFlow([])
|
||||
|
||||
await waitFor(() => expect(latestBlockSelectorProps).not.toBeNull())
|
||||
|
||||
act(() => {
|
||||
latestBlockSelectorProps?.onOpenChange(false)
|
||||
})
|
||||
|
||||
expect(mockHandlePaneContextmenuCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should create a candidate node with an incremented title when a block is selected', async () => {
|
||||
renderWithReactFlow([
|
||||
{ id: 'node-1', position: { x: 0, y: 0 }, data: { type: BlockEnum.Answer } },
|
||||
{ id: 'node-2', position: { x: 80, y: 0 }, data: { type: BlockEnum.Answer } },
|
||||
])
|
||||
|
||||
await waitFor(() => expect(latestBlockSelectorProps).not.toBeNull())
|
||||
|
||||
act(() => {
|
||||
latestBlockSelectorProps?.onSelect(BlockEnum.Answer, { pluginId: 'plugin-1' })
|
||||
})
|
||||
|
||||
expect(mockGetNodeCustomTypeByNodeDataType).toHaveBeenCalledWith(BlockEnum.Answer)
|
||||
expect(mockGenerateNewNode).toHaveBeenCalledWith({
|
||||
type: 'answer-custom',
|
||||
data: {
|
||||
title: 'Answer 3',
|
||||
desc: '',
|
||||
type: BlockEnum.Answer,
|
||||
pluginId: 'plugin-1',
|
||||
_isCandidate: true,
|
||||
},
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
})
|
||||
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({
|
||||
candidateNode: {
|
||||
id: 'generated-node',
|
||||
type: 'answer-custom',
|
||||
data: {
|
||||
title: 'Answer 3',
|
||||
desc: '',
|
||||
type: BlockEnum.Answer,
|
||||
pluginId: 'plugin-1',
|
||||
_isCandidate: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
136
web/app/components/workflow/operator/__tests__/control.spec.tsx
Normal file
136
web/app/components/workflow/operator/__tests__/control.spec.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { ControlMode } from '../../types'
|
||||
import Control from '../control'
|
||||
|
||||
type WorkflowStoreState = {
|
||||
controlMode: ControlMode
|
||||
maximizeCanvas: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
mockHandleAddNote,
|
||||
mockHandleLayout,
|
||||
mockHandleModeHand,
|
||||
mockHandleModePointer,
|
||||
mockHandleToggleMaximizeCanvas,
|
||||
} = vi.hoisted(() => ({
|
||||
mockHandleAddNote: vi.fn(),
|
||||
mockHandleLayout: vi.fn(),
|
||||
mockHandleModeHand: vi.fn(),
|
||||
mockHandleModePointer: vi.fn(),
|
||||
mockHandleToggleMaximizeCanvas: vi.fn(),
|
||||
}))
|
||||
|
||||
let mockNodesReadOnly = false
|
||||
let mockStoreState: WorkflowStoreState
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
useNodesReadOnly: () => ({
|
||||
nodesReadOnly: mockNodesReadOnly,
|
||||
getNodesReadOnly: () => mockNodesReadOnly,
|
||||
}),
|
||||
useWorkflowCanvasMaximize: () => ({
|
||||
handleToggleMaximizeCanvas: mockHandleToggleMaximizeCanvas,
|
||||
}),
|
||||
useWorkflowMoveMode: () => ({
|
||||
handleModePointer: mockHandleModePointer,
|
||||
handleModeHand: mockHandleModeHand,
|
||||
}),
|
||||
useWorkflowOrganize: () => ({
|
||||
handleLayout: mockHandleLayout,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useOperator: () => ({
|
||||
handleAddNote: mockHandleAddNote,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useStore: (selector: (state: WorkflowStoreState) => unknown) => selector(mockStoreState),
|
||||
}))
|
||||
|
||||
vi.mock('../add-block', () => ({
|
||||
default: () => <div data-testid="add-block" />,
|
||||
}))
|
||||
|
||||
vi.mock('../more-actions', () => ({
|
||||
default: () => <div data-testid="more-actions" />,
|
||||
}))
|
||||
|
||||
vi.mock('../tip-popup', () => ({
|
||||
default: ({
|
||||
children,
|
||||
title,
|
||||
}: {
|
||||
children?: ReactNode
|
||||
title?: string
|
||||
}) => <div data-testid={title}>{children}</div>,
|
||||
}))
|
||||
|
||||
describe('Control', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockNodesReadOnly = false
|
||||
mockStoreState = {
|
||||
controlMode: ControlMode.Pointer,
|
||||
maximizeCanvas: false,
|
||||
}
|
||||
})
|
||||
|
||||
// Rendering and visual states for control buttons.
|
||||
describe('Rendering', () => {
|
||||
it('should render the child action groups and highlight the active pointer mode', () => {
|
||||
render(<Control />)
|
||||
|
||||
expect(screen.getByTestId('add-block')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('more-actions')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('workflow.common.pointerMode').firstElementChild).toHaveClass('bg-state-accent-active')
|
||||
expect(screen.getByTestId('workflow.common.handMode').firstElementChild).not.toHaveClass('bg-state-accent-active')
|
||||
expect(screen.getByTestId('workflow.panel.maximize')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch the maximize tooltip and active style when the canvas is maximized', () => {
|
||||
mockStoreState = {
|
||||
controlMode: ControlMode.Hand,
|
||||
maximizeCanvas: true,
|
||||
}
|
||||
|
||||
render(<Control />)
|
||||
|
||||
expect(screen.getByTestId('workflow.common.handMode').firstElementChild).toHaveClass('bg-state-accent-active')
|
||||
expect(screen.getByTestId('workflow.panel.minimize').firstElementChild).toHaveClass('bg-state-accent-active')
|
||||
})
|
||||
})
|
||||
|
||||
// User interactions exposed by the control bar.
|
||||
describe('User Interactions', () => {
|
||||
it('should trigger the note, mode, organize, and maximize handlers', () => {
|
||||
render(<Control />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('workflow.nodes.note.addNote').firstElementChild as HTMLElement)
|
||||
fireEvent.click(screen.getByTestId('workflow.common.pointerMode').firstElementChild as HTMLElement)
|
||||
fireEvent.click(screen.getByTestId('workflow.common.handMode').firstElementChild as HTMLElement)
|
||||
fireEvent.click(screen.getByTestId('workflow.panel.organizeBlocks').firstElementChild as HTMLElement)
|
||||
fireEvent.click(screen.getByTestId('workflow.panel.maximize').firstElementChild as HTMLElement)
|
||||
|
||||
expect(mockHandleAddNote).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleModePointer).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleModeHand).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleLayout).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleToggleMaximizeCanvas).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should block note creation when the workflow is read only', () => {
|
||||
mockNodesReadOnly = true
|
||||
|
||||
render(<Control />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('workflow.nodes.note.addNote').firstElementChild as HTMLElement)
|
||||
|
||||
expect(mockHandleAddNote).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
145
web/app/components/workflow/operator/__tests__/index.spec.tsx
Normal file
145
web/app/components/workflow/operator/__tests__/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,323 @@
|
||||
import type { Shape as HooksStoreShape } from '../../hooks-store/store'
|
||||
import type { RunFile } from '../../types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ReactFlow, { ReactFlowProvider } from 'reactflow'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { createStartNode } from '../../__tests__/fixtures'
|
||||
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
|
||||
import { InputVarType, WorkflowRunningStatus } from '../../types'
|
||||
import InputsPanel from '../inputs-panel'
|
||||
|
||||
const mockCheckInputsForm = vi.fn()
|
||||
const mockNotify = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast/context', () => ({
|
||||
useToastContext: () => ({
|
||||
notify: mockNotify,
|
||||
close: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chat/chat/check-input-forms-hooks', () => ({
|
||||
useCheckInputsForms: () => ({
|
||||
checkInputsForm: mockCheckInputsForm,
|
||||
}),
|
||||
}))
|
||||
|
||||
const fileSettingsWithImage = {
|
||||
enabled: true,
|
||||
image: {
|
||||
enabled: true,
|
||||
},
|
||||
allowed_file_upload_methods: [TransferMethod.remote_url],
|
||||
number_limits: 3,
|
||||
image_file_size_limit: 10,
|
||||
} satisfies FileUpload & { image_file_size_limit: number }
|
||||
|
||||
const uploadedRunFile = {
|
||||
transfer_method: TransferMethod.remote_url,
|
||||
upload_file_id: 'file-2',
|
||||
} as unknown as RunFile
|
||||
|
||||
const uploadingRunFile = {
|
||||
transfer_method: TransferMethod.local_file,
|
||||
} as unknown as RunFile
|
||||
|
||||
const createHooksStoreProps = (
|
||||
overrides: Partial<HooksStoreShape> = {},
|
||||
): Partial<HooksStoreShape> => ({
|
||||
handleRun: vi.fn(),
|
||||
configsMap: {
|
||||
flowId: 'flow-1',
|
||||
flowType: FlowType.appFlow,
|
||||
fileSettings: fileSettingsWithImage,
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const renderInputsPanel = (
|
||||
startNode: ReturnType<typeof createStartNode>,
|
||||
options?: Parameters<typeof renderWorkflowComponent>[1],
|
||||
) => {
|
||||
return renderWorkflowComponent(
|
||||
<div style={{ width: 800, height: 600 }}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow nodes={[startNode]} edges={[]} fitView />
|
||||
<InputsPanel onRun={vi.fn()} />
|
||||
</ReactFlowProvider>
|
||||
</div>,
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
||||
describe('InputsPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckInputsForm.mockReturnValue(true)
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render current inputs, defaults, and the image uploader from the start node', () => {
|
||||
renderInputsPanel(
|
||||
createStartNode({
|
||||
data: {
|
||||
variables: [
|
||||
{
|
||||
type: InputVarType.textInput,
|
||||
variable: 'question',
|
||||
label: 'Question',
|
||||
required: true,
|
||||
default: 'default question',
|
||||
},
|
||||
{
|
||||
type: InputVarType.number,
|
||||
variable: 'count',
|
||||
label: 'Count',
|
||||
required: false,
|
||||
default: '2',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{
|
||||
initialStoreState: {
|
||||
inputs: {
|
||||
question: 'overridden question',
|
||||
},
|
||||
},
|
||||
hooksStoreProps: createHooksStoreProps(),
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('overridden question')).toHaveFocus()
|
||||
expect(screen.getByRole('spinbutton')).toHaveValue(2)
|
||||
expect(screen.getByText('common.imageUploader.pasteImageLink')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should update workflow inputs and image files when users edit the form', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { store } = renderInputsPanel(
|
||||
createStartNode({
|
||||
data: {
|
||||
variables: [
|
||||
{
|
||||
type: InputVarType.textInput,
|
||||
variable: 'question',
|
||||
label: 'Question',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{
|
||||
hooksStoreProps: createHooksStoreProps(),
|
||||
},
|
||||
)
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Question'), 'changed question')
|
||||
expect(store.getState().inputs).toEqual({ question: 'changed question' })
|
||||
|
||||
await user.click(screen.getByText('common.imageUploader.pasteImageLink'))
|
||||
await user.type(
|
||||
await screen.findByPlaceholderText('common.imageUploader.pasteImageLinkInputPlaceholder'),
|
||||
'https://example.com/image.png',
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.ok' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().files).toEqual([{
|
||||
type: 'image',
|
||||
transfer_method: TransferMethod.remote_url,
|
||||
url: 'https://example.com/image.png',
|
||||
upload_file_id: '',
|
||||
}])
|
||||
})
|
||||
})
|
||||
|
||||
it('should not start a run when input validation fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockCheckInputsForm.mockReturnValue(false)
|
||||
const onRun = vi.fn()
|
||||
const handleRun = vi.fn()
|
||||
|
||||
renderWorkflowComponent(
|
||||
<div style={{ width: 800, height: 600 }}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow
|
||||
nodes={[
|
||||
createStartNode({
|
||||
data: {
|
||||
variables: [
|
||||
{
|
||||
type: InputVarType.textInput,
|
||||
variable: 'question',
|
||||
label: 'Question',
|
||||
required: true,
|
||||
default: 'default question',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]}
|
||||
edges={[]}
|
||||
fitView
|
||||
/>
|
||||
<InputsPanel onRun={onRun} />
|
||||
</ReactFlowProvider>
|
||||
</div>,
|
||||
{
|
||||
hooksStoreProps: createHooksStoreProps({ handleRun }),
|
||||
},
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.singleRun.startRun' }))
|
||||
|
||||
expect(mockCheckInputsForm).toHaveBeenCalledWith(
|
||||
{ question: 'default question' },
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ variable: 'question' }),
|
||||
expect.objectContaining({ variable: '__image' }),
|
||||
]),
|
||||
)
|
||||
expect(onRun).not.toHaveBeenCalled()
|
||||
expect(handleRun).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should start a run with processed inputs when validation succeeds', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onRun = vi.fn()
|
||||
const handleRun = vi.fn()
|
||||
|
||||
renderWorkflowComponent(
|
||||
<div style={{ width: 800, height: 600 }}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow
|
||||
nodes={[
|
||||
createStartNode({
|
||||
data: {
|
||||
variables: [
|
||||
{
|
||||
type: InputVarType.textInput,
|
||||
variable: 'question',
|
||||
label: 'Question',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
type: InputVarType.checkbox,
|
||||
variable: 'confirmed',
|
||||
label: 'Confirmed',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]}
|
||||
edges={[]}
|
||||
fitView
|
||||
/>
|
||||
<InputsPanel onRun={onRun} />
|
||||
</ReactFlowProvider>
|
||||
</div>,
|
||||
{
|
||||
initialStoreState: {
|
||||
inputs: {
|
||||
question: 'run this',
|
||||
confirmed: 'truthy',
|
||||
},
|
||||
files: [uploadedRunFile],
|
||||
},
|
||||
hooksStoreProps: createHooksStoreProps({
|
||||
handleRun,
|
||||
configsMap: {
|
||||
flowId: 'flow-1',
|
||||
flowType: FlowType.appFlow,
|
||||
fileSettings: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.singleRun.startRun' }))
|
||||
|
||||
expect(onRun).toHaveBeenCalledTimes(1)
|
||||
expect(handleRun).toHaveBeenCalledWith({
|
||||
inputs: {
|
||||
question: 'run this',
|
||||
confirmed: true,
|
||||
},
|
||||
files: [uploadedRunFile],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled States', () => {
|
||||
it('should disable the run button while a local file is still uploading', () => {
|
||||
renderInputsPanel(createStartNode(), {
|
||||
initialStoreState: {
|
||||
files: [uploadingRunFile],
|
||||
},
|
||||
hooksStoreProps: createHooksStoreProps({
|
||||
configsMap: {
|
||||
flowId: 'flow-1',
|
||||
flowType: FlowType.appFlow,
|
||||
fileSettings: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'workflow.singleRun.startRun' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable the run button while the workflow is already running', () => {
|
||||
renderInputsPanel(createStartNode(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: {
|
||||
result: {
|
||||
status: WorkflowRunningStatus.Running,
|
||||
inputs_truncated: false,
|
||||
process_data_truncated: false,
|
||||
outputs_truncated: false,
|
||||
},
|
||||
tracing: [],
|
||||
},
|
||||
},
|
||||
hooksStoreProps: createHooksStoreProps(),
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'workflow.singleRun.startRun' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
163
web/app/components/workflow/panel/__tests__/record.spec.tsx
Normal file
163
web/app/components/workflow/panel/__tests__/record.spec.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { WorkflowRunDetailResponse } from '@/models/log'
|
||||
import { act, screen } from '@testing-library/react'
|
||||
import { createEdge, createNode } from '../../__tests__/fixtures'
|
||||
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
|
||||
import Record from '../record'
|
||||
|
||||
const mockHandleUpdateWorkflowCanvas = vi.fn()
|
||||
const mockFormatWorkflowRunIdentifier = vi.fn((finishedAt?: number) => finishedAt ? ' (Finished)' : ' (Running)')
|
||||
|
||||
let latestGetResultCallback: ((res: WorkflowRunDetailResponse) => void) | undefined
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useWorkflowUpdate: () => ({
|
||||
handleUpdateWorkflowCanvas: mockHandleUpdateWorkflowCanvas,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/run', () => ({
|
||||
default: ({
|
||||
runDetailUrl,
|
||||
tracingListUrl,
|
||||
getResultCallback,
|
||||
}: {
|
||||
runDetailUrl: string
|
||||
tracingListUrl: string
|
||||
getResultCallback: (res: WorkflowRunDetailResponse) => void
|
||||
}) => {
|
||||
latestGetResultCallback = getResultCallback
|
||||
return (
|
||||
<div
|
||||
data-run-detail-url={runDetailUrl}
|
||||
data-testid="run"
|
||||
data-tracing-list-url={tracingListUrl}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/utils', () => ({
|
||||
formatWorkflowRunIdentifier: (finishedAt?: number) => mockFormatWorkflowRunIdentifier(finishedAt),
|
||||
}))
|
||||
|
||||
const createRunDetail = (overrides: Partial<WorkflowRunDetailResponse> = {}): WorkflowRunDetailResponse => ({
|
||||
id: 'run-1',
|
||||
version: '1',
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
},
|
||||
inputs: '{}',
|
||||
inputs_truncated: false,
|
||||
status: 'succeeded',
|
||||
outputs: '{}',
|
||||
outputs_truncated: false,
|
||||
total_steps: 1,
|
||||
created_by_role: 'account',
|
||||
created_at: 1,
|
||||
finished_at: 2,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Record', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
latestGetResultCallback = undefined
|
||||
})
|
||||
|
||||
it('renders the run title and passes run and trace URLs to the run panel', () => {
|
||||
const getWorkflowRunAndTraceUrl = vi.fn((runId?: string) => ({
|
||||
runUrl: `/runs/${runId}`,
|
||||
traceUrl: `/traces/${runId}`,
|
||||
}))
|
||||
|
||||
renderWorkflowComponent(<Record />, {
|
||||
initialStoreState: {
|
||||
historyWorkflowData: {
|
||||
id: 'run-1',
|
||||
status: 'succeeded',
|
||||
finished_at: 1700000000000,
|
||||
},
|
||||
},
|
||||
hooksStoreProps: {
|
||||
getWorkflowRunAndTraceUrl,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('Test Run (Finished)')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('run')).toHaveAttribute('data-run-detail-url', '/runs/run-1')
|
||||
expect(screen.getByTestId('run')).toHaveAttribute('data-tracing-list-url', '/traces/run-1')
|
||||
expect(getWorkflowRunAndTraceUrl).toHaveBeenCalledTimes(2)
|
||||
expect(getWorkflowRunAndTraceUrl).toHaveBeenNthCalledWith(1, 'run-1')
|
||||
expect(getWorkflowRunAndTraceUrl).toHaveBeenNthCalledWith(2, 'run-1')
|
||||
expect(mockFormatWorkflowRunIdentifier).toHaveBeenCalledWith(1700000000000)
|
||||
})
|
||||
|
||||
it('updates the workflow canvas with a fallback viewport when the response omits one', () => {
|
||||
const nodes = [createNode({ id: 'node-1' })]
|
||||
const edges = [createEdge({ id: 'edge-1' })]
|
||||
|
||||
renderWorkflowComponent(<Record />, {
|
||||
initialStoreState: {
|
||||
historyWorkflowData: {
|
||||
id: 'run-1',
|
||||
status: 'succeeded',
|
||||
},
|
||||
},
|
||||
hooksStoreProps: {
|
||||
getWorkflowRunAndTraceUrl: () => ({ runUrl: '/runs/run-1', traceUrl: '/traces/run-1' }),
|
||||
},
|
||||
})
|
||||
|
||||
expect(latestGetResultCallback).toBeDefined()
|
||||
|
||||
act(() => {
|
||||
latestGetResultCallback?.(createRunDetail({
|
||||
graph: {
|
||||
nodes,
|
||||
edges,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
|
||||
nodes,
|
||||
edges,
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the response viewport when one is available', () => {
|
||||
const nodes = [createNode({ id: 'node-1' })]
|
||||
const edges = [createEdge({ id: 'edge-1' })]
|
||||
const viewport = { x: 12, y: 24, zoom: 0.75 }
|
||||
|
||||
renderWorkflowComponent(<Record />, {
|
||||
initialStoreState: {
|
||||
historyWorkflowData: {
|
||||
id: 'run-1',
|
||||
status: 'succeeded',
|
||||
},
|
||||
},
|
||||
hooksStoreProps: {
|
||||
getWorkflowRunAndTraceUrl: () => ({ runUrl: '/runs/run-1', traceUrl: '/traces/run-1' }),
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
latestGetResultCallback?.(createRunDetail({
|
||||
graph: {
|
||||
nodes,
|
||||
edges,
|
||||
viewport,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
|
||||
nodes,
|
||||
edges,
|
||||
viewport,
|
||||
})
|
||||
})
|
||||
})
|
||||
68
web/app/components/workflow/run/__tests__/meta.spec.tsx
Normal file
68
web/app/components/workflow/run/__tests__/meta.spec.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Meta from '../meta'
|
||||
|
||||
const mockFormatTime = vi.fn((value: number) => `formatted:${value}`)
|
||||
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({
|
||||
formatTime: mockFormatTime,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('Meta', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders loading placeholders while the run is in progress', () => {
|
||||
const { container } = render(<Meta status="running" />)
|
||||
|
||||
expect(container.querySelectorAll('.bg-text-quaternary')).toHaveLength(6)
|
||||
expect(screen.queryByText('SUCCESS')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('runLog.meta.steps')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['succeeded', 'SUCCESS'],
|
||||
['partial-succeeded', 'PARTIAL SUCCESS'],
|
||||
['exception', 'EXCEPTION'],
|
||||
['failed', 'FAIL'],
|
||||
['stopped', 'STOP'],
|
||||
['paused', 'PENDING'],
|
||||
] as const)('renders the %s status label', (status, label) => {
|
||||
render(<Meta status={status} />)
|
||||
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders explicit metadata values and hides steps when requested', () => {
|
||||
render(
|
||||
<Meta
|
||||
status="succeeded"
|
||||
executor="Alice"
|
||||
startTime={1700000000000}
|
||||
time={1.2349}
|
||||
tokens={42}
|
||||
steps={3}
|
||||
showSteps={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument()
|
||||
expect(screen.getByText('formatted:1700000000000')).toBeInTheDocument()
|
||||
expect(screen.getByText('1.235s')).toBeInTheDocument()
|
||||
expect(screen.getByText('42 Tokens')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Run Steps')).not.toBeInTheDocument()
|
||||
expect(mockFormatTime).toHaveBeenCalledWith(1700000000000, expect.any(String))
|
||||
})
|
||||
|
||||
it('falls back to default values when metadata is missing', () => {
|
||||
render(<Meta status="failed" />)
|
||||
|
||||
expect(screen.getByText('N/A')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('-')).toHaveLength(2)
|
||||
expect(screen.getByText('0 Tokens')).toBeInTheDocument()
|
||||
expect(screen.getByText('runLog.meta.steps').parentElement).toHaveTextContent('1')
|
||||
expect(mockFormatTime).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
137
web/app/components/workflow/run/__tests__/output-panel.spec.tsx
Normal file
137
web/app/components/workflow/run/__tests__/output-panel.spec.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { FileResponse } from '@/types/workflow'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import OutputPanel from '../output-panel'
|
||||
|
||||
type FileOutput = FileResponse & { dify_model_identity: '__dify__file__' }
|
||||
|
||||
vi.mock('@/app/components/base/chat/chat/loading-anim', () => ({
|
||||
default: () => <div data-testid="loading-anim" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/file-uploader', () => ({
|
||||
FileList: ({ files }: { files: FileEntity[] }) => (
|
||||
<div data-testid="file-list">{files.map(file => file.name).join(', ')}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/markdown', () => ({
|
||||
Markdown: ({ content }: { content: string }) => <div data-testid="markdown">{content}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/run/status-container', () => ({
|
||||
default: ({ status, children }: { status: string, children?: React.ReactNode }) => (
|
||||
<div data-status={status} data-testid="status-container">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
|
||||
default: ({
|
||||
language,
|
||||
value,
|
||||
height,
|
||||
}: {
|
||||
language: string
|
||||
value: string
|
||||
height?: number
|
||||
}) => (
|
||||
<div data-height={height} data-language={language} data-testid="code-editor" data-value={value}>
|
||||
{value}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createFileOutput = (overrides: Partial<FileOutput> = {}): FileOutput => ({
|
||||
dify_model_identity: '__dify__file__',
|
||||
related_id: 'file-1',
|
||||
extension: 'pdf',
|
||||
filename: 'report.pdf',
|
||||
size: 128,
|
||||
mime_type: 'application/pdf',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'document',
|
||||
url: 'https://example.com/report.pdf',
|
||||
upload_file_id: 'upload-1',
|
||||
remote_url: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('OutputPanel', () => {
|
||||
it('renders the loading animation while the workflow is running', () => {
|
||||
render(<OutputPanel isRunning />)
|
||||
|
||||
expect(screen.getByTestId('loading-anim')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the failed status container when there is an error', () => {
|
||||
render(<OutputPanel error="Execution failed" />)
|
||||
|
||||
expect(screen.getByTestId('status-container')).toHaveAttribute('data-status', 'failed')
|
||||
expect(screen.getByText('Execution failed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the no-output placeholder when there are no outputs', () => {
|
||||
render(<OutputPanel />)
|
||||
|
||||
expect(screen.getByTestId('markdown')).toHaveTextContent('No Output')
|
||||
})
|
||||
|
||||
it('renders a plain text output as markdown', () => {
|
||||
render(<OutputPanel outputs={{ answer: 'Hello Dify' }} />)
|
||||
|
||||
expect(screen.getByTestId('markdown')).toHaveTextContent('Hello Dify')
|
||||
})
|
||||
|
||||
it('renders array text outputs as joined markdown content', () => {
|
||||
render(<OutputPanel outputs={{ answer: ['Line 1', 'Line 2'] }} />)
|
||||
|
||||
expect(screen.getByTestId('markdown')).toHaveTextContent(/Line 1\s+Line 2/)
|
||||
})
|
||||
|
||||
it('renders a file list for a single file output', () => {
|
||||
render(<OutputPanel outputs={{ attachment: createFileOutput() }} />)
|
||||
|
||||
expect(screen.getByTestId('file-list')).toHaveTextContent('report.pdf')
|
||||
})
|
||||
|
||||
it('renders a file list for an array of file outputs', () => {
|
||||
render(
|
||||
<OutputPanel
|
||||
outputs={{
|
||||
attachments: [
|
||||
createFileOutput(),
|
||||
createFileOutput({
|
||||
related_id: 'file-2',
|
||||
filename: 'summary.md',
|
||||
extension: 'md',
|
||||
mime_type: 'text/markdown',
|
||||
type: 'custom',
|
||||
upload_file_id: 'upload-2',
|
||||
url: 'https://example.com/summary.md',
|
||||
}),
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('file-list')).toHaveTextContent('report.pdf, summary.md')
|
||||
})
|
||||
|
||||
it('renders structured outputs inside the code editor when height is available', () => {
|
||||
render(<OutputPanel height={200} outputs={{ answer: 'hello', score: 1 }} />)
|
||||
|
||||
expect(screen.getByTestId('code-editor')).toHaveAttribute('data-language', 'json')
|
||||
expect(screen.getByTestId('code-editor')).toHaveAttribute('data-height', '92')
|
||||
expect(screen.getByTestId('code-editor')).toHaveAttribute('data-value', `{
|
||||
"answer": "hello",
|
||||
"score": 1
|
||||
}`)
|
||||
})
|
||||
|
||||
it('skips the code editor when structured outputs have no positive height', () => {
|
||||
render(<OutputPanel height={0} outputs={{ answer: 'hello', score: 1 }} />)
|
||||
|
||||
expect(screen.queryByTestId('code-editor')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import ResultText from '../result-text'
|
||||
|
||||
vi.mock('@/app/components/base/chat/chat/loading-anim', () => ({
|
||||
default: () => <div data-testid="loading-anim" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/file-uploader', () => ({
|
||||
FileList: ({ files }: { files: FileEntity[] }) => (
|
||||
<div data-testid="file-list">{files.map(file => file.name).join(', ')}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/markdown', () => ({
|
||||
Markdown: ({ content }: { content: string }) => <div data-testid="markdown">{content}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/run/status-container', () => ({
|
||||
default: ({ status, children }: { status: string, children?: React.ReactNode }) => (
|
||||
<div data-status={status} data-testid="status-container">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('ResultText', () => {
|
||||
it('renders the loading animation while waiting for a text result', () => {
|
||||
render(<ResultText isRunning />)
|
||||
|
||||
expect(screen.getByTestId('loading-anim')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the error state when the run fails', () => {
|
||||
render(<ResultText error="Run failed" />)
|
||||
|
||||
expect(screen.getByTestId('status-container')).toHaveAttribute('data-status', 'failed')
|
||||
expect(screen.getByText('Run failed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the empty-state call to action and forwards clicks', () => {
|
||||
const onClick = vi.fn()
|
||||
render(<ResultText onClick={onClick} />)
|
||||
|
||||
expect(screen.getByText('runLog.resultEmpty.title')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('runLog.resultEmpty.link'))
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not render the empty state for paused runs', () => {
|
||||
render(<ResultText isPaused />)
|
||||
|
||||
expect(screen.queryByText('runLog.resultEmpty.title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders markdown content when text outputs are available', () => {
|
||||
render(<ResultText outputs="hello workflow" />)
|
||||
|
||||
expect(screen.getByTestId('markdown')).toHaveTextContent('hello workflow')
|
||||
})
|
||||
|
||||
it('renders file groups when file outputs are available', () => {
|
||||
render(
|
||||
<ResultText
|
||||
allFiles={[
|
||||
{
|
||||
varName: 'attachments',
|
||||
list: [
|
||||
{
|
||||
id: 'file-1',
|
||||
name: 'report.pdf',
|
||||
size: 128,
|
||||
type: 'application/pdf',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: 'document',
|
||||
} satisfies FileEntity,
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('attachments')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('file-list')).toHaveTextContent('report.pdf')
|
||||
})
|
||||
})
|
||||
131
web/app/components/workflow/run/__tests__/status.spec.tsx
Normal file
131
web/app/components/workflow/run/__tests__/status.spec.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { WorkflowPausedDetailsResponse } from '@/models/log'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Status from '../status'
|
||||
|
||||
const mockDocLink = vi.fn((path: string) => `https://docs.example.com${path}`)
|
||||
const mockUseWorkflowPausedDetails = vi.fn()
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => mockDocLink,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-log', () => ({
|
||||
useWorkflowPausedDetails: (params: { workflowRunId: string, enabled?: boolean }) => mockUseWorkflowPausedDetails(params),
|
||||
}))
|
||||
|
||||
const createPausedDetails = (overrides: Partial<WorkflowPausedDetailsResponse> = {}): WorkflowPausedDetailsResponse => ({
|
||||
paused_at: '2026-03-18T00:00:00Z',
|
||||
paused_nodes: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Status', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseWorkflowPausedDetails.mockReturnValue({ data: undefined })
|
||||
})
|
||||
|
||||
it('renders the running status and loading placeholders', () => {
|
||||
render(<Status status="running" workflowRunId="run-1" />)
|
||||
|
||||
expect(screen.getByText('Running')).toBeInTheDocument()
|
||||
expect(document.querySelectorAll('.bg-text-quaternary')).toHaveLength(2)
|
||||
expect(mockUseWorkflowPausedDetails).toHaveBeenCalledWith({
|
||||
workflowRunId: 'run-1',
|
||||
enabled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the listening label when the run is waiting for input', () => {
|
||||
render(<Status status="running" isListening workflowRunId="run-2" />)
|
||||
|
||||
expect(screen.getByText('Listening')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders succeeded metadata values', () => {
|
||||
render(<Status status="succeeded" time={1.234} tokens={8} />)
|
||||
|
||||
expect(screen.getByText('SUCCESS')).toBeInTheDocument()
|
||||
expect(screen.getByText('1.234s')).toBeInTheDocument()
|
||||
expect(screen.getByText('8 Tokens')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders stopped fallbacks when time and tokens are missing', () => {
|
||||
render(<Status status="stopped" />)
|
||||
|
||||
expect(screen.getByText('STOP')).toBeInTheDocument()
|
||||
expect(screen.getByText('-')).toBeInTheDocument()
|
||||
expect(screen.getByText('0 Tokens')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders failed details and the partial-success exception tip', () => {
|
||||
render(<Status status="failed" error="Something broke" exceptionCounts={2} />)
|
||||
|
||||
expect(screen.getByText('FAIL')).toBeInTheDocument()
|
||||
expect(screen.getByText('Something broke')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.common.errorHandle.partialSucceeded.tip:{"num":2}')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the partial-succeeded warning summary', () => {
|
||||
render(<Status status="partial-succeeded" exceptionCounts={3} />)
|
||||
|
||||
expect(screen.getByText('PARTIAL SUCCESS')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.common.errorHandle.partialSucceeded.tip:{"num":3}')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the exception learn-more link', () => {
|
||||
render(<Status status="exception" error="Bad request" />)
|
||||
|
||||
const learnMoreLink = screen.getByRole('link', { name: 'workflow.common.learnMore' })
|
||||
|
||||
expect(screen.getByText('EXCEPTION')).toBeInTheDocument()
|
||||
expect(learnMoreLink).toHaveAttribute('href', 'https://docs.example.com/use-dify/debug/error-type')
|
||||
expect(mockDocLink).toHaveBeenCalledWith('/use-dify/debug/error-type')
|
||||
})
|
||||
|
||||
it('renders paused placeholders when pause details have not loaded yet', () => {
|
||||
render(<Status status="paused" workflowRunId="run-3" />)
|
||||
|
||||
expect(screen.getByText('PENDING')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.humanInput.log.reason')).toBeInTheDocument()
|
||||
expect(document.querySelectorAll('.bg-text-quaternary')).toHaveLength(3)
|
||||
expect(mockUseWorkflowPausedDetails).toHaveBeenCalledWith({
|
||||
workflowRunId: 'run-3',
|
||||
enabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders paused human-input reasons and backstage URLs', () => {
|
||||
mockUseWorkflowPausedDetails.mockReturnValue({
|
||||
data: createPausedDetails({
|
||||
paused_nodes: [
|
||||
{
|
||||
node_id: 'node-1',
|
||||
node_title: 'Need review',
|
||||
pause_type: {
|
||||
type: 'human_input',
|
||||
form_id: 'form-1',
|
||||
backstage_input_url: 'https://example.com/a',
|
||||
},
|
||||
},
|
||||
{
|
||||
node_id: 'node-2',
|
||||
node_title: 'Need review 2',
|
||||
pause_type: {
|
||||
type: 'human_input',
|
||||
form_id: 'form-2',
|
||||
backstage_input_url: 'https://example.com/b',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
render(<Status status="paused" workflowRunId="run-4" />)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.humanInput.log.reasonContent')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.humanInput.log.backstageInputURL')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'https://example.com/a' })).toHaveAttribute('href', 'https://example.com/a')
|
||||
expect(screen.getByRole('link', { name: 'https://example.com/b' })).toHaveAttribute('href', 'https://example.com/b')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { NodeProps } from 'reactflow'
|
||||
import type { CommonNodeType } from '@/app/components/workflow/types'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import ReactFlow, { ReactFlowProvider } from 'reactflow'
|
||||
import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
|
||||
import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types'
|
||||
import ErrorHandleOnNode from '../error-handle-on-node'
|
||||
|
||||
const createNodeData = (overrides: Partial<CommonNodeType> = {}): CommonNodeType => ({
|
||||
type: BlockEnum.Code,
|
||||
title: 'Node',
|
||||
desc: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const ErrorNode = ({ id, data }: NodeProps<CommonNodeType>) => (
|
||||
<div>
|
||||
<ErrorHandleOnNode id={id} data={data} />
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderErrorNode = (data: CommonNodeType) => {
|
||||
return render(
|
||||
<div style={{ width: 800, height: 600 }}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow
|
||||
fitView
|
||||
edges={[]}
|
||||
nodes={[
|
||||
{
|
||||
id: 'node-1',
|
||||
type: 'errorNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data,
|
||||
},
|
||||
]}
|
||||
nodeTypes={{ errorNode: ErrorNode }}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ErrorHandleOnNode', () => {
|
||||
// Empty and default-value states.
|
||||
describe('Rendering', () => {
|
||||
it('should render nothing when the node has no error strategy', () => {
|
||||
const { container } = renderErrorNode(createNodeData())
|
||||
|
||||
expect(screen.queryByText('workflow.common.onFailure')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('.react-flow__handle')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the default-value label', async () => {
|
||||
renderErrorNode(createNodeData({ error_strategy: ErrorHandleTypeEnum.defaultValue }))
|
||||
|
||||
await waitFor(() => expect(screen.getByText('workflow.common.onFailure')).toBeInTheDocument())
|
||||
expect(screen.getByText('workflow.common.onFailure')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.common.errorHandle.defaultValue.output')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Fail-branch behavior and warning styling.
|
||||
describe('Effects', () => {
|
||||
it('should render the fail-branch source handle', async () => {
|
||||
const { container } = renderErrorNode(createNodeData({ error_strategy: ErrorHandleTypeEnum.failBranch }))
|
||||
|
||||
await waitFor(() => expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.title')).toBeInTheDocument())
|
||||
expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.title')).toBeInTheDocument()
|
||||
expect(container.querySelector('.react-flow__handle')).toHaveAttribute('data-handleid', ErrorHandleTypeEnum.failBranch)
|
||||
})
|
||||
|
||||
it('should add warning styles when the node is in exception status', async () => {
|
||||
const { container } = renderErrorNode(createNodeData({
|
||||
error_strategy: ErrorHandleTypeEnum.defaultValue,
|
||||
_runningStatus: NodeRunningStatus.Exception,
|
||||
}))
|
||||
|
||||
await waitFor(() => expect(container.querySelector('.bg-state-warning-hover')).toBeInTheDocument())
|
||||
expect(container.querySelector('.bg-state-warning-hover')).toHaveClass('border-components-badge-status-light-warning-halo')
|
||||
expect(container.querySelector('.text-text-warning')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { NodeProps } from 'reactflow'
|
||||
import type { CommonNodeType } from '@/app/components/workflow/types'
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import ReactFlow, { ReactFlowProvider } from 'reactflow'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { NodeSourceHandle, NodeTargetHandle } from '../node-handle'
|
||||
|
||||
const createNodeData = (overrides: Partial<CommonNodeType> = {}): CommonNodeType => ({
|
||||
type: BlockEnum.Code,
|
||||
title: 'Node',
|
||||
desc: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const TargetHandleNode = ({ id, data }: NodeProps<CommonNodeType>) => (
|
||||
<div>
|
||||
<NodeTargetHandle
|
||||
id={id}
|
||||
data={data}
|
||||
handleId="target-1"
|
||||
handleClassName="target-marker"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
const SourceHandleNode = ({ id, data }: NodeProps<CommonNodeType>) => (
|
||||
<div>
|
||||
<NodeSourceHandle
|
||||
id={id}
|
||||
data={data}
|
||||
handleId="source-1"
|
||||
handleClassName="source-marker"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderFlowNode = (type: 'targetNode' | 'sourceNode', data: CommonNodeType) => {
|
||||
return render(
|
||||
<div style={{ width: 800, height: 600 }}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow
|
||||
fitView
|
||||
edges={[]}
|
||||
nodes={[
|
||||
{
|
||||
id: 'node-1',
|
||||
type,
|
||||
position: { x: 0, y: 0 },
|
||||
data,
|
||||
},
|
||||
]}
|
||||
nodeTypes={{
|
||||
targetNode: TargetHandleNode,
|
||||
sourceNode: SourceHandleNode,
|
||||
}}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('node-handle', () => {
|
||||
// Target handle states and visibility rules.
|
||||
describe('NodeTargetHandle', () => {
|
||||
it('should hide the connection indicator when the target handle is not connected', async () => {
|
||||
const { container } = renderFlowNode('targetNode', createNodeData())
|
||||
|
||||
await waitFor(() => expect(container.querySelector('.target-marker')).toBeInTheDocument())
|
||||
|
||||
const handle = container.querySelector('.target-marker')
|
||||
|
||||
expect(handle).toHaveAttribute('data-handleid', 'target-1')
|
||||
expect(handle).toHaveClass('after:opacity-0')
|
||||
})
|
||||
|
||||
it('should merge custom classes and hide start-like nodes completely', async () => {
|
||||
const { container } = render(
|
||||
<div style={{ width: 800, height: 600 }}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow
|
||||
fitView
|
||||
edges={[]}
|
||||
nodes={[
|
||||
{
|
||||
id: 'node-2',
|
||||
type: 'targetNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: createNodeData({ type: BlockEnum.Start }),
|
||||
},
|
||||
]}
|
||||
nodeTypes={{
|
||||
targetNode: ({ id, data }: NodeProps<CommonNodeType>) => (
|
||||
<div>
|
||||
<NodeTargetHandle
|
||||
id={id}
|
||||
data={data}
|
||||
handleId="target-2"
|
||||
handleClassName="custom-target"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</div>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(container.querySelector('.custom-target')).toBeInTheDocument())
|
||||
|
||||
const handle = container.querySelector('.custom-target')
|
||||
|
||||
expect(handle).toHaveClass('opacity-0')
|
||||
expect(handle).toHaveClass('custom-target')
|
||||
})
|
||||
})
|
||||
|
||||
// Source handle connection state.
|
||||
describe('NodeSourceHandle', () => {
|
||||
it('should keep the source indicator visible when the handle is connected', async () => {
|
||||
const { container } = renderFlowNode('sourceNode', createNodeData({ _connectedSourceHandleIds: ['source-1'] }))
|
||||
|
||||
await waitFor(() => expect(container.querySelector('.source-marker')).toBeInTheDocument())
|
||||
|
||||
const handle = container.querySelector('.source-marker')
|
||||
|
||||
expect(handle).toHaveAttribute('data-handleid', 'source-1')
|
||||
expect(handle).not.toHaveClass('after:opacity-0')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6602,11 +6602,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/workflow/header/scroll-to-selected-node-button.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/workflow/header/test-run-menu.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
||||
Reference in New Issue
Block a user