From 8cf1da96f5e790d3479bf2f9767e660b0a60ba2b Mon Sep 17 00:00:00 2001 From: Joel Date: Wed, 17 Dec 2025 16:39:53 +0800 Subject: [PATCH 01/61] chore: tests for app agent configures (#29789) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../config/agent/agent-setting/index.spec.tsx | 112 +++++ .../agent/agent-setting/item-panel.spec.tsx | 21 + .../config/agent/agent-tools/index.spec.tsx | 466 ++++++++++++++++++ .../config/agent/agent-tools/index.tsx | 3 +- .../setting-built-in-tool.spec.tsx | 248 ++++++++++ 5 files changed, 849 insertions(+), 1 deletion(-) create mode 100644 web/app/components/app/configuration/config/agent/agent-setting/index.spec.tsx create mode 100644 web/app/components/app/configuration/config/agent/agent-setting/item-panel.spec.tsx create mode 100644 web/app/components/app/configuration/config/agent/agent-tools/index.spec.tsx create mode 100644 web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.spec.tsx diff --git a/web/app/components/app/configuration/config/agent/agent-setting/index.spec.tsx b/web/app/components/app/configuration/config/agent/agent-setting/index.spec.tsx new file mode 100644 index 0000000000..00c0776718 --- /dev/null +++ b/web/app/components/app/configuration/config/agent/agent-setting/index.spec.tsx @@ -0,0 +1,112 @@ +import React from 'react' +import { act, fireEvent, render, screen } from '@testing-library/react' +import AgentSetting from './index' +import { MAX_ITERATIONS_NUM } from '@/config' +import type { AgentConfig } from '@/models/debug' + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +jest.mock('ahooks', () => { + const actual = jest.requireActual('ahooks') + return { + ...actual, + useClickAway: jest.fn(), + } +}) + +jest.mock('react-slider', () => (props: { className?: string; min?: number; max?: number; value: number; onChange: (value: number) => void }) => ( + props.onChange(Number(e.target.value))} + /> +)) + +const basePayload = { + enabled: true, + strategy: 'react', + max_iteration: 5, + tools: [], +} + +const renderModal = (props?: Partial>) => { + const onCancel = jest.fn() + const onSave = jest.fn() + const utils = render( + , + ) + return { ...utils, onCancel, onSave } +} + +describe('AgentSetting', () => { + test('should render agent mode description and default prompt section when not function call', () => { + renderModal() + + expect(screen.getByText('appDebug.agent.agentMode')).toBeInTheDocument() + expect(screen.getByText('appDebug.agent.agentModeType.ReACT')).toBeInTheDocument() + expect(screen.getByText('tools.builtInPromptTitle')).toBeInTheDocument() + }) + + test('should display function call mode when isFunctionCall true', () => { + renderModal({ isFunctionCall: true }) + + expect(screen.getByText('appDebug.agent.agentModeType.functionCall')).toBeInTheDocument() + expect(screen.queryByText('tools.builtInPromptTitle')).not.toBeInTheDocument() + }) + + test('should update iteration via slider and number input', () => { + const { container } = renderModal() + const slider = container.querySelector('.slider') as HTMLInputElement + const numberInput = screen.getByRole('spinbutton') + + fireEvent.change(slider, { target: { value: '7' } }) + expect(screen.getAllByDisplayValue('7')).toHaveLength(2) + + fireEvent.change(numberInput, { target: { value: '2' } }) + expect(screen.getAllByDisplayValue('2')).toHaveLength(2) + }) + + test('should clamp iteration value within min/max range', () => { + renderModal() + + const numberInput = screen.getByRole('spinbutton') + + fireEvent.change(numberInput, { target: { value: '0' } }) + expect(screen.getAllByDisplayValue('1')).toHaveLength(2) + + fireEvent.change(numberInput, { target: { value: '999' } }) + expect(screen.getAllByDisplayValue(String(MAX_ITERATIONS_NUM))).toHaveLength(2) + }) + + test('should call onCancel when cancel button clicked', () => { + const { onCancel } = renderModal() + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + expect(onCancel).toHaveBeenCalled() + }) + + test('should call onSave with updated payload', async () => { + const { onSave } = renderModal() + const numberInput = screen.getByRole('spinbutton') + fireEvent.change(numberInput, { target: { value: '6' } }) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + }) + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ max_iteration: 6 })) + }) +}) diff --git a/web/app/components/app/configuration/config/agent/agent-setting/item-panel.spec.tsx b/web/app/components/app/configuration/config/agent/agent-setting/item-panel.spec.tsx new file mode 100644 index 0000000000..242f249738 --- /dev/null +++ b/web/app/components/app/configuration/config/agent/agent-setting/item-panel.spec.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import ItemPanel from './item-panel' + +describe('AgentSetting/ItemPanel', () => { + test('should render icon, name, and children content', () => { + render( + icon} + name="Panel name" + description="More info" + children={
child content
} + />, + ) + + expect(screen.getByText('Panel name')).toBeInTheDocument() + expect(screen.getByText('child content')).toBeInTheDocument() + expect(screen.getByText('icon')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/configuration/config/agent/agent-tools/index.spec.tsx b/web/app/components/app/configuration/config/agent/agent-tools/index.spec.tsx new file mode 100644 index 0000000000..9899f15375 --- /dev/null +++ b/web/app/components/app/configuration/config/agent/agent-tools/index.spec.tsx @@ -0,0 +1,466 @@ +import type { + PropsWithChildren, +} from 'react' +import React, { + useEffect, + useMemo, + useState, +} from 'react' +import { act, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import AgentTools from './index' +import ConfigContext from '@/context/debug-configuration' +import type { AgentTool } from '@/types/app' +import { CollectionType, type Tool, type ToolParameter } from '@/app/components/tools/types' +import type { ToolWithProvider } from '@/app/components/workflow/types' +import type { ToolDefaultValue } from '@/app/components/workflow/block-selector/types' +import type { ModelConfig } from '@/models/debug' +import { ModelModeType } from '@/types/app' +import { + DEFAULT_AGENT_SETTING, + DEFAULT_CHAT_PROMPT_CONFIG, + DEFAULT_COMPLETION_PROMPT_CONFIG, +} from '@/config' +import copy from 'copy-to-clipboard' +import type ToolPickerType from '@/app/components/workflow/block-selector/tool-picker' +import type SettingBuiltInToolType from './setting-built-in-tool' + +const formattingDispatcherMock = jest.fn() +jest.mock('@/app/components/app/configuration/debug/hooks', () => ({ + useFormattingChangedDispatcher: () => formattingDispatcherMock, +})) + +let pluginInstallHandler: ((names: string[]) => void) | null = null +const subscribeMock = jest.fn((event: string, handler: any) => { + if (event === 'plugin:install:success') + pluginInstallHandler = handler +}) +jest.mock('@/context/mitt-context', () => ({ + useMittContextSelector: (selector: any) => selector({ + useSubscribe: subscribeMock, + }), +})) + +let builtInTools: ToolWithProvider[] = [] +let customTools: ToolWithProvider[] = [] +let workflowTools: ToolWithProvider[] = [] +let mcpTools: ToolWithProvider[] = [] +jest.mock('@/service/use-tools', () => ({ + useAllBuiltInTools: () => ({ data: builtInTools }), + useAllCustomTools: () => ({ data: customTools }), + useAllWorkflowTools: () => ({ data: workflowTools }), + useAllMCPTools: () => ({ data: mcpTools }), +})) + +type ToolPickerProps = React.ComponentProps +let singleToolSelection: ToolDefaultValue | null = null +let multipleToolSelection: ToolDefaultValue[] = [] +const ToolPickerMock = (props: ToolPickerProps) => ( +
+
{props.trigger}
+ + +
+) +jest.mock('@/app/components/workflow/block-selector/tool-picker', () => ({ + __esModule: true, + default: (props: ToolPickerProps) => , +})) + +type SettingBuiltInToolProps = React.ComponentProps +let latestSettingPanelProps: SettingBuiltInToolProps | null = null +let settingPanelSavePayload: Record = {} +let settingPanelCredentialId = 'credential-from-panel' +const SettingBuiltInToolMock = (props: SettingBuiltInToolProps) => { + latestSettingPanelProps = props + return ( +
+ {props.toolName} + + + +
+ ) +} +jest.mock('./setting-built-in-tool', () => ({ + __esModule: true, + default: (props: SettingBuiltInToolProps) => , +})) + +jest.mock('copy-to-clipboard') + +const copyMock = copy as jest.Mock + +const createToolParameter = (overrides?: Partial): ToolParameter => ({ + name: 'api_key', + label: { + en_US: 'API Key', + zh_Hans: 'API Key', + }, + human_description: { + en_US: 'desc', + zh_Hans: 'desc', + }, + type: 'string', + form: 'config', + llm_description: '', + required: true, + multiple: false, + default: 'default', + ...overrides, +}) + +const createToolDefinition = (overrides?: Partial): Tool => ({ + name: 'search', + author: 'tester', + label: { + en_US: 'Search', + zh_Hans: 'Search', + }, + description: { + en_US: 'desc', + zh_Hans: 'desc', + }, + parameters: [createToolParameter()], + labels: [], + output_schema: {}, + ...overrides, +}) + +const createCollection = (overrides?: Partial): ToolWithProvider => ({ + id: overrides?.id || 'provider-1', + name: overrides?.name || 'vendor/provider-1', + author: 'tester', + description: { + en_US: 'desc', + zh_Hans: 'desc', + }, + icon: 'https://example.com/icon.png', + label: { + en_US: 'Provider Label', + zh_Hans: 'Provider Label', + }, + type: overrides?.type || CollectionType.builtIn, + team_credentials: {}, + is_team_authorization: true, + allow_delete: true, + labels: [], + tools: overrides?.tools || [createToolDefinition()], + meta: { + version: '1.0.0', + }, + ...overrides, +}) + +const createAgentTool = (overrides?: Partial): AgentTool => ({ + provider_id: overrides?.provider_id || 'provider-1', + provider_type: overrides?.provider_type || CollectionType.builtIn, + provider_name: overrides?.provider_name || 'vendor/provider-1', + tool_name: overrides?.tool_name || 'search', + tool_label: overrides?.tool_label || 'Search Tool', + tool_parameters: overrides?.tool_parameters || { api_key: 'key' }, + enabled: overrides?.enabled ?? true, + ...overrides, +}) + +const createModelConfig = (tools: AgentTool[]): ModelConfig => ({ + provider: 'OPENAI', + model_id: 'gpt-3.5-turbo', + mode: ModelModeType.chat, + configs: { + prompt_template: '', + prompt_variables: [], + }, + chat_prompt_config: DEFAULT_CHAT_PROMPT_CONFIG, + completion_prompt_config: DEFAULT_COMPLETION_PROMPT_CONFIG, + opening_statement: '', + more_like_this: null, + suggested_questions: [], + suggested_questions_after_answer: null, + speech_to_text: null, + text_to_speech: null, + file_upload: null, + retriever_resource: null, + sensitive_word_avoidance: null, + annotation_reply: null, + external_data_tools: [], + system_parameters: { + audio_file_size_limit: 0, + file_size_limit: 0, + image_file_size_limit: 0, + video_file_size_limit: 0, + workflow_file_upload_limit: 0, + }, + dataSets: [], + agentConfig: { + ...DEFAULT_AGENT_SETTING, + tools, + }, +}) + +const renderAgentTools = (initialTools?: AgentTool[]) => { + const tools = initialTools ?? [createAgentTool()] + const modelConfigRef = { current: createModelConfig(tools) } + const Wrapper = ({ children }: PropsWithChildren) => { + const [modelConfig, setModelConfig] = useState(modelConfigRef.current) + useEffect(() => { + modelConfigRef.current = modelConfig + }, [modelConfig]) + const value = useMemo(() => ({ + modelConfig, + setModelConfig, + }), [modelConfig]) + return ( + + {children} + + ) + } + const renderResult = render( + + + , + ) + return { + ...renderResult, + getModelConfig: () => modelConfigRef.current, + } +} + +const hoverInfoIcon = async (rowIndex = 0) => { + const rows = document.querySelectorAll('.group') + const infoTrigger = rows.item(rowIndex)?.querySelector('[data-testid="tool-info-tooltip"]') + if (!infoTrigger) + throw new Error('Info trigger not found') + await userEvent.hover(infoTrigger as HTMLElement) +} + +describe('AgentTools', () => { + beforeEach(() => { + jest.clearAllMocks() + builtInTools = [ + createCollection(), + createCollection({ + id: 'provider-2', + name: 'vendor/provider-2', + tools: [createToolDefinition({ + name: 'translate', + label: { + en_US: 'Translate', + zh_Hans: 'Translate', + }, + })], + }), + createCollection({ + id: 'provider-3', + name: 'vendor/provider-3', + tools: [createToolDefinition({ + name: 'summarize', + label: { + en_US: 'Summary', + zh_Hans: 'Summary', + }, + })], + }), + ] + customTools = [] + workflowTools = [] + mcpTools = [] + singleToolSelection = { + provider_id: 'provider-3', + provider_type: CollectionType.builtIn, + provider_name: 'vendor/provider-3', + tool_name: 'summarize', + tool_label: 'Summary Tool', + tool_description: 'desc', + title: 'Summary Tool', + is_team_authorization: true, + params: { api_key: 'picker-value' }, + paramSchemas: [], + output_schema: {}, + } + multipleToolSelection = [ + { + provider_id: 'provider-2', + provider_type: CollectionType.builtIn, + provider_name: 'vendor/provider-2', + tool_name: 'translate', + tool_label: 'Translate Tool', + tool_description: 'desc', + title: 'Translate Tool', + is_team_authorization: true, + params: { api_key: 'multi-a' }, + paramSchemas: [], + output_schema: {}, + }, + { + provider_id: 'provider-3', + provider_type: CollectionType.builtIn, + provider_name: 'vendor/provider-3', + tool_name: 'summarize', + tool_label: 'Summary Tool', + tool_description: 'desc', + title: 'Summary Tool', + is_team_authorization: true, + params: { api_key: 'multi-b' }, + paramSchemas: [], + output_schema: {}, + }, + ] + latestSettingPanelProps = null + settingPanelSavePayload = {} + settingPanelCredentialId = 'credential-from-panel' + pluginInstallHandler = null + }) + + test('should show enabled count and provider information', () => { + renderAgentTools([ + createAgentTool(), + createAgentTool({ + provider_id: 'provider-2', + provider_name: 'vendor/provider-2', + tool_name: 'translate', + tool_label: 'Translate Tool', + enabled: false, + }), + ]) + + const enabledText = screen.getByText(content => content.includes('appDebug.agent.tools.enabled')) + expect(enabledText).toHaveTextContent('1/2') + expect(screen.getByText('provider-1')).toBeInTheDocument() + expect(screen.getByText('Translate Tool')).toBeInTheDocument() + }) + + test('should copy tool name from tooltip action', async () => { + renderAgentTools() + + await hoverInfoIcon() + const copyButton = await screen.findByText('tools.copyToolName') + await userEvent.click(copyButton) + expect(copyMock).toHaveBeenCalledWith('search') + }) + + test('should toggle tool enabled state via switch', async () => { + const { getModelConfig } = renderAgentTools() + + const switchButton = screen.getByRole('switch') + await userEvent.click(switchButton) + + await waitFor(() => { + const tools = getModelConfig().agentConfig.tools as Array<{ tool_name?: string; enabled?: boolean }> + const toggledTool = tools.find(tool => tool.tool_name === 'search') + expect(toggledTool?.enabled).toBe(false) + }) + expect(formattingDispatcherMock).toHaveBeenCalled() + }) + + test('should remove tool when delete action is clicked', async () => { + const { getModelConfig } = renderAgentTools() + const deleteButton = screen.getByTestId('delete-removed-tool') + if (!deleteButton) + throw new Error('Delete button not found') + await userEvent.click(deleteButton) + await waitFor(() => { + expect(getModelConfig().agentConfig.tools).toHaveLength(0) + }) + expect(formattingDispatcherMock).toHaveBeenCalled() + }) + + test('should add a tool when ToolPicker selects one', async () => { + const { getModelConfig } = renderAgentTools([]) + const addSingleButton = screen.getByRole('button', { name: 'pick-single' }) + await userEvent.click(addSingleButton) + + await waitFor(() => { + expect(screen.getByText('Summary Tool')).toBeInTheDocument() + }) + expect(getModelConfig().agentConfig.tools).toHaveLength(1) + }) + + test('should append multiple selected tools at once', async () => { + const { getModelConfig } = renderAgentTools([]) + await userEvent.click(screen.getByRole('button', { name: 'pick-multiple' })) + + await waitFor(() => { + expect(screen.getByText('Translate Tool')).toBeInTheDocument() + expect(screen.getAllByText('Summary Tool')).toHaveLength(1) + }) + expect(getModelConfig().agentConfig.tools).toHaveLength(2) + }) + + test('should open settings panel for not authorized tool', async () => { + renderAgentTools([ + createAgentTool({ + notAuthor: true, + }), + ]) + + const notAuthorizedButton = screen.getByRole('button', { name: /tools.notAuthorized/ }) + await userEvent.click(notAuthorizedButton) + expect(screen.getByTestId('setting-built-in-tool')).toBeInTheDocument() + expect(latestSettingPanelProps?.toolName).toBe('search') + }) + + test('should persist tool parameters when SettingBuiltInTool saves values', async () => { + const { getModelConfig } = renderAgentTools([ + createAgentTool({ + notAuthor: true, + }), + ]) + await userEvent.click(screen.getByRole('button', { name: /tools.notAuthorized/ })) + settingPanelSavePayload = { api_key: 'updated' } + await userEvent.click(screen.getByRole('button', { name: 'save-from-panel' })) + + await waitFor(() => { + expect((getModelConfig().agentConfig.tools[0] as { tool_parameters: Record }).tool_parameters).toEqual({ api_key: 'updated' }) + }) + }) + + test('should update credential id when authorization selection changes', async () => { + const { getModelConfig } = renderAgentTools([ + createAgentTool({ + notAuthor: true, + }), + ]) + await userEvent.click(screen.getByRole('button', { name: /tools.notAuthorized/ })) + settingPanelCredentialId = 'credential-123' + await userEvent.click(screen.getByRole('button', { name: 'auth-from-panel' })) + + await waitFor(() => { + expect((getModelConfig().agentConfig.tools[0] as { credential_id: string }).credential_id).toBe('credential-123') + }) + expect(formattingDispatcherMock).toHaveBeenCalled() + }) + + test('should reinstate deleted tools after plugin install success event', async () => { + const { getModelConfig } = renderAgentTools([ + createAgentTool({ + provider_id: 'provider-1', + provider_name: 'vendor/provider-1', + tool_name: 'search', + tool_label: 'Search Tool', + isDeleted: true, + }), + ]) + if (!pluginInstallHandler) + throw new Error('Plugin handler not registered') + + await act(async () => { + pluginInstallHandler?.(['provider-1']) + }) + + await waitFor(() => { + expect((getModelConfig().agentConfig.tools[0] as { isDeleted: boolean }).isDeleted).toBe(false) + }) + }) +}) diff --git a/web/app/components/app/configuration/config/agent/agent-tools/index.tsx b/web/app/components/app/configuration/config/agent/agent-tools/index.tsx index 5716bfd92d..4793b5fe49 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/index.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/index.tsx @@ -217,7 +217,7 @@ const AgentTools: FC = () => { } >
-
+
@@ -277,6 +277,7 @@ const AgentTools: FC = () => { }} onMouseOver={() => setIsDeleting(index)} onMouseLeave={() => setIsDeleting(-1)} + data-testid='delete-removed-tool' >
diff --git a/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.spec.tsx b/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.spec.tsx new file mode 100644 index 0000000000..8cd95472dc --- /dev/null +++ b/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.spec.tsx @@ -0,0 +1,248 @@ +import React from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import SettingBuiltInTool from './setting-built-in-tool' +import I18n from '@/context/i18n' +import { CollectionType, type Tool, type ToolParameter } from '@/app/components/tools/types' + +const fetchModelToolList = jest.fn() +const fetchBuiltInToolList = jest.fn() +const fetchCustomToolList = jest.fn() +const fetchWorkflowToolList = jest.fn() +jest.mock('@/service/tools', () => ({ + fetchModelToolList: (collectionName: string) => fetchModelToolList(collectionName), + fetchBuiltInToolList: (collectionName: string) => fetchBuiltInToolList(collectionName), + fetchCustomToolList: (collectionName: string) => fetchCustomToolList(collectionName), + fetchWorkflowToolList: (appId: string) => fetchWorkflowToolList(appId), +})) + +type MockFormProps = { + value: Record + onChange: (val: Record) => void +} +let nextFormValue: Record = {} +const FormMock = ({ value, onChange }: MockFormProps) => { + return ( +
+
{JSON.stringify(value)}
+ +
+ ) +} +jest.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({ + __esModule: true, + default: (props: MockFormProps) => , +})) + +let pluginAuthClickValue = 'credential-from-plugin' +jest.mock('@/app/components/plugins/plugin-auth', () => ({ + AuthCategory: { tool: 'tool' }, + PluginAuthInAgent: (props: { onAuthorizationItemClick?: (id: string) => void }) => ( +
+ +
+ ), +})) + +jest.mock('@/app/components/plugins/readme-panel/entrance', () => ({ + ReadmeEntrance: ({ className }: { className?: string }) =>
readme
, +})) + +const createParameter = (overrides?: Partial): ToolParameter => ({ + name: 'settingParam', + label: { + en_US: 'Setting Param', + zh_Hans: 'Setting Param', + }, + human_description: { + en_US: 'desc', + zh_Hans: 'desc', + }, + type: 'string', + form: 'config', + llm_description: '', + required: true, + multiple: false, + default: '', + ...overrides, +}) + +const createTool = (overrides?: Partial): Tool => ({ + name: 'search', + author: 'tester', + label: { + en_US: 'Search Tool', + zh_Hans: 'Search Tool', + }, + description: { + en_US: 'tool description', + zh_Hans: 'tool description', + }, + parameters: [ + createParameter({ + name: 'infoParam', + label: { + en_US: 'Info Param', + zh_Hans: 'Info Param', + }, + form: 'llm', + required: false, + }), + createParameter(), + ], + labels: [], + output_schema: {}, + ...overrides, +}) + +const baseCollection = { + id: 'provider-1', + name: 'vendor/provider-1', + author: 'tester', + description: { + en_US: 'desc', + zh_Hans: 'desc', + }, + icon: 'https://example.com/icon.png', + label: { + en_US: 'Provider Label', + zh_Hans: 'Provider Label', + }, + type: CollectionType.builtIn, + team_credentials: {}, + is_team_authorization: true, + allow_delete: true, + labels: [], + tools: [createTool()], +} + +const renderComponent = (props?: Partial>) => { + const onHide = jest.fn() + const onSave = jest.fn() + const onAuthorizationItemClick = jest.fn() + const utils = render( + + + , + ) + return { + ...utils, + onHide, + onSave, + onAuthorizationItemClick, + } +} + +describe('SettingBuiltInTool', () => { + beforeEach(() => { + jest.clearAllMocks() + nextFormValue = {} + pluginAuthClickValue = 'credential-from-plugin' + }) + + test('should fetch tool list when collection has no tools', async () => { + fetchModelToolList.mockResolvedValueOnce([createTool()]) + renderComponent({ + collection: { + ...baseCollection, + tools: [], + }, + }) + + await waitFor(() => { + expect(fetchModelToolList).toHaveBeenCalledTimes(1) + expect(fetchModelToolList).toHaveBeenCalledWith('vendor/provider-1') + }) + expect(await screen.findByText('Search Tool')).toBeInTheDocument() + }) + + test('should switch between info and setting tabs', async () => { + renderComponent() + await waitFor(() => { + expect(screen.getByTestId('mock-form')).toBeInTheDocument() + }) + + await userEvent.click(screen.getByText('tools.setBuiltInTools.parameters')) + expect(screen.getByText('Info Param')).toBeInTheDocument() + await userEvent.click(screen.getByText('tools.setBuiltInTools.setting')) + expect(screen.getByTestId('mock-form')).toBeInTheDocument() + }) + + test('should call onSave with updated values when save button clicked', async () => { + const { onSave } = renderComponent() + await waitFor(() => expect(screen.getByTestId('mock-form')).toBeInTheDocument()) + nextFormValue = { settingParam: 'updated' } + await userEvent.click(screen.getByRole('button', { name: 'update-form' })) + await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ settingParam: 'updated' })) + }) + + test('should keep save disabled until required field provided', async () => { + renderComponent({ + setting: {}, + }) + await waitFor(() => expect(screen.getByTestId('mock-form')).toBeInTheDocument()) + const saveButton = screen.getByRole('button', { name: 'common.operation.save' }) + expect(saveButton).toBeDisabled() + nextFormValue = { settingParam: 'filled' } + await userEvent.click(screen.getByRole('button', { name: 'update-form' })) + expect(saveButton).not.toBeDisabled() + }) + + test('should call onHide when cancel button is pressed', async () => { + const { onHide } = renderComponent() + await waitFor(() => expect(screen.getByTestId('mock-form')).toBeInTheDocument()) + await userEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + expect(onHide).toHaveBeenCalled() + }) + + test('should trigger authorization callback from plugin auth section', async () => { + const { onAuthorizationItemClick } = renderComponent() + await userEvent.click(screen.getByRole('button', { name: 'choose-plugin-credential' })) + expect(onAuthorizationItemClick).toHaveBeenCalledWith('credential-from-plugin') + }) + + test('should call onHide when back button is clicked', async () => { + const { onHide } = renderComponent({ + showBackButton: true, + }) + await userEvent.click(screen.getByText('plugin.detailPanel.operation.back')) + expect(onHide).toHaveBeenCalled() + }) + + test('should load workflow tools when workflow collection is provided', async () => { + fetchWorkflowToolList.mockResolvedValueOnce([createTool({ + name: 'workflow-tool', + })]) + renderComponent({ + collection: { + ...baseCollection, + type: CollectionType.workflow, + tools: [], + id: 'workflow-1', + } as any, + isBuiltIn: false, + isModel: false, + }) + + await waitFor(() => { + expect(fetchWorkflowToolList).toHaveBeenCalledWith('workflow-1') + }) + }) +}) From f41344e6944f691ca4eb94c97bcbac7b45baf4d0 Mon Sep 17 00:00:00 2001 From: ttaylorr1 Date: Wed, 17 Dec 2025 16:56:16 +0800 Subject: [PATCH 02/61] fix: Correct French grammar (#29793) Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> --- docs/fr-FR/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/fr-FR/README.md b/docs/fr-FR/README.md index 03f3221798..291c8dab40 100644 --- a/docs/fr-FR/README.md +++ b/docs/fr-FR/README.md @@ -61,14 +61,14 @@

langgenius%2Fdify | Trendshift

-Dify est une plateforme de développement d'applications LLM open source. Son interface intuitive combine un flux de travail d'IA, un pipeline RAG, des capacités d'agent, une gestion de modèles, des fonctionnalités d'observabilité, et plus encore, vous permettant de passer rapidement du prototype à la production. Voici une liste des fonctionnalités principales: +Dify est une plateforme de développement d'applications LLM open source. Sa interface intuitive combine un flux de travail d'IA, un pipeline RAG, des capacités d'agent, une gestion de modèles, des fonctionnalités d'observabilité, et plus encore, vous permettant de passer rapidement du prototype à la production. Voici une liste des fonctionnalités principales:

**1. Flux de travail** : Construisez et testez des flux de travail d'IA puissants sur un canevas visuel, en utilisant toutes les fonctionnalités suivantes et plus encore. **2. Prise en charge complète des modèles** : -Intégration transparente avec des centaines de LLM propriétaires / open source provenant de dizaines de fournisseurs d'inférence et de solutions auto-hébergées, couvrant GPT, Mistral, Llama3, et tous les modèles compatibles avec l'API OpenAI. Une liste complète des fournisseurs de modèles pris en charge se trouve [ici](https://docs.dify.ai/getting-started/readme/model-providers). +Intégration transparente avec des centaines de LLM propriétaires / open source offerts par dizaines de fournisseurs d'inférence et de solutions auto-hébergées, couvrant GPT, Mistral, Llama3, et tous les modèles compatibles avec l'API OpenAI. Une liste complète des fournisseurs de modèles pris en charge se trouve [ici](https://docs.dify.ai/getting-started/readme/model-providers). ![providers-v5](https://github.com/langgenius/dify/assets/13230914/5a17bdbe-097a-4100-8363-40255b70f6e3) @@ -79,7 +79,7 @@ Interface intuitive pour créer des prompts, comparer les performances des modè Des capacités RAG étendues qui couvrent tout, de l'ingestion de documents à la récupération, avec un support prêt à l'emploi pour l'extraction de texte à partir de PDF, PPT et autres formats de document courants. **5. Capacités d'agent** : -Vous pouvez définir des agents basés sur l'appel de fonction LLM ou ReAct, et ajouter des outils pré-construits ou personnalisés pour l'agent. Dify fournit plus de 50 outils intégrés pour les agents d'IA, tels que la recherche Google, DALL·E, Stable Diffusion et WolframAlpha. +Vous pouvez définir des agents basés sur l'appel de fonctions LLM ou ReAct, et ajouter des outils pré-construits ou personnalisés pour l'agent. Dify fournit plus de 50 outils intégrés pour les agents d'IA, tels que la recherche Google, DALL·E, Stable Diffusion et WolframAlpha. **6. LLMOps** : Surveillez et analysez les journaux d'application et les performances au fil du temps. Vous pouvez continuellement améliorer les prompts, les ensembles de données et les modèles en fonction des données de production et des annotations. From df2f1eb028f9e7c7083e96b2c90ee28cb7f53a6d Mon Sep 17 00:00:00 2001 From: fanadong Date: Wed, 17 Dec 2025 16:56:41 +0800 Subject: [PATCH 03/61] fix(deps): restore charset_normalizer, revert accidental chardet reintroduction (#29782) --- api/pyproject.toml | 3 ++- api/uv.lock | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index 6fcbc0f25c..870de33f4b 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -12,7 +12,7 @@ dependencies = [ "bs4~=0.0.1", "cachetools~=5.3.0", "celery~=5.5.2", - "chardet~=5.1.0", + "charset-normalizer>=3.4.4", "flask~=3.1.2", "flask-compress>=1.17,<1.18", "flask-cors~=6.0.0", @@ -32,6 +32,7 @@ dependencies = [ "httpx[socks]~=0.27.0", "jieba==0.42.1", "json-repair>=0.41.1", + "jsonschema>=4.25.1", "langfuse~=2.51.3", "langsmith~=0.1.77", "markdown~=3.5.1", diff --git a/api/uv.lock b/api/uv.lock index 726abf6920..8d0dffbd8f 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1380,7 +1380,7 @@ dependencies = [ { name = "bs4" }, { name = "cachetools" }, { name = "celery" }, - { name = "chardet" }, + { name = "charset-normalizer" }, { name = "croniter" }, { name = "flask" }, { name = "flask-compress" }, @@ -1403,6 +1403,7 @@ dependencies = [ { name = "httpx-sse" }, { name = "jieba" }, { name = "json-repair" }, + { name = "jsonschema" }, { name = "langfuse" }, { name = "langsmith" }, { name = "litellm" }, @@ -1577,7 +1578,7 @@ requires-dist = [ { name = "bs4", specifier = "~=0.0.1" }, { name = "cachetools", specifier = "~=5.3.0" }, { name = "celery", specifier = "~=5.5.2" }, - { name = "chardet", specifier = "~=5.1.0" }, + { name = "charset-normalizer", specifier = ">=3.4.4" }, { name = "croniter", specifier = ">=6.0.0" }, { name = "flask", specifier = "~=3.1.2" }, { name = "flask-compress", specifier = ">=1.17,<1.18" }, @@ -1600,6 +1601,7 @@ requires-dist = [ { name = "httpx-sse", specifier = "~=0.4.0" }, { name = "jieba", specifier = "==0.42.1" }, { name = "json-repair", specifier = ">=0.41.1" }, + { name = "jsonschema", specifier = ">=4.25.1" }, { name = "langfuse", specifier = "~=2.51.3" }, { name = "langsmith", specifier = "~=0.1.77" }, { name = "litellm", specifier = "==1.77.1" }, From c474177a1651121a776bf0b55124b174d18299f2 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Thu, 18 Dec 2025 09:59:00 +0800 Subject: [PATCH 04/61] chore: scope docs CODEOWNERS (#29813) --- .github/CODEOWNERS | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d6f326d4dc..13c33308f7 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -6,6 +6,12 @@ * @crazywoola @laipz8200 @Yeuoly +# CODEOWNERS file +.github/CODEOWNERS @laipz8200 @crazywoola + +# Docs +docs/ @crazywoola + # Backend (default owner, more specific rules below will override) api/ @QuantumGhost From 9812dc2cb2666ed6a3db5638e88713cf624b2a79 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Thu, 18 Dec 2025 10:00:11 +0800 Subject: [PATCH 05/61] chore: add some jest tests (#29800) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../edit-item/index.spec.tsx | 6 - .../access-control.spec.tsx | 388 ++++++++++++ .../add-member-or-group-pop.tsx | 2 +- .../config/agent/agent-setting/index.spec.tsx | 6 - .../params-config/config-content.spec.tsx | 392 ++++++++++++ .../params-config/index.spec.tsx | 242 ++++++++ .../params-config/weighted-score.spec.tsx | 81 +++ .../app/create-app-dialog/index.spec.tsx | 6 +- .../billing/annotation-full/index.spec.tsx | 6 - .../billing/annotation-full/modal.spec.tsx | 3 - .../settings/pipeline-settings/index.spec.tsx | 7 - .../process-documents/index.spec.tsx | 7 - .../documents/status-item/index.spec.tsx | 7 - .../explore/create-app-modal/index.spec.tsx | 578 ++++++++++++++++++ .../chat-variable-trigger.spec.tsx | 72 +++ .../workflow-header/features-trigger.spec.tsx | 458 ++++++++++++++ .../components/workflow-header/index.spec.tsx | 149 +++++ 17 files changed, 2364 insertions(+), 46 deletions(-) create mode 100644 web/app/components/app/app-access-control/access-control.spec.tsx create mode 100644 web/app/components/app/configuration/dataset-config/params-config/config-content.spec.tsx create mode 100644 web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx create mode 100644 web/app/components/app/configuration/dataset-config/params-config/weighted-score.spec.tsx create mode 100644 web/app/components/explore/create-app-modal/index.spec.tsx create mode 100644 web/app/components/workflow-app/components/workflow-header/chat-variable-trigger.spec.tsx create mode 100644 web/app/components/workflow-app/components/workflow-header/features-trigger.spec.tsx create mode 100644 web/app/components/workflow-app/components/workflow-header/index.spec.tsx diff --git a/web/app/components/app/annotation/add-annotation-modal/edit-item/index.spec.tsx b/web/app/components/app/annotation/add-annotation-modal/edit-item/index.spec.tsx index 356f813afc..f226adf22b 100644 --- a/web/app/components/app/annotation/add-annotation-modal/edit-item/index.spec.tsx +++ b/web/app/components/app/annotation/add-annotation-modal/edit-item/index.spec.tsx @@ -2,12 +2,6 @@ import React from 'react' import { fireEvent, render, screen } from '@testing-library/react' import EditItem, { EditItemType } from './index' -jest.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - describe('AddAnnotationModal/EditItem', () => { test('should render query inputs with user avatar and placeholder strings', () => { render( diff --git a/web/app/components/app/app-access-control/access-control.spec.tsx b/web/app/components/app/app-access-control/access-control.spec.tsx new file mode 100644 index 0000000000..2959500a29 --- /dev/null +++ b/web/app/components/app/app-access-control/access-control.spec.tsx @@ -0,0 +1,388 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import AccessControl from './index' +import AccessControlDialog from './access-control-dialog' +import AccessControlItem from './access-control-item' +import AddMemberOrGroupDialog from './add-member-or-group-pop' +import SpecificGroupsOrMembers from './specific-groups-or-members' +import useAccessControlStore from '@/context/access-control-store' +import { useGlobalPublicStore } from '@/context/global-public-context' +import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control' +import { AccessMode, SubjectType } from '@/models/access-control' +import Toast from '../../base/toast' +import { defaultSystemFeatures } from '@/types/feature' +import type { App } from '@/types/app' + +const mockUseAppWhiteListSubjects = jest.fn() +const mockUseSearchForWhiteListCandidates = jest.fn() +const mockMutateAsync = jest.fn() +const mockUseUpdateAccessMode = jest.fn(() => ({ + isPending: false, + mutateAsync: mockMutateAsync, +})) + +jest.mock('@/context/app-context', () => ({ + useSelector: (selector: (value: { userProfile: { email: string; id?: string; name?: string; avatar?: string; avatar_url?: string; is_password_set?: boolean } }) => T) => selector({ + userProfile: { + id: 'current-user', + name: 'Current User', + email: 'member@example.com', + avatar: '', + avatar_url: '', + is_password_set: true, + }, + }), +})) + +jest.mock('@/service/common', () => ({ + fetchCurrentWorkspace: jest.fn(), + fetchLangGeniusVersion: jest.fn(), + fetchUserProfile: jest.fn(), + getSystemFeatures: jest.fn(), +})) + +jest.mock('@/service/access-control', () => ({ + useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args), + useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args), + useUpdateAccessMode: () => mockUseUpdateAccessMode(), +})) + +jest.mock('@headlessui/react', () => { + const DialogComponent: any = ({ children, className, ...rest }: any) => ( +
{children}
+ ) + DialogComponent.Panel = ({ children, className, ...rest }: any) => ( +
{children}
+ ) + const DialogTitle = ({ children, className, ...rest }: any) => ( +
{children}
+ ) + const DialogDescription = ({ children, className, ...rest }: any) => ( +
{children}
+ ) + const TransitionChild = ({ children }: any) => ( + <>{typeof children === 'function' ? children({}) : children} + ) + const Transition = ({ show = true, children }: any) => ( + show ? <>{typeof children === 'function' ? children({}) : children} : null + ) + Transition.Child = TransitionChild + return { + Dialog: DialogComponent, + Transition, + DialogTitle, + Description: DialogDescription, + } +}) + +jest.mock('ahooks', () => { + const actual = jest.requireActual('ahooks') + return { + ...actual, + useDebounce: (value: unknown) => value, + } +}) + +const createGroup = (overrides: Partial = {}): AccessControlGroup => ({ + id: 'group-1', + name: 'Group One', + groupSize: 5, + ...overrides, +} as AccessControlGroup) + +const createMember = (overrides: Partial = {}): AccessControlAccount => ({ + id: 'member-1', + name: 'Member One', + email: 'member@example.com', + avatar: '', + avatarUrl: '', + ...overrides, +} as AccessControlAccount) + +const baseGroup = createGroup() +const baseMember = createMember() +const groupSubject: Subject = { + subjectId: baseGroup.id, + subjectType: SubjectType.GROUP, + groupData: baseGroup, +} as Subject +const memberSubject: Subject = { + subjectId: baseMember.id, + subjectType: SubjectType.ACCOUNT, + accountData: baseMember, +} as Subject + +const resetAccessControlStore = () => { + useAccessControlStore.setState({ + appId: '', + specificGroups: [], + specificMembers: [], + currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS, + selectedGroupsForBreadcrumb: [], + }) +} + +const resetGlobalStore = () => { + useGlobalPublicStore.setState({ + systemFeatures: defaultSystemFeatures, + isGlobalPending: false, + }) +} + +beforeAll(() => { + class MockIntersectionObserver { + observe = jest.fn(() => undefined) + disconnect = jest.fn(() => undefined) + unobserve = jest.fn(() => undefined) + } + // @ts-expect-error jsdom does not implement IntersectionObserver + globalThis.IntersectionObserver = MockIntersectionObserver +}) + +beforeEach(() => { + jest.clearAllMocks() + resetAccessControlStore() + resetGlobalStore() + mockMutateAsync.mockResolvedValue(undefined) + mockUseUpdateAccessMode.mockReturnValue({ + isPending: false, + mutateAsync: mockMutateAsync, + }) + mockUseAppWhiteListSubjects.mockReturnValue({ + isPending: false, + data: { + groups: [baseGroup], + members: [baseMember], + }, + }) + mockUseSearchForWhiteListCandidates.mockReturnValue({ + isLoading: false, + isFetchingNextPage: false, + fetchNextPage: jest.fn(), + data: { pages: [{ currPage: 1, subjects: [groupSubject, memberSubject], hasMore: false }] }, + }) +}) + +// AccessControlItem handles selected vs. unselected styling and click state updates +describe('AccessControlItem', () => { + it('should update current menu when selecting a different access type', () => { + useAccessControlStore.setState({ currentMenu: AccessMode.PUBLIC }) + render( + + Organization Only + , + ) + + const option = screen.getByText('Organization Only').parentElement as HTMLElement + expect(option).toHaveClass('cursor-pointer') + + fireEvent.click(option) + + expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION) + }) + + it('should render selected styles when the current menu matches the type', () => { + useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION }) + render( + + Organization Only + , + ) + + const option = screen.getByText('Organization Only').parentElement as HTMLElement + expect(option.className).toContain('border-[1.5px]') + expect(option.className).not.toContain('cursor-pointer') + }) +}) + +// AccessControlDialog renders a headless UI dialog with a manual close control +describe('AccessControlDialog', () => { + it('should render dialog content when visible', () => { + render( + +
Dialog Content
+
, + ) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('Dialog Content')).toBeInTheDocument() + }) + + it('should trigger onClose when clicking the close control', async () => { + const handleClose = jest.fn() + const { container } = render( + +
Dialog Content
+
, + ) + + const closeButton = container.querySelector('.absolute.right-5.top-5') as HTMLElement + fireEvent.click(closeButton) + + await waitFor(() => { + expect(handleClose).toHaveBeenCalledTimes(1) + }) + }) +}) + +// SpecificGroupsOrMembers syncs store state with fetched data and supports removals +describe('SpecificGroupsOrMembers', () => { + it('should render collapsed view when not in specific selection mode', () => { + useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION }) + + render() + + expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument() + expect(screen.queryByText(baseGroup.name)).not.toBeInTheDocument() + }) + + it('should show loading state while pending', async () => { + useAccessControlStore.setState({ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }) + mockUseAppWhiteListSubjects.mockReturnValue({ + isPending: true, + data: undefined, + }) + + const { container } = render() + + await waitFor(() => { + expect(container.querySelector('.spin-animation')).toBeInTheDocument() + }) + }) + + it('should render fetched groups and members and support removal', async () => { + useAccessControlStore.setState({ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }) + + render() + + await waitFor(() => { + expect(screen.getByText(baseGroup.name)).toBeInTheDocument() + expect(screen.getByText(baseMember.name)).toBeInTheDocument() + }) + + const groupItem = screen.getByText(baseGroup.name).closest('div') + const groupRemove = groupItem?.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement + fireEvent.click(groupRemove) + + await waitFor(() => { + expect(screen.queryByText(baseGroup.name)).not.toBeInTheDocument() + }) + + const memberItem = screen.getByText(baseMember.name).closest('div') + const memberRemove = memberItem?.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement + fireEvent.click(memberRemove) + + await waitFor(() => { + expect(screen.queryByText(baseMember.name)).not.toBeInTheDocument() + }) + }) +}) + +// AddMemberOrGroupDialog renders search results and updates store selections +describe('AddMemberOrGroupDialog', () => { + it('should open search popover and display candidates', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByText('common.operation.add')) + + expect(screen.getByPlaceholderText('app.accessControlDialog.operateGroupAndMember.searchPlaceholder')).toBeInTheDocument() + expect(screen.getByText(baseGroup.name)).toBeInTheDocument() + expect(screen.getByText(baseMember.name)).toBeInTheDocument() + }) + + it('should allow selecting members and expanding groups', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByText('common.operation.add')) + + const expandButton = screen.getByText('app.accessControlDialog.operateGroupAndMember.expand') + await user.click(expandButton) + expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup]) + + const memberLabel = screen.getByText(baseMember.name) + const memberCheckbox = memberLabel.parentElement?.previousElementSibling as HTMLElement + fireEvent.click(memberCheckbox) + + expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember]) + }) + + it('should show empty state when no candidates are returned', async () => { + mockUseSearchForWhiteListCandidates.mockReturnValue({ + isLoading: false, + isFetchingNextPage: false, + fetchNextPage: jest.fn(), + data: { pages: [] }, + }) + + const user = userEvent.setup() + render() + + await user.click(screen.getByText('common.operation.add')) + + expect(screen.getByText('app.accessControlDialog.operateGroupAndMember.noResult')).toBeInTheDocument() + }) +}) + +// AccessControl integrates dialog, selection items, and confirm flow +describe('AccessControl', () => { + it('should initialize menu from app and call update on confirm', async () => { + const onClose = jest.fn() + const onConfirm = jest.fn() + const toastSpy = jest.spyOn(Toast, 'notify').mockReturnValue({}) + useAccessControlStore.setState({ + specificGroups: [baseGroup], + specificMembers: [baseMember], + }) + const app = { + id: 'app-id-1', + access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS, + } as App + + render( + , + ) + + await waitFor(() => { + expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.SPECIFIC_GROUPS_MEMBERS) + }) + + fireEvent.click(screen.getByText('common.operation.confirm')) + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith({ + appId: app.id, + accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS, + subjects: [ + { subjectId: baseGroup.id, subjectType: SubjectType.GROUP }, + { subjectId: baseMember.id, subjectType: SubjectType.ACCOUNT }, + ], + }) + expect(toastSpy).toHaveBeenCalled() + expect(onConfirm).toHaveBeenCalled() + }) + }) + + it('should expose the external members tip when SSO is disabled', () => { + const app = { + id: 'app-id-2', + access_mode: AccessMode.PUBLIC, + } as App + + render( + , + ) + + expect(screen.getByText('app.accessControlDialog.accessItems.external')).toBeInTheDocument() + expect(screen.getByText('app.accessControlDialog.accessItems.anyone')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/app-access-control/add-member-or-group-pop.tsx b/web/app/components/app/app-access-control/add-member-or-group-pop.tsx index e9519aeedf..bb8dabbae6 100644 --- a/web/app/components/app/app-access-control/add-member-or-group-pop.tsx +++ b/web/app/components/app/app-access-control/add-member-or-group-pop.tsx @@ -32,7 +32,7 @@ export default function AddMemberOrGroupDialog() { const anchorRef = useRef(null) useEffect(() => { - const hasMore = data?.pages?.[0].hasMore ?? false + const hasMore = data?.pages?.[0]?.hasMore ?? false let observer: IntersectionObserver | undefined if (anchorRef.current) { observer = new IntersectionObserver((entries) => { diff --git a/web/app/components/app/configuration/config/agent/agent-setting/index.spec.tsx b/web/app/components/app/configuration/config/agent/agent-setting/index.spec.tsx index 00c0776718..2ff1034537 100644 --- a/web/app/components/app/configuration/config/agent/agent-setting/index.spec.tsx +++ b/web/app/components/app/configuration/config/agent/agent-setting/index.spec.tsx @@ -4,12 +4,6 @@ import AgentSetting from './index' import { MAX_ITERATIONS_NUM } from '@/config' import type { AgentConfig } from '@/models/debug' -jest.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - jest.mock('ahooks', () => { const actual = jest.requireActual('ahooks') return { diff --git a/web/app/components/app/configuration/dataset-config/params-config/config-content.spec.tsx b/web/app/components/app/configuration/dataset-config/params-config/config-content.spec.tsx new file mode 100644 index 0000000000..a7673a7491 --- /dev/null +++ b/web/app/components/app/configuration/dataset-config/params-config/config-content.spec.tsx @@ -0,0 +1,392 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import ConfigContent from './config-content' +import type { DataSet } from '@/models/datasets' +import { ChunkingMode, DataSourceType, DatasetPermission, RerankingModeEnum, WeightedScoreEnum } from '@/models/datasets' +import type { DatasetConfigs } from '@/models/debug' +import { RETRIEVE_METHOD, RETRIEVE_TYPE } from '@/types/app' +import type { RetrievalConfig } from '@/types/app' +import Toast from '@/app/components/base/toast' +import type { IndexingType } from '@/app/components/datasets/create/step-two' +import { + useCurrentProviderAndModel, + useModelListAndDefaultModelAndCurrentProviderAndModel, +} from '@/app/components/header/account-setting/model-provider-page/hooks' + +jest.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => { + type Props = { + defaultModel?: { provider: string; model: string } + onSelect?: (model: { provider: string; model: string }) => void + } + + const MockModelSelector = ({ defaultModel, onSelect }: Props) => ( + + ) + + return { + __esModule: true, + default: MockModelSelector, + } +}) + +jest.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({ + __esModule: true, + default: () =>
, +})) + +jest.mock('@/app/components/base/toast', () => ({ + __esModule: true, + default: { + notify: jest.fn(), + }, +})) + +jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelListAndDefaultModelAndCurrentProviderAndModel: jest.fn(), + useCurrentProviderAndModel: jest.fn(), +})) + +const mockedUseModelListAndDefaultModelAndCurrentProviderAndModel = useModelListAndDefaultModelAndCurrentProviderAndModel as jest.MockedFunction +const mockedUseCurrentProviderAndModel = useCurrentProviderAndModel as jest.MockedFunction + +const mockToastNotify = Toast.notify as unknown as jest.Mock + +const baseRetrievalConfig: RetrievalConfig = { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { + reranking_provider_name: 'provider', + reranking_model_name: 'rerank-model', + }, + top_k: 4, + score_threshold_enabled: false, + score_threshold: 0, +} + +const defaultIndexingTechnique: IndexingType = 'high_quality' as IndexingType + +const createDataset = (overrides: Partial = {}): DataSet => { + const { + retrieval_model, + retrieval_model_dict, + icon_info, + ...restOverrides + } = overrides + + const resolvedRetrievalModelDict = { + ...baseRetrievalConfig, + ...retrieval_model_dict, + } + const resolvedRetrievalModel = { + ...baseRetrievalConfig, + ...(retrieval_model ?? retrieval_model_dict), + } + + const defaultIconInfo = { + icon: '📘', + icon_type: 'emoji', + icon_background: '#FFEAD5', + icon_url: '', + } + + const resolvedIconInfo = ('icon_info' in overrides) + ? icon_info + : defaultIconInfo + + return { + id: 'dataset-id', + name: 'Dataset Name', + indexing_status: 'completed', + icon_info: resolvedIconInfo as DataSet['icon_info'], + description: 'A test dataset', + permission: DatasetPermission.onlyMe, + data_source_type: DataSourceType.FILE, + indexing_technique: defaultIndexingTechnique, + author_name: 'author', + created_by: 'creator', + updated_by: 'updater', + updated_at: 0, + app_count: 0, + doc_form: ChunkingMode.text, + document_count: 0, + total_document_count: 0, + total_available_documents: 0, + word_count: 0, + provider: 'dify', + embedding_model: 'text-embedding', + embedding_model_provider: 'openai', + embedding_available: true, + retrieval_model_dict: resolvedRetrievalModelDict, + retrieval_model: resolvedRetrievalModel, + tags: [], + external_knowledge_info: { + external_knowledge_id: 'external-id', + external_knowledge_api_id: 'api-id', + external_knowledge_api_name: 'api-name', + external_knowledge_api_endpoint: 'https://endpoint', + }, + external_retrieval_model: { + top_k: 2, + score_threshold: 0.5, + score_threshold_enabled: true, + }, + built_in_field_enabled: true, + doc_metadata: [], + keyword_number: 3, + pipeline_id: 'pipeline-id', + is_published: true, + runtime_mode: 'general', + enable_api: true, + is_multimodal: false, + ...restOverrides, + } +} + +const createDatasetConfigs = (overrides: Partial = {}): DatasetConfigs => { + return { + retrieval_model: RETRIEVE_TYPE.multiWay, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + top_k: 4, + score_threshold_enabled: false, + score_threshold: 0, + datasets: { + datasets: [], + }, + reranking_mode: RerankingModeEnum.WeightedScore, + weights: { + weight_type: WeightedScoreEnum.Customized, + vector_setting: { + vector_weight: 0.5, + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding', + }, + keyword_setting: { + keyword_weight: 0.5, + }, + }, + reranking_enable: false, + ...overrides, + } +} + +describe('ConfigContent', () => { + beforeEach(() => { + jest.clearAllMocks() + mockedUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({ + modelList: [], + defaultModel: undefined, + currentProvider: undefined, + currentModel: undefined, + }) + mockedUseCurrentProviderAndModel.mockReturnValue({ + currentProvider: undefined, + currentModel: undefined, + }) + }) + + // State management + describe('Effects', () => { + it('should normalize oneWay retrieval mode to multiWay', async () => { + // Arrange + const onChange = jest.fn() + const datasetConfigs = createDatasetConfigs({ retrieval_model: RETRIEVE_TYPE.oneWay }) + + // Act + render() + + // Assert + await waitFor(() => { + expect(onChange).toHaveBeenCalled() + }) + const [nextConfigs] = onChange.mock.calls[0] + expect(nextConfigs.retrieval_model).toBe(RETRIEVE_TYPE.multiWay) + }) + }) + + // Rendering tests (REQUIRED) + describe('Rendering', () => { + it('should render weighted score panel when datasets are high-quality and consistent', () => { + // Arrange + const onChange = jest.fn() + const datasetConfigs = createDatasetConfigs({ + reranking_mode: RerankingModeEnum.WeightedScore, + }) + const selectedDatasets: DataSet[] = [ + createDataset({ + indexing_technique: 'high_quality' as IndexingType, + provider: 'dify', + embedding_model: 'text-embedding', + embedding_model_provider: 'openai', + retrieval_model_dict: { + ...baseRetrievalConfig, + search_method: RETRIEVE_METHOD.semantic, + }, + }), + ] + + // Act + render( + , + ) + + // Assert + expect(screen.getByText('dataset.weightedScore.title')).toBeInTheDocument() + expect(screen.getByText('common.modelProvider.rerankModel.key')).toBeInTheDocument() + expect(screen.getByText('dataset.weightedScore.semantic')).toBeInTheDocument() + expect(screen.getByText('dataset.weightedScore.keyword')).toBeInTheDocument() + }) + }) + + // User interactions + describe('User Interactions', () => { + it('should update weights when user changes weighted score slider', async () => { + // Arrange + const user = userEvent.setup() + const onChange = jest.fn() + const datasetConfigs = createDatasetConfigs({ + reranking_mode: RerankingModeEnum.WeightedScore, + weights: { + weight_type: WeightedScoreEnum.Customized, + vector_setting: { + vector_weight: 0.5, + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding', + }, + keyword_setting: { + keyword_weight: 0.5, + }, + }, + }) + const selectedDatasets: DataSet[] = [ + createDataset({ + indexing_technique: 'high_quality' as IndexingType, + provider: 'dify', + embedding_model: 'text-embedding', + embedding_model_provider: 'openai', + retrieval_model_dict: { + ...baseRetrievalConfig, + search_method: RETRIEVE_METHOD.semantic, + }, + }), + ] + + // Act + render( + , + ) + + const weightedScoreSlider = screen.getAllByRole('slider') + .find(slider => slider.getAttribute('aria-valuemax') === '1') + expect(weightedScoreSlider).toBeDefined() + await user.click(weightedScoreSlider!) + const callsBefore = onChange.mock.calls.length + await user.keyboard('{ArrowRight}') + + // Assert + expect(onChange.mock.calls.length).toBeGreaterThan(callsBefore) + const [nextConfigs] = onChange.mock.calls.at(-1) ?? [] + expect(nextConfigs?.weights?.vector_setting.vector_weight).toBeCloseTo(0.6, 5) + expect(nextConfigs?.weights?.keyword_setting.keyword_weight).toBeCloseTo(0.4, 5) + }) + + it('should warn when switching to rerank model mode without a valid model', async () => { + // Arrange + const user = userEvent.setup() + const onChange = jest.fn() + const datasetConfigs = createDatasetConfigs({ + reranking_mode: RerankingModeEnum.WeightedScore, + }) + const selectedDatasets: DataSet[] = [ + createDataset({ + indexing_technique: 'high_quality' as IndexingType, + provider: 'dify', + embedding_model: 'text-embedding', + embedding_model_provider: 'openai', + retrieval_model_dict: { + ...baseRetrievalConfig, + search_method: RETRIEVE_METHOD.semantic, + }, + }), + ] + + // Act + render( + , + ) + await user.click(screen.getByText('common.modelProvider.rerankModel.key')) + + // Assert + expect(mockToastNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'workflow.errorMsg.rerankModelRequired', + }) + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + reranking_mode: RerankingModeEnum.RerankingModel, + }), + ) + }) + + it('should warn when enabling rerank without a valid model in manual toggle mode', async () => { + // Arrange + const user = userEvent.setup() + const onChange = jest.fn() + const datasetConfigs = createDatasetConfigs({ + reranking_enable: false, + }) + const selectedDatasets: DataSet[] = [ + createDataset({ + indexing_technique: 'economy' as IndexingType, + provider: 'dify', + embedding_model: 'text-embedding', + embedding_model_provider: 'openai', + retrieval_model_dict: { + ...baseRetrievalConfig, + search_method: RETRIEVE_METHOD.semantic, + }, + }), + ] + + // Act + render( + , + ) + await user.click(screen.getByRole('switch')) + + // Assert + expect(mockToastNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'workflow.errorMsg.rerankModelRequired', + }) + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + reranking_enable: true, + }), + ) + }) + }) +}) diff --git a/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx b/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx new file mode 100644 index 0000000000..3303c484a1 --- /dev/null +++ b/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx @@ -0,0 +1,242 @@ +import * as React from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import ParamsConfig from './index' +import ConfigContext from '@/context/debug-configuration' +import type { DatasetConfigs } from '@/models/debug' +import { RerankingModeEnum } from '@/models/datasets' +import { RETRIEVE_TYPE } from '@/types/app' +import Toast from '@/app/components/base/toast' +import { + useCurrentProviderAndModel, + useModelListAndDefaultModelAndCurrentProviderAndModel, +} from '@/app/components/header/account-setting/model-provider-page/hooks' + +jest.mock('@/app/components/base/modal', () => { + type Props = { + isShow: boolean + children?: React.ReactNode + } + + const MockModal = ({ isShow, children }: Props) => { + if (!isShow) return null + return
{children}
+ } + + return { + __esModule: true, + default: MockModal, + } +}) + +jest.mock('@/app/components/base/toast', () => ({ + __esModule: true, + default: { + notify: jest.fn(), + }, +})) + +jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelListAndDefaultModelAndCurrentProviderAndModel: jest.fn(), + useCurrentProviderAndModel: jest.fn(), +})) + +jest.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => { + type Props = { + defaultModel?: { provider: string; model: string } + onSelect?: (model: { provider: string; model: string }) => void + } + + const MockModelSelector = ({ defaultModel, onSelect }: Props) => ( + + ) + + return { + __esModule: true, + default: MockModelSelector, + } +}) + +jest.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({ + __esModule: true, + default: () =>
, +})) + +const mockedUseModelListAndDefaultModelAndCurrentProviderAndModel = useModelListAndDefaultModelAndCurrentProviderAndModel as jest.MockedFunction +const mockedUseCurrentProviderAndModel = useCurrentProviderAndModel as jest.MockedFunction +const mockToastNotify = Toast.notify as unknown as jest.Mock + +const createDatasetConfigs = (overrides: Partial = {}): DatasetConfigs => { + return { + retrieval_model: RETRIEVE_TYPE.multiWay, + reranking_model: { + reranking_provider_name: 'provider', + reranking_model_name: 'rerank-model', + }, + top_k: 4, + score_threshold_enabled: false, + score_threshold: 0, + datasets: { + datasets: [], + }, + reranking_enable: false, + reranking_mode: RerankingModeEnum.RerankingModel, + ...overrides, + } +} + +const renderParamsConfig = ({ + datasetConfigs = createDatasetConfigs(), + initialModalOpen = false, + disabled, +}: { + datasetConfigs?: DatasetConfigs + initialModalOpen?: boolean + disabled?: boolean +} = {}) => { + const setDatasetConfigsSpy = jest.fn() + const setModalOpenSpy = jest.fn() + + const Wrapper = ({ children }: { children: React.ReactNode }) => { + const [datasetConfigsState, setDatasetConfigsState] = React.useState(datasetConfigs) + const [modalOpen, setModalOpen] = React.useState(initialModalOpen) + + const contextValue = { + datasetConfigs: datasetConfigsState, + setDatasetConfigs: (next: DatasetConfigs) => { + setDatasetConfigsSpy(next) + setDatasetConfigsState(next) + }, + rerankSettingModalOpen: modalOpen, + setRerankSettingModalOpen: (open: boolean) => { + setModalOpenSpy(open) + setModalOpen(open) + }, + } as unknown as React.ComponentProps['value'] + + return ( + + {children} + + ) + } + + render( + , + { wrapper: Wrapper }, + ) + + return { + setDatasetConfigsSpy, + setModalOpenSpy, + } +} + +describe('dataset-config/params-config', () => { + beforeEach(() => { + jest.clearAllMocks() + mockedUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({ + modelList: [], + defaultModel: undefined, + currentProvider: undefined, + currentModel: undefined, + }) + mockedUseCurrentProviderAndModel.mockReturnValue({ + currentProvider: undefined, + currentModel: undefined, + }) + }) + + // Rendering tests (REQUIRED) + describe('Rendering', () => { + it('should disable settings trigger when disabled is true', () => { + // Arrange + renderParamsConfig({ disabled: true }) + + // Assert + expect(screen.getByRole('button', { name: 'dataset.retrievalSettings' })).toBeDisabled() + }) + }) + + // User Interactions + describe('User Interactions', () => { + it('should open modal and persist changes when save is clicked', async () => { + // Arrange + const user = userEvent.setup() + const { setDatasetConfigsSpy } = renderParamsConfig() + + // Act + await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' })) + await screen.findByRole('dialog') + + // Change top_k via the first number input increment control. + const incrementButtons = screen.getAllByRole('button', { name: 'increment' }) + await user.click(incrementButtons[0]) + + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + // Assert + expect(setDatasetConfigsSpy).toHaveBeenCalledWith(expect.objectContaining({ top_k: 5 })) + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + }) + + it('should discard changes when cancel is clicked', async () => { + // Arrange + const user = userEvent.setup() + const { setDatasetConfigsSpy } = renderParamsConfig() + + // Act + await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' })) + await screen.findByRole('dialog') + + const incrementButtons = screen.getAllByRole('button', { name: 'increment' }) + await user.click(incrementButtons[0]) + + await user.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + // Re-open and save without changes. + await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' })) + await screen.findByRole('dialog') + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + // Assert - should save original top_k rather than the canceled change. + expect(setDatasetConfigsSpy).toHaveBeenCalledWith(expect.objectContaining({ top_k: 4 })) + }) + + it('should prevent saving when rerank model is required but invalid', async () => { + // Arrange + const user = userEvent.setup() + const { setDatasetConfigsSpy } = renderParamsConfig({ + datasetConfigs: createDatasetConfigs({ + reranking_enable: true, + reranking_mode: RerankingModeEnum.RerankingModel, + }), + initialModalOpen: true, + }) + + // Act + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + // Assert + expect(mockToastNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'appDebug.datasetConfig.rerankModelRequired', + }) + expect(setDatasetConfigsSpy).not.toHaveBeenCalled() + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/app/configuration/dataset-config/params-config/weighted-score.spec.tsx b/web/app/components/app/configuration/dataset-config/params-config/weighted-score.spec.tsx new file mode 100644 index 0000000000..e7b1eb8421 --- /dev/null +++ b/web/app/components/app/configuration/dataset-config/params-config/weighted-score.spec.tsx @@ -0,0 +1,81 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import WeightedScore from './weighted-score' + +describe('WeightedScore', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + // Rendering tests (REQUIRED) + describe('Rendering', () => { + it('should render semantic and keyword weights', () => { + // Arrange + const onChange = jest.fn() + const value = { value: [0.3, 0.7] } + + // Act + render() + + // Assert + expect(screen.getByText('dataset.weightedScore.semantic')).toBeInTheDocument() + expect(screen.getByText('dataset.weightedScore.keyword')).toBeInTheDocument() + expect(screen.getByText('0.3')).toBeInTheDocument() + expect(screen.getByText('0.7')).toBeInTheDocument() + }) + + it('should format a weight of 1 as 1.0', () => { + // Arrange + const onChange = jest.fn() + const value = { value: [1, 0] } + + // Act + render() + + // Assert + expect(screen.getByText('1.0')).toBeInTheDocument() + expect(screen.getByText('0')).toBeInTheDocument() + }) + }) + + // User Interactions + describe('User Interactions', () => { + it('should emit complementary weights when the slider value changes', async () => { + // Arrange + const onChange = jest.fn() + const value = { value: [0.5, 0.5] } + const user = userEvent.setup() + render() + + // Act + await user.tab() + const slider = screen.getByRole('slider') + expect(slider).toHaveFocus() + const callsBefore = onChange.mock.calls.length + await user.keyboard('{ArrowRight}') + + // Assert + expect(onChange.mock.calls.length).toBeGreaterThan(callsBefore) + const lastCall = onChange.mock.calls.at(-1)?.[0] + expect(lastCall?.value[0]).toBeCloseTo(0.6, 5) + expect(lastCall?.value[1]).toBeCloseTo(0.4, 5) + }) + + it('should not call onChange when readonly is true', async () => { + // Arrange + const onChange = jest.fn() + const value = { value: [0.5, 0.5] } + const user = userEvent.setup() + render() + + // Act + await user.tab() + const slider = screen.getByRole('slider') + expect(slider).toHaveFocus() + await user.keyboard('{ArrowRight}') + + // Assert + expect(onChange).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/app/create-app-dialog/index.spec.tsx b/web/app/components/app/create-app-dialog/index.spec.tsx index a64e409b25..db4384a173 100644 --- a/web/app/components/app/create-app-dialog/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/index.spec.tsx @@ -26,7 +26,7 @@ jest.mock('./app-list', () => { }) jest.mock('ahooks', () => ({ - useKeyPress: jest.fn((key: string, callback: () => void) => { + useKeyPress: jest.fn((_key: string, _callback: () => void) => { // Mock implementation for testing return jest.fn() }), @@ -67,7 +67,7 @@ describe('CreateAppTemplateDialog', () => { }) it('should not render create from blank button when onCreateFromBlank is not provided', () => { - const { onCreateFromBlank, ...propsWithoutOnCreate } = defaultProps + const { onCreateFromBlank: _onCreateFromBlank, ...propsWithoutOnCreate } = defaultProps render() @@ -259,7 +259,7 @@ describe('CreateAppTemplateDialog', () => { }) it('should handle missing optional onCreateFromBlank prop', () => { - const { onCreateFromBlank, ...propsWithoutOnCreate } = defaultProps + const { onCreateFromBlank: _onCreateFromBlank, ...propsWithoutOnCreate } = defaultProps expect(() => { render() diff --git a/web/app/components/billing/annotation-full/index.spec.tsx b/web/app/components/billing/annotation-full/index.spec.tsx index 0caa6a0b57..e95900777c 100644 --- a/web/app/components/billing/annotation-full/index.spec.tsx +++ b/web/app/components/billing/annotation-full/index.spec.tsx @@ -1,11 +1,9 @@ import { render, screen } from '@testing-library/react' import AnnotationFull from './index' -let mockUsageProps: { className?: string } | null = null jest.mock('./usage', () => ({ __esModule: true, default: (props: { className?: string }) => { - mockUsageProps = props return (
usage @@ -14,11 +12,9 @@ jest.mock('./usage', () => ({ }, })) -let mockUpgradeBtnProps: { loc?: string } | null = null jest.mock('../upgrade-btn', () => ({ __esModule: true, default: (props: { loc?: string }) => { - mockUpgradeBtnProps = props return ( + ), +})) + +describe('ChatVariableTrigger', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + // Verifies conditional rendering when chat mode is off. + describe('Rendering', () => { + it('should not render when not in chat mode', () => { + // Arrange + mockUseIsChatMode.mockReturnValue(false) + mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false }) + + // Act + render() + + // Assert + expect(screen.queryByTestId('chat-variable-button')).not.toBeInTheDocument() + }) + }) + + // Verifies the disabled state reflects read-only nodes. + describe('Props', () => { + it('should render enabled ChatVariableButton when nodes are editable', () => { + // Arrange + mockUseIsChatMode.mockReturnValue(true) + mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false }) + + // Act + render() + + // Assert + expect(screen.getByTestId('chat-variable-button')).toBeEnabled() + }) + + it('should render disabled ChatVariableButton when nodes are read-only', () => { + // Arrange + mockUseIsChatMode.mockReturnValue(true) + mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: true }) + + // Act + render() + + // Assert + expect(screen.getByTestId('chat-variable-button')).toBeDisabled() + }) + }) +}) diff --git a/web/app/components/workflow-app/components/workflow-header/features-trigger.spec.tsx b/web/app/components/workflow-app/components/workflow-header/features-trigger.spec.tsx new file mode 100644 index 0000000000..a3fc2c12a9 --- /dev/null +++ b/web/app/components/workflow-app/components/workflow-header/features-trigger.spec.tsx @@ -0,0 +1,458 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Plan } from '@/app/components/billing/type' +import { BlockEnum, InputVarType } from '@/app/components/workflow/types' +import FeaturesTrigger from './features-trigger' + +const mockUseIsChatMode = jest.fn() +const mockUseTheme = jest.fn() +const mockUseNodesReadOnly = jest.fn() +const mockUseChecklist = jest.fn() +const mockUseChecklistBeforePublish = jest.fn() +const mockUseNodesSyncDraft = jest.fn() +const mockUseToastContext = jest.fn() +const mockUseFeatures = jest.fn() +const mockUseProviderContext = jest.fn() +const mockUseNodes = jest.fn() +const mockUseEdges = jest.fn() +const mockUseAppStoreSelector = jest.fn() + +const mockNotify = jest.fn() +const mockHandleCheckBeforePublish = jest.fn() +const mockHandleSyncWorkflowDraft = jest.fn() +const mockPublishWorkflow = jest.fn() +const mockUpdatePublishedWorkflow = jest.fn() +const mockResetWorkflowVersionHistory = jest.fn() +const mockInvalidateAppTriggers = jest.fn() +const mockFetchAppDetail = jest.fn() +const mockSetAppDetail = jest.fn() +const mockSetPublishedAt = jest.fn() +const mockSetLastPublishedHasUserInput = jest.fn() + +const mockWorkflowStoreSetState = jest.fn() +const mockWorkflowStoreSetShowFeaturesPanel = jest.fn() + +let workflowStoreState = { + showFeaturesPanel: false, + isRestoring: false, + setShowFeaturesPanel: mockWorkflowStoreSetShowFeaturesPanel, + setPublishedAt: mockSetPublishedAt, + setLastPublishedHasUserInput: mockSetLastPublishedHasUserInput, +} + +const mockWorkflowStore = { + getState: () => workflowStoreState, + setState: mockWorkflowStoreSetState, +} + +let capturedAppPublisherProps: Record | null = null + +jest.mock('@/app/components/workflow/hooks', () => ({ + __esModule: true, + useChecklist: (...args: unknown[]) => mockUseChecklist(...args), + useChecklistBeforePublish: () => mockUseChecklistBeforePublish(), + useNodesReadOnly: () => mockUseNodesReadOnly(), + useNodesSyncDraft: () => mockUseNodesSyncDraft(), + useIsChatMode: () => mockUseIsChatMode(), +})) + +jest.mock('@/app/components/workflow/store', () => ({ + __esModule: true, + useStore: (selector: (state: Record) => unknown) => { + const state: Record = { + publishedAt: null, + draftUpdatedAt: null, + toolPublished: false, + lastPublishedHasUserInput: false, + } + return selector(state) + }, + useWorkflowStore: () => mockWorkflowStore, +})) + +jest.mock('@/app/components/base/features/hooks', () => ({ + __esModule: true, + useFeatures: (selector: (state: Record) => unknown) => mockUseFeatures(selector), +})) + +jest.mock('@/app/components/base/toast', () => ({ + __esModule: true, + useToastContext: () => mockUseToastContext(), +})) + +jest.mock('@/context/provider-context', () => ({ + __esModule: true, + useProviderContext: () => mockUseProviderContext(), +})) + +jest.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({ + __esModule: true, + default: () => mockUseNodes(), +})) + +jest.mock('reactflow', () => ({ + __esModule: true, + useEdges: () => mockUseEdges(), +})) + +jest.mock('@/app/components/app/app-publisher', () => ({ + __esModule: true, + default: (props: Record) => { + capturedAppPublisherProps = props + return ( +
+ ) + }, +})) + +jest.mock('@/service/use-workflow', () => ({ + __esModule: true, + useInvalidateAppWorkflow: () => mockUpdatePublishedWorkflow, + usePublishWorkflow: () => ({ mutateAsync: mockPublishWorkflow }), + useResetWorkflowVersionHistory: () => mockResetWorkflowVersionHistory, +})) + +jest.mock('@/service/use-tools', () => ({ + __esModule: true, + useInvalidateAppTriggers: () => mockInvalidateAppTriggers, +})) + +jest.mock('@/service/apps', () => ({ + __esModule: true, + fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args), +})) + +jest.mock('@/hooks/use-theme', () => ({ + __esModule: true, + default: () => mockUseTheme(), +})) + +jest.mock('@/app/components/app/store', () => ({ + __esModule: true, + useStore: (selector: (state: { appDetail?: { id: string }; setAppDetail: typeof mockSetAppDetail }) => unknown) => mockUseAppStoreSelector(selector), +})) + +const createProviderContext = ({ + type = Plan.sandbox, + isFetchedPlan = true, +}: { + type?: Plan + isFetchedPlan?: boolean +}) => ({ + plan: { type }, + isFetchedPlan, +}) + +describe('FeaturesTrigger', () => { + beforeEach(() => { + jest.clearAllMocks() + capturedAppPublisherProps = null + workflowStoreState = { + showFeaturesPanel: false, + isRestoring: false, + setShowFeaturesPanel: mockWorkflowStoreSetShowFeaturesPanel, + setPublishedAt: mockSetPublishedAt, + setLastPublishedHasUserInput: mockSetLastPublishedHasUserInput, + } + + mockUseTheme.mockReturnValue({ theme: 'light' }) + mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false, getNodesReadOnly: () => false }) + mockUseChecklist.mockReturnValue([]) + mockUseChecklistBeforePublish.mockReturnValue({ handleCheckBeforePublish: mockHandleCheckBeforePublish }) + mockHandleCheckBeforePublish.mockResolvedValue(true) + mockUseNodesSyncDraft.mockReturnValue({ handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft }) + mockUseToastContext.mockReturnValue({ notify: mockNotify }) + mockUseFeatures.mockImplementation((selector: (state: Record) => unknown) => selector({ features: { file: {} } })) + mockUseProviderContext.mockReturnValue(createProviderContext({})) + mockUseNodes.mockReturnValue([]) + mockUseEdges.mockReturnValue([]) + mockUseAppStoreSelector.mockImplementation(selector => selector({ appDetail: { id: 'app-id' }, setAppDetail: mockSetAppDetail })) + mockFetchAppDetail.mockResolvedValue({ id: 'app-id' }) + mockPublishWorkflow.mockResolvedValue({ created_at: '2024-01-01T00:00:00Z' }) + }) + + // Verifies the feature toggle button only appears in chatflow mode. + describe('Rendering', () => { + it('should not render the features button when not in chat mode', () => { + // Arrange + mockUseIsChatMode.mockReturnValue(false) + + // Act + render() + + // Assert + expect(screen.queryByRole('button', { name: /workflow\.common\.features/i })).not.toBeInTheDocument() + }) + + it('should render the features button when in chat mode', () => { + // Arrange + mockUseIsChatMode.mockReturnValue(true) + + // Act + render() + + // Assert + expect(screen.getByRole('button', { name: /workflow\.common\.features/i })).toBeInTheDocument() + }) + + it('should apply dark theme styling when theme is dark', () => { + // Arrange + mockUseIsChatMode.mockReturnValue(true) + mockUseTheme.mockReturnValue({ theme: 'dark' }) + + // Act + render() + + // Assert + expect(screen.getByRole('button', { name: /workflow\.common\.features/i })).toHaveClass('rounded-lg') + }) + }) + + // Verifies user clicks toggle the features panel visibility. + describe('User Interactions', () => { + it('should toggle features panel when clicked and nodes are editable', async () => { + // Arrange + const user = userEvent.setup() + mockUseIsChatMode.mockReturnValue(true) + mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false, getNodesReadOnly: () => false }) + + render() + + // Act + await user.click(screen.getByRole('button', { name: /workflow\.common\.features/i })) + + // Assert + expect(mockWorkflowStoreSetShowFeaturesPanel).toHaveBeenCalledWith(true) + }) + }) + + // Covers read-only gating that prevents toggling unless restoring. + describe('Edge Cases', () => { + it('should not toggle features panel when nodes are read-only and not restoring', async () => { + // Arrange + const user = userEvent.setup() + mockUseIsChatMode.mockReturnValue(true) + mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: true, getNodesReadOnly: () => true }) + workflowStoreState = { + ...workflowStoreState, + isRestoring: false, + } + + render() + + // Act + await user.click(screen.getByRole('button', { name: /workflow\.common\.features/i })) + + // Assert + expect(mockWorkflowStoreSetShowFeaturesPanel).not.toHaveBeenCalled() + }) + }) + + // Verifies the publisher reflects the presence of workflow nodes. + describe('Props', () => { + it('should disable AppPublisher when there are no workflow nodes', () => { + // Arrange + mockUseIsChatMode.mockReturnValue(false) + mockUseNodes.mockReturnValue([]) + + // Act + render() + + // Assert + expect(capturedAppPublisherProps?.disabled).toBe(true) + expect(screen.getByTestId('app-publisher')).toHaveAttribute('data-disabled', 'true') + }) + }) + + // Verifies derived props passed into AppPublisher (variables, limits, and triggers). + describe('Computed Props', () => { + it('should append image input when file image upload is enabled', () => { + // Arrange + mockUseFeatures.mockImplementation((selector: (state: Record) => unknown) => selector({ + features: { file: { image: { enabled: true } } }, + })) + mockUseNodes.mockReturnValue([ + { id: 'start', data: { type: BlockEnum.Start } }, + ]) + + // Act + render() + + // Assert + const inputs = (capturedAppPublisherProps?.inputs as unknown as Array<{ type?: string; variable?: string }>) || [] + expect(inputs).toContainEqual({ + type: InputVarType.files, + variable: '__image', + required: false, + label: 'files', + }) + }) + + it('should set startNodeLimitExceeded when sandbox entry limit is exceeded', () => { + // Arrange + mockUseNodes.mockReturnValue([ + { id: 'start', data: { type: BlockEnum.Start } }, + { id: 'trigger-1', data: { type: BlockEnum.TriggerWebhook } }, + { id: 'trigger-2', data: { type: BlockEnum.TriggerSchedule } }, + { id: 'end', data: { type: BlockEnum.End } }, + ]) + + // Act + render() + + // Assert + expect(capturedAppPublisherProps?.startNodeLimitExceeded).toBe(true) + expect(capturedAppPublisherProps?.publishDisabled).toBe(true) + expect(capturedAppPublisherProps?.hasTriggerNode).toBe(true) + }) + }) + + // Verifies callbacks wired from AppPublisher to stores and draft syncing. + describe('Callbacks', () => { + it('should set toolPublished when AppPublisher refreshes data', () => { + // Arrange + render() + const refresh = capturedAppPublisherProps?.onRefreshData as unknown as (() => void) | undefined + expect(refresh).toBeDefined() + + // Act + refresh?.() + + // Assert + expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ toolPublished: true }) + }) + + it('should sync workflow draft when AppPublisher toggles on', () => { + // Arrange + render() + const onToggle = capturedAppPublisherProps?.onToggle as unknown as ((state: boolean) => void) | undefined + expect(onToggle).toBeDefined() + + // Act + onToggle?.(true) + + // Assert + expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true) + }) + + it('should not sync workflow draft when AppPublisher toggles off', () => { + // Arrange + render() + const onToggle = capturedAppPublisherProps?.onToggle as unknown as ((state: boolean) => void) | undefined + expect(onToggle).toBeDefined() + + // Act + onToggle?.(false) + + // Assert + expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled() + }) + }) + + // Verifies publishing behavior across warnings, validation, and success. + describe('Publishing', () => { + it('should notify error and reject publish when checklist has warning nodes', async () => { + // Arrange + mockUseChecklist.mockReturnValue([{ id: 'warning' }]) + render() + + const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise) | undefined + expect(onPublish).toBeDefined() + + // Act + await expect(onPublish?.()).rejects.toThrow('Checklist has unresolved items') + + // Assert + expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'workflow.panel.checklistTip' }) + }) + + it('should reject publish when checklist before publish fails', async () => { + // Arrange + mockHandleCheckBeforePublish.mockResolvedValue(false) + render() + + const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise) | undefined + expect(onPublish).toBeDefined() + + // Act & Assert + await expect(onPublish?.()).rejects.toThrow('Checklist failed') + }) + + it('should publish workflow and update related stores when validation passes', async () => { + // Arrange + mockUseNodes.mockReturnValue([ + { id: 'start', data: { type: BlockEnum.Start } }, + ]) + mockUseEdges.mockReturnValue([ + { source: 'start' }, + ]) + render() + + const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise) | undefined + expect(onPublish).toBeDefined() + + // Act + await onPublish?.() + + // Assert + expect(mockPublishWorkflow).toHaveBeenCalledWith({ + url: '/apps/app-id/workflows/publish', + title: '', + releaseNotes: '', + }) + expect(mockUpdatePublishedWorkflow).toHaveBeenCalledWith('app-id') + expect(mockInvalidateAppTriggers).toHaveBeenCalledWith('app-id') + expect(mockSetPublishedAt).toHaveBeenCalledWith('2024-01-01T00:00:00Z') + expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(true) + expect(mockResetWorkflowVersionHistory).toHaveBeenCalled() + expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.api.actionSuccess' }) + + await waitFor(() => { + expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-id' }) + expect(mockSetAppDetail).toHaveBeenCalled() + }) + }) + + it('should pass publish params to workflow publish mutation', async () => { + // Arrange + render() + + const onPublish = capturedAppPublisherProps?.onPublish as unknown as ((params: { title: string; releaseNotes: string }) => Promise) | undefined + expect(onPublish).toBeDefined() + + // Act + await onPublish?.({ title: 'Test title', releaseNotes: 'Test notes' }) + + // Assert + expect(mockPublishWorkflow).toHaveBeenCalledWith({ + url: '/apps/app-id/workflows/publish', + title: 'Test title', + releaseNotes: 'Test notes', + }) + }) + + it('should log error when app detail refresh fails after publish', async () => { + // Arrange + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined) + mockFetchAppDetail.mockRejectedValue(new Error('fetch failed')) + + render() + + const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise) | undefined + expect(onPublish).toBeDefined() + + // Act + await onPublish?.() + + // Assert + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalled() + }) + consoleErrorSpy.mockRestore() + }) + }) +}) diff --git a/web/app/components/workflow-app/components/workflow-header/index.spec.tsx b/web/app/components/workflow-app/components/workflow-header/index.spec.tsx new file mode 100644 index 0000000000..4dd90610bf --- /dev/null +++ b/web/app/components/workflow-app/components/workflow-header/index.spec.tsx @@ -0,0 +1,149 @@ +import { render } from '@testing-library/react' +import type { App } from '@/types/app' +import { AppModeEnum } from '@/types/app' +import type { HeaderProps } from '@/app/components/workflow/header' +import WorkflowHeader from './index' +import { fetchWorkflowRunHistory } from '@/service/workflow' + +const mockUseAppStoreSelector = jest.fn() +const mockSetCurrentLogItem = jest.fn() +const mockSetShowMessageLogModal = jest.fn() +const mockResetWorkflowVersionHistory = jest.fn() + +let capturedHeaderProps: HeaderProps | null = null +let appDetail: App + +jest.mock('ky', () => ({ + __esModule: true, + default: { + create: () => ({ + extend: () => async () => ({ + status: 200, + headers: new Headers(), + json: async () => ({}), + blob: async () => new Blob(), + clone: () => ({ + status: 200, + json: async () => ({}), + }), + }), + }), + }, +})) + +jest.mock('@/app/components/app/store', () => ({ + __esModule: true, + useStore: (selector: (state: { appDetail?: App; setCurrentLogItem: typeof mockSetCurrentLogItem; setShowMessageLogModal: typeof mockSetShowMessageLogModal }) => unknown) => mockUseAppStoreSelector(selector), +})) + +jest.mock('@/app/components/workflow/header', () => ({ + __esModule: true, + default: (props: HeaderProps) => { + capturedHeaderProps = props + return
+ }, +})) + +jest.mock('@/service/workflow', () => ({ + __esModule: true, + fetchWorkflowRunHistory: jest.fn(), +})) + +jest.mock('@/service/use-workflow', () => ({ + __esModule: true, + useResetWorkflowVersionHistory: () => mockResetWorkflowVersionHistory, +})) + +describe('WorkflowHeader', () => { + beforeEach(() => { + jest.clearAllMocks() + capturedHeaderProps = null + appDetail = { id: 'app-id', mode: AppModeEnum.COMPLETION } as unknown as App + + mockUseAppStoreSelector.mockImplementation(selector => selector({ + appDetail, + setCurrentLogItem: mockSetCurrentLogItem, + setShowMessageLogModal: mockSetShowMessageLogModal, + })) + }) + + // Verifies the wrapper renders the workflow header shell. + describe('Rendering', () => { + it('should render without crashing', () => { + // Act + render() + + // Assert + expect(capturedHeaderProps).not.toBeNull() + }) + }) + + // Verifies chat mode affects which primary action is shown in the header. + describe('Props', () => { + it('should configure preview mode when app is in advanced chat mode', () => { + // Arrange + appDetail = { id: 'app-id', mode: AppModeEnum.ADVANCED_CHAT } as unknown as App + mockUseAppStoreSelector.mockImplementation(selector => selector({ + appDetail, + setCurrentLogItem: mockSetCurrentLogItem, + setShowMessageLogModal: mockSetShowMessageLogModal, + })) + + // Act + render() + + // Assert + expect(capturedHeaderProps?.normal?.runAndHistoryProps?.showRunButton).toBe(false) + expect(capturedHeaderProps?.normal?.runAndHistoryProps?.showPreviewButton).toBe(true) + expect(capturedHeaderProps?.normal?.runAndHistoryProps?.viewHistoryProps?.historyUrl).toBe('/apps/app-id/advanced-chat/workflow-runs') + expect(capturedHeaderProps?.normal?.runAndHistoryProps?.viewHistoryProps?.historyFetcher).toBe(fetchWorkflowRunHistory) + }) + + it('should configure run mode when app is not in advanced chat mode', () => { + // Arrange + appDetail = { id: 'app-id', mode: AppModeEnum.COMPLETION } as unknown as App + mockUseAppStoreSelector.mockImplementation(selector => selector({ + appDetail, + setCurrentLogItem: mockSetCurrentLogItem, + setShowMessageLogModal: mockSetShowMessageLogModal, + })) + + // Act + render() + + // Assert + expect(capturedHeaderProps?.normal?.runAndHistoryProps?.showRunButton).toBe(true) + expect(capturedHeaderProps?.normal?.runAndHistoryProps?.showPreviewButton).toBe(false) + expect(capturedHeaderProps?.normal?.runAndHistoryProps?.viewHistoryProps?.historyUrl).toBe('/apps/app-id/workflow-runs') + }) + }) + + // Verifies callbacks clear log state as expected. + describe('User Interactions', () => { + it('should clear log and close message modal when clearing history modal state', () => { + // Arrange + render() + + const clear = capturedHeaderProps?.normal?.runAndHistoryProps?.viewHistoryProps?.onClearLogAndMessageModal + expect(clear).toBeDefined() + + // Act + clear?.() + + // Assert + expect(mockSetCurrentLogItem).toHaveBeenCalledWith() + expect(mockSetShowMessageLogModal).toHaveBeenCalledWith(false) + }) + }) + + // Ensures restoring callback is wired to reset version history. + describe('Edge Cases', () => { + it('should use resetWorkflowVersionHistory as restore settled handler', () => { + // Act + render() + + // Assert + expect(capturedHeaderProps?.restoring?.onRestoreSettled).toBe(mockResetWorkflowVersionHistory) + }) + }) +}) From b3e5d45755b5e2d43400f726ed3438f057b6430c Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Thu, 18 Dec 2025 10:00:31 +0800 Subject: [PATCH 06/61] chore: compatiable opendal modify (#29794) --- api/extensions/storage/opendal_storage.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/api/extensions/storage/opendal_storage.py b/api/extensions/storage/opendal_storage.py index a084844d72..83c5c2d12f 100644 --- a/api/extensions/storage/opendal_storage.py +++ b/api/extensions/storage/opendal_storage.py @@ -87,15 +87,16 @@ class OpenDALStorage(BaseStorage): if not self.exists(path): raise FileNotFoundError("Path not found") - all_files = self.op.scan(path=path) + # Use the new OpenDAL 0.46.0+ API with recursive listing + lister = self.op.list(path, recursive=True) if files and directories: logger.debug("files and directories on %s scanned", path) - return [f.path for f in all_files] + return [entry.path for entry in lister] if files: logger.debug("files on %s scanned", path) - return [f.path for f in all_files if not f.path.endswith("/")] + return [entry.path for entry in lister if not entry.metadata.is_dir] elif directories: logger.debug("directories on %s scanned", path) - return [f.path for f in all_files if f.path.endswith("/")] + return [entry.path for entry in lister if entry.metadata.is_dir] else: raise ValueError("At least one of files or directories must be True") From 69eab28da1c19a4cf26247143a8ac811f6d31a70 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Thu, 18 Dec 2025 10:05:53 +0800 Subject: [PATCH 07/61] =?UTF-8?q?test:=20add=20comprehensive=20unit=20test?= =?UTF-8?q?s=20for=20JinaReader=20and=20WaterCrawl=20comp=E2=80=A6=20(#297?= =?UTF-8?q?68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: CodingOnStar --- .../create/file-preview/index.spec.tsx | 873 ++++++++ .../create/notion-page-preview/index.spec.tsx | 1150 +++++++++++ .../datasets/create/step-three/index.spec.tsx | 844 ++++++++ .../datasets/create/stepper/index.spec.tsx | 735 +++++++ .../stop-embedding-modal/index.spec.tsx | 738 +++++++ .../datasets/create/top-bar/index.spec.tsx | 539 +++++ .../datasets/create/website/base.spec.tsx | 555 +++++ .../website/base/checkbox-with-label.tsx | 4 +- .../website/base/crawled-result-item.tsx | 4 +- .../create/website/base/crawled-result.tsx | 5 +- .../create/website/jina-reader/base.spec.tsx | 396 ++++ .../website/jina-reader/base/url-input.tsx | 1 + .../create/website/jina-reader/index.spec.tsx | 1631 +++++++++++++++ .../create/website/jina-reader/options.tsx | 2 + .../create/website/watercrawl/index.spec.tsx | 1812 +++++++++++++++++ .../create/website/watercrawl/options.tsx | 2 + 16 files changed, 9288 insertions(+), 3 deletions(-) create mode 100644 web/app/components/datasets/create/file-preview/index.spec.tsx create mode 100644 web/app/components/datasets/create/notion-page-preview/index.spec.tsx create mode 100644 web/app/components/datasets/create/step-three/index.spec.tsx create mode 100644 web/app/components/datasets/create/stepper/index.spec.tsx create mode 100644 web/app/components/datasets/create/stop-embedding-modal/index.spec.tsx create mode 100644 web/app/components/datasets/create/top-bar/index.spec.tsx create mode 100644 web/app/components/datasets/create/website/base.spec.tsx create mode 100644 web/app/components/datasets/create/website/jina-reader/base.spec.tsx create mode 100644 web/app/components/datasets/create/website/jina-reader/index.spec.tsx create mode 100644 web/app/components/datasets/create/website/watercrawl/index.spec.tsx diff --git a/web/app/components/datasets/create/file-preview/index.spec.tsx b/web/app/components/datasets/create/file-preview/index.spec.tsx new file mode 100644 index 0000000000..b7d7b489b4 --- /dev/null +++ b/web/app/components/datasets/create/file-preview/index.spec.tsx @@ -0,0 +1,873 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import FilePreview from './index' +import type { CustomFile as File } from '@/models/datasets' +import { fetchFilePreview } from '@/service/common' + +// Mock the fetchFilePreview service +jest.mock('@/service/common', () => ({ + fetchFilePreview: jest.fn(), +})) + +const mockFetchFilePreview = fetchFilePreview as jest.MockedFunction + +// Factory function to create mock file objects +const createMockFile = (overrides: Partial = {}): File => { + const file = new window.File(['test content'], 'test-file.txt', { + type: 'text/plain', + }) as File + return Object.assign(file, { + id: 'file-123', + extension: 'txt', + mime_type: 'text/plain', + created_by: 'user-1', + created_at: Date.now(), + ...overrides, + }) +} + +// Helper to render FilePreview with default props +const renderFilePreview = (props: Partial<{ file?: File; hidePreview: () => void }> = {}) => { + const defaultProps = { + file: createMockFile(), + hidePreview: jest.fn(), + ...props, + } + return { + ...render(), + props: defaultProps, + } +} + +// Helper to find the loading spinner element +const findLoadingSpinner = (container: HTMLElement) => { + return container.querySelector('.spin-animation') +} + +// ============================================================================ +// FilePreview Component Tests +// ============================================================================ +describe('FilePreview', () => { + beforeEach(() => { + jest.clearAllMocks() + // Default successful API response + mockFetchFilePreview.mockResolvedValue({ content: 'Preview content here' }) + }) + + // -------------------------------------------------------------------------- + // Rendering Tests - Verify component renders properly + // -------------------------------------------------------------------------- + describe('Rendering', () => { + it('should render without crashing', async () => { + // Arrange & Act + renderFilePreview() + + // Assert + await waitFor(() => { + expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument() + }) + }) + + it('should render file preview header', async () => { + // Arrange & Act + renderFilePreview() + + // Assert + expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument() + }) + + it('should render close button with XMarkIcon', async () => { + // Arrange & Act + const { container } = renderFilePreview() + + // Assert + const closeButton = container.querySelector('.cursor-pointer') + expect(closeButton).toBeInTheDocument() + const xMarkIcon = closeButton?.querySelector('svg') + expect(xMarkIcon).toBeInTheDocument() + }) + + it('should render file name without extension', async () => { + // Arrange + const file = createMockFile({ name: 'document.pdf' }) + + // Act + renderFilePreview({ file }) + + // Assert + await waitFor(() => { + expect(screen.getByText('document')).toBeInTheDocument() + }) + }) + + it('should render file extension', async () => { + // Arrange + const file = createMockFile({ extension: 'pdf' }) + + // Act + renderFilePreview({ file }) + + // Assert + expect(screen.getByText('.pdf')).toBeInTheDocument() + }) + + it('should apply correct CSS classes to container', async () => { + // Arrange & Act + const { container } = renderFilePreview() + + // Assert + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('h-full') + }) + }) + + // -------------------------------------------------------------------------- + // Loading State Tests + // -------------------------------------------------------------------------- + describe('Loading State', () => { + it('should show loading indicator initially', async () => { + // Arrange - Delay API response to keep loading state + mockFetchFilePreview.mockImplementation( + () => new Promise(resolve => setTimeout(() => resolve({ content: 'test' }), 100)), + ) + + // Act + const { container } = renderFilePreview() + + // Assert - Loading should be visible initially (using spin-animation class) + const loadingElement = findLoadingSpinner(container) + expect(loadingElement).toBeInTheDocument() + }) + + it('should hide loading indicator after content loads', async () => { + // Arrange + mockFetchFilePreview.mockResolvedValue({ content: 'Loaded content' }) + + // Act + const { container } = renderFilePreview() + + // Assert + await waitFor(() => { + expect(screen.getByText('Loaded content')).toBeInTheDocument() + }) + // Loading should be gone + const loadingElement = findLoadingSpinner(container) + expect(loadingElement).not.toBeInTheDocument() + }) + + it('should show loading when file changes', async () => { + // Arrange + const file1 = createMockFile({ id: 'file-1', name: 'file1.txt' }) + const file2 = createMockFile({ id: 'file-2', name: 'file2.txt' }) + + let resolveFirst: (value: { content: string }) => void + let resolveSecond: (value: { content: string }) => void + + mockFetchFilePreview + .mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve })) + .mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve })) + + // Act - Initial render + const { rerender, container } = render( + , + ) + + // First file loading - spinner should be visible + expect(findLoadingSpinner(container)).toBeInTheDocument() + + // Resolve first file + await act(async () => { + resolveFirst({ content: 'Content 1' }) + }) + + await waitFor(() => { + expect(screen.getByText('Content 1')).toBeInTheDocument() + }) + + // Rerender with new file + rerender() + + // Should show loading again + await waitFor(() => { + expect(findLoadingSpinner(container)).toBeInTheDocument() + }) + + // Resolve second file + await act(async () => { + resolveSecond({ content: 'Content 2' }) + }) + + await waitFor(() => { + expect(screen.getByText('Content 2')).toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // API Call Tests + // -------------------------------------------------------------------------- + describe('API Calls', () => { + it('should call fetchFilePreview with correct fileID', async () => { + // Arrange + const file = createMockFile({ id: 'test-file-id' }) + + // Act + renderFilePreview({ file }) + + // Assert + await waitFor(() => { + expect(mockFetchFilePreview).toHaveBeenCalledWith({ fileID: 'test-file-id' }) + }) + }) + + it('should not call fetchFilePreview when file is undefined', async () => { + // Arrange & Act + renderFilePreview({ file: undefined }) + + // Assert + expect(mockFetchFilePreview).not.toHaveBeenCalled() + }) + + it('should not call fetchFilePreview when file has no id', async () => { + // Arrange + const file = createMockFile({ id: undefined }) + + // Act + renderFilePreview({ file }) + + // Assert + expect(mockFetchFilePreview).not.toHaveBeenCalled() + }) + + it('should call fetchFilePreview again when file changes', async () => { + // Arrange + const file1 = createMockFile({ id: 'file-1' }) + const file2 = createMockFile({ id: 'file-2' }) + + // Act + const { rerender } = render( + , + ) + + await waitFor(() => { + expect(mockFetchFilePreview).toHaveBeenCalledWith({ fileID: 'file-1' }) + }) + + rerender() + + // Assert + await waitFor(() => { + expect(mockFetchFilePreview).toHaveBeenCalledWith({ fileID: 'file-2' }) + expect(mockFetchFilePreview).toHaveBeenCalledTimes(2) + }) + }) + + it('should handle API success and display content', async () => { + // Arrange + mockFetchFilePreview.mockResolvedValue({ content: 'File preview content from API' }) + + // Act + renderFilePreview() + + // Assert + await waitFor(() => { + expect(screen.getByText('File preview content from API')).toBeInTheDocument() + }) + }) + + it('should handle API error gracefully', async () => { + // Arrange + mockFetchFilePreview.mockRejectedValue(new Error('Network error')) + + // Act + const { container } = renderFilePreview() + + // Assert - Component should not crash, loading may persist + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + // No error thrown, component still rendered + expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument() + }) + + it('should handle empty content response', async () => { + // Arrange + mockFetchFilePreview.mockResolvedValue({ content: '' }) + + // Act + const { container } = renderFilePreview() + + // Assert - Should still render without loading + await waitFor(() => { + const loadingElement = findLoadingSpinner(container) + expect(loadingElement).not.toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // User Interactions Tests + // -------------------------------------------------------------------------- + describe('User Interactions', () => { + it('should call hidePreview when close button is clicked', async () => { + // Arrange + const hidePreview = jest.fn() + const { container } = renderFilePreview({ hidePreview }) + + // Act + const closeButton = container.querySelector('.cursor-pointer') as HTMLElement + fireEvent.click(closeButton) + + // Assert + expect(hidePreview).toHaveBeenCalledTimes(1) + }) + + it('should call hidePreview with event object when clicked', async () => { + // Arrange + const hidePreview = jest.fn() + const { container } = renderFilePreview({ hidePreview }) + + // Act + const closeButton = container.querySelector('.cursor-pointer') as HTMLElement + fireEvent.click(closeButton) + + // Assert - onClick receives the event object + expect(hidePreview).toHaveBeenCalled() + expect(hidePreview.mock.calls[0][0]).toBeDefined() + }) + + it('should handle multiple clicks on close button', async () => { + // Arrange + const hidePreview = jest.fn() + const { container } = renderFilePreview({ hidePreview }) + + // Act + const closeButton = container.querySelector('.cursor-pointer') as HTMLElement + fireEvent.click(closeButton) + fireEvent.click(closeButton) + fireEvent.click(closeButton) + + // Assert + expect(hidePreview).toHaveBeenCalledTimes(3) + }) + }) + + // -------------------------------------------------------------------------- + // State Management Tests + // -------------------------------------------------------------------------- + describe('State Management', () => { + it('should initialize with loading state true', async () => { + // Arrange - Keep loading indefinitely (never resolves) + mockFetchFilePreview.mockImplementation(() => new Promise(() => { /* intentionally empty */ })) + + // Act + const { container } = renderFilePreview() + + // Assert + const loadingElement = findLoadingSpinner(container) + expect(loadingElement).toBeInTheDocument() + }) + + it('should update previewContent state after successful fetch', async () => { + // Arrange + mockFetchFilePreview.mockResolvedValue({ content: 'New preview content' }) + + // Act + renderFilePreview() + + // Assert + await waitFor(() => { + expect(screen.getByText('New preview content')).toBeInTheDocument() + }) + }) + + it('should reset loading to true when file changes', async () => { + // Arrange + const file1 = createMockFile({ id: 'file-1' }) + const file2 = createMockFile({ id: 'file-2' }) + + mockFetchFilePreview + .mockResolvedValueOnce({ content: 'Content 1' }) + .mockImplementationOnce(() => new Promise(() => { /* never resolves */ })) + + // Act + const { rerender, container } = render( + , + ) + + await waitFor(() => { + expect(screen.getByText('Content 1')).toBeInTheDocument() + }) + + // Change file + rerender() + + // Assert - Loading should be shown again + await waitFor(() => { + const loadingElement = findLoadingSpinner(container) + expect(loadingElement).toBeInTheDocument() + }) + }) + + it('should preserve content until new content loads', async () => { + // Arrange + const file1 = createMockFile({ id: 'file-1' }) + const file2 = createMockFile({ id: 'file-2' }) + + let resolveSecond: (value: { content: string }) => void + + mockFetchFilePreview + .mockResolvedValueOnce({ content: 'Content 1' }) + .mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve })) + + // Act + const { rerender } = render( + , + ) + + await waitFor(() => { + expect(screen.getByText('Content 1')).toBeInTheDocument() + }) + + // Change file - loading should replace content + rerender() + + // Resolve second fetch + await act(async () => { + resolveSecond({ content: 'Content 2' }) + }) + + await waitFor(() => { + expect(screen.getByText('Content 2')).toBeInTheDocument() + expect(screen.queryByText('Content 1')).not.toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // Props Testing + // -------------------------------------------------------------------------- + describe('Props', () => { + describe('file prop', () => { + it('should render correctly with file prop', async () => { + // Arrange + const file = createMockFile({ name: 'my-document.pdf', extension: 'pdf' }) + + // Act + renderFilePreview({ file }) + + // Assert + expect(screen.getByText('my-document')).toBeInTheDocument() + expect(screen.getByText('.pdf')).toBeInTheDocument() + }) + + it('should render correctly without file prop', async () => { + // Arrange & Act + renderFilePreview({ file: undefined }) + + // Assert - Header should still render + expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument() + }) + + it('should handle file with multiple dots in name', async () => { + // Arrange + const file = createMockFile({ name: 'my.document.v2.pdf' }) + + // Act + renderFilePreview({ file }) + + // Assert - Should join all parts except last with comma + expect(screen.getByText('my,document,v2')).toBeInTheDocument() + }) + + it('should handle file with no extension in name', async () => { + // Arrange + const file = createMockFile({ name: 'README' }) + + // Act + const { container } = renderFilePreview({ file }) + + // Assert - getFileName returns empty for single segment, but component still renders + const fileNameElement = container.querySelector('.fileName') + expect(fileNameElement).toBeInTheDocument() + // The first span (file name) should be empty + const fileNameSpan = fileNameElement?.querySelector('span:first-child') + expect(fileNameSpan?.textContent).toBe('') + }) + + it('should handle file with empty name', async () => { + // Arrange + const file = createMockFile({ name: '' }) + + // Act + const { container } = renderFilePreview({ file }) + + // Assert - Should not crash + expect(container.firstChild).toBeInTheDocument() + }) + }) + + describe('hidePreview prop', () => { + it('should accept hidePreview callback', async () => { + // Arrange + const hidePreview = jest.fn() + + // Act + renderFilePreview({ hidePreview }) + + // Assert - No errors thrown + expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // Edge Cases Tests + // -------------------------------------------------------------------------- + describe('Edge Cases', () => { + it('should handle file with undefined id', async () => { + // Arrange + const file = createMockFile({ id: undefined }) + + // Act + const { container } = renderFilePreview({ file }) + + // Assert - Should not call API, remain in loading state + expect(mockFetchFilePreview).not.toHaveBeenCalled() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle file with empty string id', async () => { + // Arrange + const file = createMockFile({ id: '' }) + + // Act + renderFilePreview({ file }) + + // Assert - Empty string is falsy, should not call API + expect(mockFetchFilePreview).not.toHaveBeenCalled() + }) + + it('should handle very long file names', async () => { + // Arrange + const longName = `${'a'.repeat(200)}.pdf` + const file = createMockFile({ name: longName }) + + // Act + renderFilePreview({ file }) + + // Assert + expect(screen.getByText('a'.repeat(200))).toBeInTheDocument() + }) + + it('should handle file with special characters in name', async () => { + // Arrange + const file = createMockFile({ name: 'file-with_special@#$%.txt' }) + + // Act + renderFilePreview({ file }) + + // Assert + expect(screen.getByText('file-with_special@#$%')).toBeInTheDocument() + }) + + it('should handle very long preview content', async () => { + // Arrange + const longContent = 'x'.repeat(10000) + mockFetchFilePreview.mockResolvedValue({ content: longContent }) + + // Act + renderFilePreview() + + // Assert + await waitFor(() => { + expect(screen.getByText(longContent)).toBeInTheDocument() + }) + }) + + it('should handle preview content with special characters safely', async () => { + // Arrange + const specialContent = '\n\t& < > "' + mockFetchFilePreview.mockResolvedValue({ content: specialContent }) + + // Act + const { container } = renderFilePreview() + + // Assert - Should render as text, not execute scripts + await waitFor(() => { + const contentDiv = container.querySelector('.fileContent') + expect(contentDiv).toBeInTheDocument() + // Content is escaped by React, so HTML entities are displayed + expect(contentDiv?.textContent).toContain('alert') + }) + }) + + it('should handle preview content with unicode', async () => { + // Arrange + const unicodeContent = '中文内容 🚀 émojis & spëcîal çhàrs' + mockFetchFilePreview.mockResolvedValue({ content: unicodeContent }) + + // Act + renderFilePreview() + + // Assert + await waitFor(() => { + expect(screen.getByText(unicodeContent)).toBeInTheDocument() + }) + }) + + it('should handle preview content with newlines', async () => { + // Arrange + const multilineContent = 'Line 1\nLine 2\nLine 3' + mockFetchFilePreview.mockResolvedValue({ content: multilineContent }) + + // Act + const { container } = renderFilePreview() + + // Assert - Content should be in the DOM + await waitFor(() => { + const contentDiv = container.querySelector('.fileContent') + expect(contentDiv).toBeInTheDocument() + expect(contentDiv?.textContent).toContain('Line 1') + expect(contentDiv?.textContent).toContain('Line 2') + expect(contentDiv?.textContent).toContain('Line 3') + }) + }) + + it('should handle null content from API', async () => { + // Arrange + mockFetchFilePreview.mockResolvedValue({ content: null as unknown as string }) + + // Act + const { container } = renderFilePreview() + + // Assert - Should not crash + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // Side Effects and Cleanup Tests + // -------------------------------------------------------------------------- + describe('Side Effects and Cleanup', () => { + it('should trigger effect when file prop changes', async () => { + // Arrange + const file1 = createMockFile({ id: 'file-1' }) + const file2 = createMockFile({ id: 'file-2' }) + + // Act + const { rerender } = render( + , + ) + + await waitFor(() => { + expect(mockFetchFilePreview).toHaveBeenCalledTimes(1) + }) + + rerender() + + // Assert + await waitFor(() => { + expect(mockFetchFilePreview).toHaveBeenCalledTimes(2) + }) + }) + + it('should not trigger effect when hidePreview changes', async () => { + // Arrange + const file = createMockFile() + const hidePreview1 = jest.fn() + const hidePreview2 = jest.fn() + + // Act + const { rerender } = render( + , + ) + + await waitFor(() => { + expect(mockFetchFilePreview).toHaveBeenCalledTimes(1) + }) + + rerender() + + // Assert - Should not call API again (file didn't change) + // Note: This depends on useEffect dependency array only including [file] + await waitFor(() => { + expect(mockFetchFilePreview).toHaveBeenCalledTimes(1) + }) + }) + + it('should handle rapid file changes', async () => { + // Arrange + const files = Array.from({ length: 5 }, (_, i) => + createMockFile({ id: `file-${i}` }), + ) + + // Act + const { rerender } = render( + , + ) + + // Rapidly change files + for (let i = 1; i < files.length; i++) + rerender() + + // Assert - Should have called API for each file + await waitFor(() => { + expect(mockFetchFilePreview).toHaveBeenCalledTimes(5) + }) + }) + + it('should handle unmount during loading', async () => { + // Arrange + mockFetchFilePreview.mockImplementation( + () => new Promise(resolve => setTimeout(() => resolve({ content: 'delayed' }), 1000)), + ) + + // Act + const { unmount } = renderFilePreview() + + // Unmount before API resolves + unmount() + + // Assert - No errors should be thrown (React handles state updates on unmounted) + expect(true).toBe(true) + }) + + it('should handle file changing from defined to undefined', async () => { + // Arrange + const file = createMockFile() + + // Act + const { rerender, container } = render( + , + ) + + await waitFor(() => { + expect(mockFetchFilePreview).toHaveBeenCalledTimes(1) + }) + + rerender() + + // Assert - Should not crash, API should not be called again + expect(container.firstChild).toBeInTheDocument() + expect(mockFetchFilePreview).toHaveBeenCalledTimes(1) + }) + }) + + // -------------------------------------------------------------------------- + // getFileName Helper Tests + // -------------------------------------------------------------------------- + describe('getFileName Helper', () => { + it('should extract name without extension for simple filename', async () => { + // Arrange + const file = createMockFile({ name: 'document.pdf' }) + + // Act + renderFilePreview({ file }) + + // Assert + expect(screen.getByText('document')).toBeInTheDocument() + }) + + it('should handle filename with multiple dots', async () => { + // Arrange + const file = createMockFile({ name: 'file.name.with.dots.txt' }) + + // Act + renderFilePreview({ file }) + + // Assert - Should join all parts except last with comma + expect(screen.getByText('file,name,with,dots')).toBeInTheDocument() + }) + + it('should return empty for filename without dot', async () => { + // Arrange + const file = createMockFile({ name: 'nodotfile' }) + + // Act + const { container } = renderFilePreview({ file }) + + // Assert - slice(0, -1) on single element array returns empty + const fileNameElement = container.querySelector('.fileName') + const firstSpan = fileNameElement?.querySelector('span:first-child') + expect(firstSpan?.textContent).toBe('') + }) + + it('should return empty string when file is undefined', async () => { + // Arrange & Act + const { container } = renderFilePreview({ file: undefined }) + + // Assert - File name area should have empty first span + const fileNameElement = container.querySelector('.system-xs-medium') + expect(fileNameElement).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Accessibility Tests + // -------------------------------------------------------------------------- + describe('Accessibility', () => { + it('should have clickable close button with visual indicator', async () => { + // Arrange & Act + const { container } = renderFilePreview() + + // Assert + const closeButton = container.querySelector('.cursor-pointer') + expect(closeButton).toBeInTheDocument() + expect(closeButton).toHaveClass('cursor-pointer') + }) + + it('should have proper heading structure', async () => { + // Arrange & Act + renderFilePreview() + + // Assert + expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Error Handling Tests + // -------------------------------------------------------------------------- + describe('Error Handling', () => { + it('should not crash on API network error', async () => { + // Arrange + mockFetchFilePreview.mockRejectedValue(new Error('Network Error')) + + // Act + const { container } = renderFilePreview() + + // Assert - Component should still render + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + }) + + it('should not crash on API timeout', async () => { + // Arrange + mockFetchFilePreview.mockRejectedValue(new Error('Timeout')) + + // Act + const { container } = renderFilePreview() + + // Assert + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + }) + + it('should not crash on malformed API response', async () => { + // Arrange + mockFetchFilePreview.mockResolvedValue({} as { content: string }) + + // Act + const { container } = renderFilePreview() + + // Assert + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + }) + }) +}) diff --git a/web/app/components/datasets/create/notion-page-preview/index.spec.tsx b/web/app/components/datasets/create/notion-page-preview/index.spec.tsx new file mode 100644 index 0000000000..daec7a8cdf --- /dev/null +++ b/web/app/components/datasets/create/notion-page-preview/index.spec.tsx @@ -0,0 +1,1150 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import NotionPagePreview from './index' +import type { NotionPage } from '@/models/common' +import { fetchNotionPagePreview } from '@/service/datasets' + +// Mock the fetchNotionPagePreview service +jest.mock('@/service/datasets', () => ({ + fetchNotionPagePreview: jest.fn(), +})) + +const mockFetchNotionPagePreview = fetchNotionPagePreview as jest.MockedFunction + +// Factory function to create mock NotionPage objects +const createMockNotionPage = (overrides: Partial = {}): NotionPage => { + return { + page_id: 'page-123', + page_name: 'Test Page', + page_icon: null, + parent_id: 'parent-123', + type: 'page', + is_bound: false, + workspace_id: 'workspace-123', + ...overrides, + } +} + +// Factory function to create NotionPage with emoji icon +const createMockNotionPageWithEmojiIcon = (emoji: string, overrides: Partial = {}): NotionPage => { + return createMockNotionPage({ + page_icon: { + type: 'emoji', + url: null, + emoji, + }, + ...overrides, + }) +} + +// Factory function to create NotionPage with URL icon +const createMockNotionPageWithUrlIcon = (url: string, overrides: Partial = {}): NotionPage => { + return createMockNotionPage({ + page_icon: { + type: 'url', + url, + emoji: null, + }, + ...overrides, + }) +} + +// Helper to render NotionPagePreview with default props and wait for async updates +const renderNotionPagePreview = async ( + props: Partial<{ + currentPage?: NotionPage + notionCredentialId: string + hidePreview: () => void + }> = {}, + waitForContent = true, +) => { + const defaultProps = { + currentPage: createMockNotionPage(), + notionCredentialId: 'credential-123', + hidePreview: jest.fn(), + ...props, + } + const result = render() + + // Wait for async state updates to complete if needed + if (waitForContent && defaultProps.currentPage) { + await waitFor(() => { + // Wait for loading to finish + expect(result.container.querySelector('.spin-animation')).not.toBeInTheDocument() + }) + } + + return { + ...result, + props: defaultProps, + } +} + +// Helper to find the loading spinner element +const findLoadingSpinner = (container: HTMLElement) => { + return container.querySelector('.spin-animation') +} + +// ============================================================================ +// NotionPagePreview Component Tests +// ============================================================================ +// Note: Branch coverage is ~88% because line 29 (`if (!currentPage) return`) +// is defensive code that cannot be reached - getPreviewContent is only called +// from useEffect when currentPage is truthy. +// ============================================================================ +describe('NotionPagePreview', () => { + beforeEach(() => { + jest.clearAllMocks() + // Default successful API response + mockFetchNotionPagePreview.mockResolvedValue({ content: 'Preview content here' }) + }) + + afterEach(async () => { + // Wait for any pending state updates to complete + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)) + }) + }) + + // -------------------------------------------------------------------------- + // Rendering Tests - Verify component renders properly + // -------------------------------------------------------------------------- + describe('Rendering', () => { + it('should render without crashing', async () => { + // Arrange & Act + await renderNotionPagePreview() + + // Assert + expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument() + }) + + it('should render page preview header', async () => { + // Arrange & Act + await renderNotionPagePreview() + + // Assert + expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument() + }) + + it('should render close button with XMarkIcon', async () => { + // Arrange & Act + const { container } = await renderNotionPagePreview() + + // Assert + const closeButton = container.querySelector('.cursor-pointer') + expect(closeButton).toBeInTheDocument() + const xMarkIcon = closeButton?.querySelector('svg') + expect(xMarkIcon).toBeInTheDocument() + }) + + it('should render page name', async () => { + // Arrange + const page = createMockNotionPage({ page_name: 'My Notion Page' }) + + // Act + await renderNotionPagePreview({ currentPage: page }) + + // Assert + expect(screen.getByText('My Notion Page')).toBeInTheDocument() + }) + + it('should apply correct CSS classes to container', async () => { + // Arrange & Act + const { container } = await renderNotionPagePreview() + + // Assert + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('h-full') + }) + + it('should render NotionIcon component', async () => { + // Arrange + const page = createMockNotionPage() + + // Act + const { container } = await renderNotionPagePreview({ currentPage: page }) + + // Assert - NotionIcon should be rendered (either as img or div or svg) + const iconContainer = container.querySelector('.mr-1.shrink-0') + expect(iconContainer).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // NotionIcon Rendering Tests + // -------------------------------------------------------------------------- + describe('NotionIcon Rendering', () => { + it('should render default icon when page_icon is null', async () => { + // Arrange + const page = createMockNotionPage({ page_icon: null }) + + // Act + const { container } = await renderNotionPagePreview({ currentPage: page }) + + // Assert - Should render RiFileTextLine icon (svg) + const svgIcon = container.querySelector('svg') + expect(svgIcon).toBeInTheDocument() + }) + + it('should render emoji icon when page_icon has emoji type', async () => { + // Arrange + const page = createMockNotionPageWithEmojiIcon('📝') + + // Act + await renderNotionPagePreview({ currentPage: page }) + + // Assert + expect(screen.getByText('📝')).toBeInTheDocument() + }) + + it('should render image icon when page_icon has url type', async () => { + // Arrange + const page = createMockNotionPageWithUrlIcon('https://example.com/icon.png') + + // Act + const { container } = await renderNotionPagePreview({ currentPage: page }) + + // Assert + const img = container.querySelector('img[alt="page icon"]') + expect(img).toBeInTheDocument() + expect(img).toHaveAttribute('src', 'https://example.com/icon.png') + }) + }) + + // -------------------------------------------------------------------------- + // Loading State Tests + // -------------------------------------------------------------------------- + describe('Loading State', () => { + it('should show loading indicator initially', async () => { + // Arrange - Delay API response to keep loading state + mockFetchNotionPagePreview.mockImplementation( + () => new Promise(resolve => setTimeout(() => resolve({ content: 'test' }), 100)), + ) + + // Act - Don't wait for content to load + const { container } = await renderNotionPagePreview({}, false) + + // Assert - Loading should be visible initially + const loadingElement = findLoadingSpinner(container) + expect(loadingElement).toBeInTheDocument() + }) + + it('should hide loading indicator after content loads', async () => { + // Arrange + mockFetchNotionPagePreview.mockResolvedValue({ content: 'Loaded content' }) + + // Act + const { container } = await renderNotionPagePreview() + + // Assert + expect(screen.getByText('Loaded content')).toBeInTheDocument() + // Loading should be gone + const loadingElement = findLoadingSpinner(container) + expect(loadingElement).not.toBeInTheDocument() + }) + + it('should show loading when currentPage changes', async () => { + // Arrange + const page1 = createMockNotionPage({ page_id: 'page-1', page_name: 'Page 1' }) + const page2 = createMockNotionPage({ page_id: 'page-2', page_name: 'Page 2' }) + + let resolveFirst: (value: { content: string }) => void + let resolveSecond: (value: { content: string }) => void + + mockFetchNotionPagePreview + .mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve })) + .mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve })) + + // Act - Initial render + const { rerender, container } = render( + , + ) + + // First page loading - spinner should be visible + expect(findLoadingSpinner(container)).toBeInTheDocument() + + // Resolve first page + await act(async () => { + resolveFirst({ content: 'Content 1' }) + }) + + await waitFor(() => { + expect(screen.getByText('Content 1')).toBeInTheDocument() + }) + + // Rerender with new page + rerender() + + // Should show loading again + await waitFor(() => { + expect(findLoadingSpinner(container)).toBeInTheDocument() + }) + + // Resolve second page + await act(async () => { + resolveSecond({ content: 'Content 2' }) + }) + + await waitFor(() => { + expect(screen.getByText('Content 2')).toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // API Call Tests + // -------------------------------------------------------------------------- + describe('API Calls', () => { + it('should call fetchNotionPagePreview with correct parameters', async () => { + // Arrange + const page = createMockNotionPage({ + page_id: 'test-page-id', + type: 'database', + }) + + // Act + await renderNotionPagePreview({ + currentPage: page, + notionCredentialId: 'test-credential-id', + }) + + // Assert + expect(mockFetchNotionPagePreview).toHaveBeenCalledWith({ + pageID: 'test-page-id', + pageType: 'database', + credentialID: 'test-credential-id', + }) + }) + + it('should not call fetchNotionPagePreview when currentPage is undefined', async () => { + // Arrange & Act + await renderNotionPagePreview({ currentPage: undefined }, false) + + // Assert + expect(mockFetchNotionPagePreview).not.toHaveBeenCalled() + }) + + it('should call fetchNotionPagePreview again when currentPage changes', async () => { + // Arrange + const page1 = createMockNotionPage({ page_id: 'page-1' }) + const page2 = createMockNotionPage({ page_id: 'page-2' }) + + // Act + const { rerender } = render( + , + ) + + await waitFor(() => { + expect(mockFetchNotionPagePreview).toHaveBeenCalledWith({ + pageID: 'page-1', + pageType: 'page', + credentialID: 'cred-123', + }) + }) + + await act(async () => { + rerender() + }) + + // Assert + await waitFor(() => { + expect(mockFetchNotionPagePreview).toHaveBeenCalledWith({ + pageID: 'page-2', + pageType: 'page', + credentialID: 'cred-123', + }) + expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(2) + }) + }) + + it('should handle API success and display content', async () => { + // Arrange + mockFetchNotionPagePreview.mockResolvedValue({ content: 'Notion page preview content from API' }) + + // Act + await renderNotionPagePreview() + + // Assert + expect(screen.getByText('Notion page preview content from API')).toBeInTheDocument() + }) + + it('should handle API error gracefully', async () => { + // Arrange + mockFetchNotionPagePreview.mockRejectedValue(new Error('Network error')) + + // Act + const { container } = await renderNotionPagePreview({}, false) + + // Assert - Component should not crash + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + // Header should still render + expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument() + }) + + it('should handle empty content response', async () => { + // Arrange + mockFetchNotionPagePreview.mockResolvedValue({ content: '' }) + + // Act + const { container } = await renderNotionPagePreview() + + // Assert - Should still render without loading + const loadingElement = findLoadingSpinner(container) + expect(loadingElement).not.toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // User Interactions Tests + // -------------------------------------------------------------------------- + describe('User Interactions', () => { + it('should call hidePreview when close button is clicked', async () => { + // Arrange + const hidePreview = jest.fn() + const { container } = await renderNotionPagePreview({ hidePreview }) + + // Act + const closeButton = container.querySelector('.cursor-pointer') as HTMLElement + fireEvent.click(closeButton) + + // Assert + expect(hidePreview).toHaveBeenCalledTimes(1) + }) + + it('should handle multiple clicks on close button', async () => { + // Arrange + const hidePreview = jest.fn() + const { container } = await renderNotionPagePreview({ hidePreview }) + + // Act + const closeButton = container.querySelector('.cursor-pointer') as HTMLElement + fireEvent.click(closeButton) + fireEvent.click(closeButton) + fireEvent.click(closeButton) + + // Assert + expect(hidePreview).toHaveBeenCalledTimes(3) + }) + }) + + // -------------------------------------------------------------------------- + // State Management Tests + // -------------------------------------------------------------------------- + describe('State Management', () => { + it('should initialize with loading state true', async () => { + // Arrange - Keep loading indefinitely (never resolves) + mockFetchNotionPagePreview.mockImplementation(() => new Promise(() => { /* intentionally empty */ })) + + // Act - Don't wait for content + const { container } = await renderNotionPagePreview({}, false) + + // Assert + const loadingElement = findLoadingSpinner(container) + expect(loadingElement).toBeInTheDocument() + }) + + it('should update previewContent state after successful fetch', async () => { + // Arrange + mockFetchNotionPagePreview.mockResolvedValue({ content: 'New preview content' }) + + // Act + await renderNotionPagePreview() + + // Assert + expect(screen.getByText('New preview content')).toBeInTheDocument() + }) + + it('should reset loading to true when currentPage changes', async () => { + // Arrange + const page1 = createMockNotionPage({ page_id: 'page-1' }) + const page2 = createMockNotionPage({ page_id: 'page-2' }) + + mockFetchNotionPagePreview + .mockResolvedValueOnce({ content: 'Content 1' }) + .mockImplementationOnce(() => new Promise(() => { /* never resolves */ })) + + // Act + const { rerender, container } = render( + , + ) + + await waitFor(() => { + expect(screen.getByText('Content 1')).toBeInTheDocument() + }) + + // Change page + await act(async () => { + rerender() + }) + + // Assert - Loading should be shown again + await waitFor(() => { + const loadingElement = findLoadingSpinner(container) + expect(loadingElement).toBeInTheDocument() + }) + }) + + it('should replace old content with new content when page changes', async () => { + // Arrange + const page1 = createMockNotionPage({ page_id: 'page-1' }) + const page2 = createMockNotionPage({ page_id: 'page-2' }) + + let resolveSecond: (value: { content: string }) => void + + mockFetchNotionPagePreview + .mockResolvedValueOnce({ content: 'Content 1' }) + .mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve })) + + // Act + const { rerender } = render( + , + ) + + await waitFor(() => { + expect(screen.getByText('Content 1')).toBeInTheDocument() + }) + + // Change page + await act(async () => { + rerender() + }) + + // Resolve second fetch + await act(async () => { + resolveSecond({ content: 'Content 2' }) + }) + + await waitFor(() => { + expect(screen.getByText('Content 2')).toBeInTheDocument() + expect(screen.queryByText('Content 1')).not.toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // Props Testing + // -------------------------------------------------------------------------- + describe('Props', () => { + describe('currentPage prop', () => { + it('should render correctly with currentPage prop', async () => { + // Arrange + const page = createMockNotionPage({ page_name: 'My Test Page' }) + + // Act + await renderNotionPagePreview({ currentPage: page }) + + // Assert + expect(screen.getByText('My Test Page')).toBeInTheDocument() + }) + + it('should render correctly without currentPage prop (undefined)', async () => { + // Arrange & Act + await renderNotionPagePreview({ currentPage: undefined }, false) + + // Assert - Header should still render + expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument() + }) + + it('should handle page with empty name', async () => { + // Arrange + const page = createMockNotionPage({ page_name: '' }) + + // Act + const { container } = await renderNotionPagePreview({ currentPage: page }) + + // Assert - Should not crash + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle page with very long name', async () => { + // Arrange + const longName = 'a'.repeat(200) + const page = createMockNotionPage({ page_name: longName }) + + // Act + await renderNotionPagePreview({ currentPage: page }) + + // Assert + expect(screen.getByText(longName)).toBeInTheDocument() + }) + + it('should handle page with special characters in name', async () => { + // Arrange + const page = createMockNotionPage({ page_name: 'Page with & "chars"' }) + + // Act + await renderNotionPagePreview({ currentPage: page }) + + // Assert + expect(screen.getByText('Page with & "chars"')).toBeInTheDocument() + }) + + it('should handle page with unicode characters in name', async () => { + // Arrange + const page = createMockNotionPage({ page_name: '中文页面名称 🚀 日本語' }) + + // Act + await renderNotionPagePreview({ currentPage: page }) + + // Assert + expect(screen.getByText('中文页面名称 🚀 日本語')).toBeInTheDocument() + }) + }) + + describe('notionCredentialId prop', () => { + it('should pass notionCredentialId to API call', async () => { + // Arrange + const page = createMockNotionPage() + + // Act + await renderNotionPagePreview({ + currentPage: page, + notionCredentialId: 'my-credential-id', + }) + + // Assert + expect(mockFetchNotionPagePreview).toHaveBeenCalledWith( + expect.objectContaining({ credentialID: 'my-credential-id' }), + ) + }) + }) + + describe('hidePreview prop', () => { + it('should accept hidePreview callback', async () => { + // Arrange + const hidePreview = jest.fn() + + // Act + await renderNotionPagePreview({ hidePreview }) + + // Assert - No errors thrown + expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // Edge Cases Tests + // -------------------------------------------------------------------------- + describe('Edge Cases', () => { + it('should handle page with undefined page_id', async () => { + // Arrange + const page = createMockNotionPage({ page_id: undefined as unknown as string }) + + // Act + await renderNotionPagePreview({ currentPage: page }) + + // Assert - API should still be called (with undefined pageID) + expect(mockFetchNotionPagePreview).toHaveBeenCalled() + }) + + it('should handle page with empty string page_id', async () => { + // Arrange + const page = createMockNotionPage({ page_id: '' }) + + // Act + await renderNotionPagePreview({ currentPage: page }) + + // Assert + expect(mockFetchNotionPagePreview).toHaveBeenCalledWith( + expect.objectContaining({ pageID: '' }), + ) + }) + + it('should handle very long preview content', async () => { + // Arrange + const longContent = 'x'.repeat(10000) + mockFetchNotionPagePreview.mockResolvedValue({ content: longContent }) + + // Act + await renderNotionPagePreview() + + // Assert + expect(screen.getByText(longContent)).toBeInTheDocument() + }) + + it('should handle preview content with special characters safely', async () => { + // Arrange + const specialContent = '\n\t& < > "' + mockFetchNotionPagePreview.mockResolvedValue({ content: specialContent }) + + // Act + const { container } = await renderNotionPagePreview() + + // Assert - Should render as text, not execute scripts + const contentDiv = container.querySelector('.fileContent') + expect(contentDiv).toBeInTheDocument() + expect(contentDiv?.textContent).toContain('alert') + }) + + it('should handle preview content with unicode', async () => { + // Arrange + const unicodeContent = '中文内容 🚀 émojis & spëcîal çhàrs' + mockFetchNotionPagePreview.mockResolvedValue({ content: unicodeContent }) + + // Act + await renderNotionPagePreview() + + // Assert + expect(screen.getByText(unicodeContent)).toBeInTheDocument() + }) + + it('should handle preview content with newlines', async () => { + // Arrange + const multilineContent = 'Line 1\nLine 2\nLine 3' + mockFetchNotionPagePreview.mockResolvedValue({ content: multilineContent }) + + // Act + const { container } = await renderNotionPagePreview() + + // Assert + const contentDiv = container.querySelector('.fileContent') + expect(contentDiv).toBeInTheDocument() + expect(contentDiv?.textContent).toContain('Line 1') + expect(contentDiv?.textContent).toContain('Line 2') + expect(contentDiv?.textContent).toContain('Line 3') + }) + + it('should handle null content from API', async () => { + // Arrange + mockFetchNotionPagePreview.mockResolvedValue({ content: null as unknown as string }) + + // Act + const { container } = await renderNotionPagePreview() + + // Assert - Should not crash + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle different page types', async () => { + // Arrange + const databasePage = createMockNotionPage({ type: 'database' }) + + // Act + await renderNotionPagePreview({ currentPage: databasePage }) + + // Assert + expect(mockFetchNotionPagePreview).toHaveBeenCalledWith( + expect.objectContaining({ pageType: 'database' }), + ) + }) + }) + + // -------------------------------------------------------------------------- + // Side Effects and Cleanup Tests + // -------------------------------------------------------------------------- + describe('Side Effects and Cleanup', () => { + it('should trigger effect when currentPage prop changes', async () => { + // Arrange + const page1 = createMockNotionPage({ page_id: 'page-1' }) + const page2 = createMockNotionPage({ page_id: 'page-2' }) + + // Act + const { rerender } = render( + , + ) + + await waitFor(() => { + expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(1) + }) + + await act(async () => { + rerender() + }) + + // Assert + await waitFor(() => { + expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(2) + }) + }) + + it('should not trigger effect when hidePreview changes', async () => { + // Arrange + const page = createMockNotionPage() + const hidePreview1 = jest.fn() + const hidePreview2 = jest.fn() + + // Act + const { rerender } = render( + , + ) + + await waitFor(() => { + expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(1) + }) + + await act(async () => { + rerender() + }) + + // Assert - Should not call API again (currentPage didn't change by reference) + // Note: Since currentPage is the same object, effect should not re-run + expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(1) + }) + + it('should not trigger effect when notionCredentialId changes', async () => { + // Arrange + const page = createMockNotionPage() + + // Act + const { rerender } = render( + , + ) + + await waitFor(() => { + expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(1) + }) + + await act(async () => { + rerender() + }) + + // Assert - Should not call API again (only currentPage is in dependency array) + expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(1) + }) + + it('should handle rapid page changes', async () => { + // Arrange + const pages = Array.from({ length: 5 }, (_, i) => + createMockNotionPage({ page_id: `page-${i}` }), + ) + + // Act + const { rerender } = render( + , + ) + + // Rapidly change pages + for (let i = 1; i < pages.length; i++) { + await act(async () => { + rerender() + }) + } + + // Assert - Should have called API for each page + await waitFor(() => { + expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(5) + }) + }) + + it('should handle unmount during loading', async () => { + // Arrange + mockFetchNotionPagePreview.mockImplementation( + () => new Promise(resolve => setTimeout(() => resolve({ content: 'delayed' }), 1000)), + ) + + // Act - Don't wait for content + const { unmount } = await renderNotionPagePreview({}, false) + + // Unmount before API resolves + unmount() + + // Assert - No errors should be thrown + expect(true).toBe(true) + }) + + it('should handle page changing from defined to undefined', async () => { + // Arrange + const page = createMockNotionPage() + + // Act + const { rerender, container } = render( + , + ) + + await waitFor(() => { + expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(1) + }) + + await act(async () => { + rerender() + }) + + // Assert - Should not crash, API should not be called again + expect(container.firstChild).toBeInTheDocument() + expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(1) + }) + }) + + // -------------------------------------------------------------------------- + // Accessibility Tests + // -------------------------------------------------------------------------- + describe('Accessibility', () => { + it('should have clickable close button with visual indicator', async () => { + // Arrange & Act + const { container } = await renderNotionPagePreview() + + // Assert + const closeButton = container.querySelector('.cursor-pointer') + expect(closeButton).toBeInTheDocument() + expect(closeButton).toHaveClass('cursor-pointer') + }) + + it('should have proper heading structure', async () => { + // Arrange & Act + await renderNotionPagePreview() + + // Assert + expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Error Handling Tests + // -------------------------------------------------------------------------- + describe('Error Handling', () => { + it('should not crash on API network error', async () => { + // Arrange + mockFetchNotionPagePreview.mockRejectedValue(new Error('Network Error')) + + // Act + const { container } = await renderNotionPagePreview({}, false) + + // Assert - Component should still render + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + }) + + it('should not crash on API timeout', async () => { + // Arrange + mockFetchNotionPagePreview.mockRejectedValue(new Error('Timeout')) + + // Act + const { container } = await renderNotionPagePreview({}, false) + + // Assert + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + }) + + it('should not crash on malformed API response', async () => { + // Arrange + mockFetchNotionPagePreview.mockResolvedValue({} as { content: string }) + + // Act + const { container } = await renderNotionPagePreview() + + // Assert + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle 404 error gracefully', async () => { + // Arrange + mockFetchNotionPagePreview.mockRejectedValue(new Error('404 Not Found')) + + // Act + const { container } = await renderNotionPagePreview({}, false) + + // Assert + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + }) + + it('should handle 500 error gracefully', async () => { + // Arrange + mockFetchNotionPagePreview.mockRejectedValue(new Error('500 Internal Server Error')) + + // Act + const { container } = await renderNotionPagePreview({}, false) + + // Assert + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + }) + + it('should handle authorization error gracefully', async () => { + // Arrange + mockFetchNotionPagePreview.mockRejectedValue(new Error('401 Unauthorized')) + + // Act + const { container } = await renderNotionPagePreview({}, false) + + // Assert + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // Page Type Variations Tests + // -------------------------------------------------------------------------- + describe('Page Type Variations', () => { + it('should handle page type', async () => { + // Arrange + const page = createMockNotionPage({ type: 'page' }) + + // Act + await renderNotionPagePreview({ currentPage: page }) + + // Assert + expect(mockFetchNotionPagePreview).toHaveBeenCalledWith( + expect.objectContaining({ pageType: 'page' }), + ) + }) + + it('should handle database type', async () => { + // Arrange + const page = createMockNotionPage({ type: 'database' }) + + // Act + await renderNotionPagePreview({ currentPage: page }) + + // Assert + expect(mockFetchNotionPagePreview).toHaveBeenCalledWith( + expect.objectContaining({ pageType: 'database' }), + ) + }) + + it('should handle unknown type', async () => { + // Arrange + const page = createMockNotionPage({ type: 'unknown_type' }) + + // Act + await renderNotionPagePreview({ currentPage: page }) + + // Assert + expect(mockFetchNotionPagePreview).toHaveBeenCalledWith( + expect.objectContaining({ pageType: 'unknown_type' }), + ) + }) + }) + + // -------------------------------------------------------------------------- + // Icon Type Variations Tests + // -------------------------------------------------------------------------- + describe('Icon Type Variations', () => { + it('should handle page with null icon', async () => { + // Arrange + const page = createMockNotionPage({ page_icon: null }) + + // Act + const { container } = await renderNotionPagePreview({ currentPage: page }) + + // Assert - Should render default icon + const svgIcon = container.querySelector('svg') + expect(svgIcon).toBeInTheDocument() + }) + + it('should handle page with emoji icon object', async () => { + // Arrange + const page = createMockNotionPageWithEmojiIcon('📄') + + // Act + await renderNotionPagePreview({ currentPage: page }) + + // Assert + expect(screen.getByText('📄')).toBeInTheDocument() + }) + + it('should handle page with url icon object', async () => { + // Arrange + const page = createMockNotionPageWithUrlIcon('https://example.com/custom-icon.png') + + // Act + const { container } = await renderNotionPagePreview({ currentPage: page }) + + // Assert + const img = container.querySelector('img[alt="page icon"]') + expect(img).toBeInTheDocument() + expect(img).toHaveAttribute('src', 'https://example.com/custom-icon.png') + }) + + it('should handle page with icon object having null values', async () => { + // Arrange + const page = createMockNotionPage({ + page_icon: { + type: null, + url: null, + emoji: null, + }, + }) + + // Act + const { container } = await renderNotionPagePreview({ currentPage: page }) + + // Assert - Should render, likely with default/fallback + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle page with icon object having empty url', async () => { + // Arrange + // Suppress console.error for this test as we're intentionally testing empty src edge case + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn()) + + const page = createMockNotionPage({ + page_icon: { + type: 'url', + url: '', + emoji: null, + }, + }) + + // Act + const { container } = await renderNotionPagePreview({ currentPage: page }) + + // Assert - Component should not crash, may render img or fallback + expect(container.firstChild).toBeInTheDocument() + // NotionIcon renders img when type is 'url' + const img = container.querySelector('img[alt="page icon"]') + if (img) + expect(img).toBeInTheDocument() + + // Restore console.error + consoleErrorSpy.mockRestore() + }) + }) + + // -------------------------------------------------------------------------- + // Content Display Tests + // -------------------------------------------------------------------------- + describe('Content Display', () => { + it('should display content in fileContent div with correct class', async () => { + // Arrange + mockFetchNotionPagePreview.mockResolvedValue({ content: 'Test content' }) + + // Act + const { container } = await renderNotionPagePreview() + + // Assert + const contentDiv = container.querySelector('.fileContent') + expect(contentDiv).toBeInTheDocument() + expect(contentDiv).toHaveTextContent('Test content') + }) + + it('should preserve whitespace in content', async () => { + // Arrange + const contentWithWhitespace = ' indented content\n more indent' + mockFetchNotionPagePreview.mockResolvedValue({ content: contentWithWhitespace }) + + // Act + const { container } = await renderNotionPagePreview() + + // Assert + const contentDiv = container.querySelector('.fileContent') + expect(contentDiv).toBeInTheDocument() + // The CSS class has white-space: pre-line + expect(contentDiv?.textContent).toContain('indented content') + }) + + it('should display empty string content without loading', async () => { + // Arrange + mockFetchNotionPagePreview.mockResolvedValue({ content: '' }) + + // Act + const { container } = await renderNotionPagePreview() + + // Assert + const loadingElement = findLoadingSpinner(container) + expect(loadingElement).not.toBeInTheDocument() + const contentDiv = container.querySelector('.fileContent') + expect(contentDiv).toBeInTheDocument() + expect(contentDiv?.textContent).toBe('') + }) + }) +}) diff --git a/web/app/components/datasets/create/step-three/index.spec.tsx b/web/app/components/datasets/create/step-three/index.spec.tsx new file mode 100644 index 0000000000..02746c8aee --- /dev/null +++ b/web/app/components/datasets/create/step-three/index.spec.tsx @@ -0,0 +1,844 @@ +import { render, screen } from '@testing-library/react' +import StepThree from './index' +import type { FullDocumentDetail, IconInfo, createDocumentResponse } from '@/models/datasets' + +// Mock the EmbeddingProcess component since it has complex async logic +jest.mock('../embedding-process', () => ({ + __esModule: true, + default: jest.fn(({ datasetId, batchId, documents, indexingType, retrievalMethod }) => ( +
+ {datasetId} + {batchId} + {documents?.length ?? 0} + {indexingType} + {retrievalMethod} +
+ )), +})) + +// Mock useBreakpoints hook +let mockMediaType = 'pc' +jest.mock('@/hooks/use-breakpoints', () => ({ + __esModule: true, + MediaType: { + mobile: 'mobile', + tablet: 'tablet', + pc: 'pc', + }, + default: jest.fn(() => mockMediaType), +})) + +// Mock useDocLink hook +jest.mock('@/context/i18n', () => ({ + useDocLink: () => (path?: string) => `https://docs.dify.ai/en-US${path || ''}`, +})) + +// Factory function to create mock IconInfo +const createMockIconInfo = (overrides: Partial = {}): IconInfo => ({ + icon: '📙', + icon_type: 'emoji', + icon_background: '#FFF4ED', + icon_url: '', + ...overrides, +}) + +// Factory function to create mock FullDocumentDetail +const createMockDocument = (overrides: Partial = {}): FullDocumentDetail => ({ + id: 'doc-123', + name: 'test-document.txt', + data_source_type: 'upload_file', + data_source_info: { + upload_file: { + id: 'file-123', + name: 'test-document.txt', + extension: 'txt', + mime_type: 'text/plain', + size: 1024, + created_by: 'user-1', + created_at: Date.now(), + }, + }, + batch: 'batch-123', + created_api_request_id: 'request-123', + processing_started_at: Date.now(), + parsing_completed_at: Date.now(), + cleaning_completed_at: Date.now(), + splitting_completed_at: Date.now(), + tokens: 100, + indexing_latency: 5000, + completed_at: Date.now(), + paused_by: '', + paused_at: 0, + stopped_at: 0, + indexing_status: 'completed', + disabled_at: 0, + ...overrides, +} as FullDocumentDetail) + +// Factory function to create mock createDocumentResponse +const createMockCreationCache = (overrides: Partial = {}): createDocumentResponse => ({ + dataset: { + id: 'dataset-123', + name: 'Test Dataset', + icon_info: createMockIconInfo(), + indexing_technique: 'high_quality', + retrieval_model_dict: { + search_method: 'semantic_search', + }, + } as createDocumentResponse['dataset'], + batch: 'batch-123', + documents: [createMockDocument()] as createDocumentResponse['documents'], + ...overrides, +}) + +// Helper to render StepThree with default props +const renderStepThree = (props: Partial[0]> = {}) => { + const defaultProps = { + ...props, + } + return render() +} + +// ============================================================================ +// StepThree Component Tests +// ============================================================================ +describe('StepThree', () => { + beforeEach(() => { + jest.clearAllMocks() + mockMediaType = 'pc' + }) + + // -------------------------------------------------------------------------- + // Rendering Tests - Verify component renders properly + // -------------------------------------------------------------------------- + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + renderStepThree() + + // Assert + expect(screen.getByTestId('embedding-process')).toBeInTheDocument() + }) + + it('should render with creation title when datasetId is not provided', () => { + // Arrange & Act + renderStepThree() + + // Assert + expect(screen.getByText('datasetCreation.stepThree.creationTitle')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepThree.creationContent')).toBeInTheDocument() + }) + + it('should render with addition title when datasetId is provided', () => { + // Arrange & Act + renderStepThree({ + datasetId: 'existing-dataset-123', + datasetName: 'Existing Dataset', + }) + + // Assert + expect(screen.getByText('datasetCreation.stepThree.additionTitle')).toBeInTheDocument() + expect(screen.queryByText('datasetCreation.stepThree.creationTitle')).not.toBeInTheDocument() + }) + + it('should render label text in creation mode', () => { + // Arrange & Act + renderStepThree() + + // Assert + expect(screen.getByText('datasetCreation.stepThree.label')).toBeInTheDocument() + }) + + it('should render side tip panel on desktop', () => { + // Arrange + mockMediaType = 'pc' + + // Act + renderStepThree() + + // Assert + expect(screen.getByText('datasetCreation.stepThree.sideTipTitle')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepThree.sideTipContent')).toBeInTheDocument() + expect(screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore')).toBeInTheDocument() + }) + + it('should not render side tip panel on mobile', () => { + // Arrange + mockMediaType = 'mobile' + + // Act + renderStepThree() + + // Assert + expect(screen.queryByText('datasetCreation.stepThree.sideTipTitle')).not.toBeInTheDocument() + expect(screen.queryByText('datasetCreation.stepThree.sideTipContent')).not.toBeInTheDocument() + }) + + it('should render EmbeddingProcess component', () => { + // Arrange & Act + renderStepThree() + + // Assert + expect(screen.getByTestId('embedding-process')).toBeInTheDocument() + }) + + it('should render documentation link with correct href on desktop', () => { + // Arrange + mockMediaType = 'pc' + + // Act + renderStepThree() + + // Assert + const link = screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore') + expect(link).toHaveAttribute('href', 'https://docs.dify.ai/en-US/guides/knowledge-base/integrate-knowledge-within-application') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noreferrer noopener') + }) + + it('should apply correct container classes', () => { + // Arrange & Act + const { container } = renderStepThree() + + // Assert + const outerDiv = container.firstChild as HTMLElement + expect(outerDiv).toHaveClass('flex', 'h-full', 'max-h-full', 'w-full', 'justify-center', 'overflow-y-auto') + }) + }) + + // -------------------------------------------------------------------------- + // Props Testing - Test all prop variations + // -------------------------------------------------------------------------- + describe('Props', () => { + describe('datasetId prop', () => { + it('should render creation mode when datasetId is undefined', () => { + // Arrange & Act + renderStepThree({ datasetId: undefined }) + + // Assert + expect(screen.getByText('datasetCreation.stepThree.creationTitle')).toBeInTheDocument() + }) + + it('should render addition mode when datasetId is provided', () => { + // Arrange & Act + renderStepThree({ datasetId: 'dataset-123' }) + + // Assert + expect(screen.getByText('datasetCreation.stepThree.additionTitle')).toBeInTheDocument() + }) + + it('should pass datasetId to EmbeddingProcess', () => { + // Arrange + const datasetId = 'my-dataset-id' + + // Act + renderStepThree({ datasetId }) + + // Assert + expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent(datasetId) + }) + + it('should use creationCache dataset id when datasetId is not provided', () => { + // Arrange + const creationCache = createMockCreationCache() + + // Act + renderStepThree({ creationCache }) + + // Assert + expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('dataset-123') + }) + }) + + describe('datasetName prop', () => { + it('should display datasetName in creation mode', () => { + // Arrange & Act + renderStepThree({ datasetName: 'My Custom Dataset' }) + + // Assert + expect(screen.getByText('My Custom Dataset')).toBeInTheDocument() + }) + + it('should display datasetName in addition mode description', () => { + // Arrange & Act + renderStepThree({ + datasetId: 'dataset-123', + datasetName: 'Existing Dataset Name', + }) + + // Assert - Check the text contains the dataset name (in the description) + const description = screen.getByText(/datasetCreation.stepThree.additionP1.*Existing Dataset Name.*datasetCreation.stepThree.additionP2/i) + expect(description).toBeInTheDocument() + }) + + it('should fallback to creationCache dataset name when datasetName is not provided', () => { + // Arrange + const creationCache = createMockCreationCache() + creationCache.dataset!.name = 'Cache Dataset Name' + + // Act + renderStepThree({ creationCache }) + + // Assert + expect(screen.getByText('Cache Dataset Name')).toBeInTheDocument() + }) + }) + + describe('indexingType prop', () => { + it('should pass indexingType to EmbeddingProcess', () => { + // Arrange & Act + renderStepThree({ indexingType: 'high_quality' }) + + // Assert + expect(screen.getByTestId('ep-indexing-type')).toHaveTextContent('high_quality') + }) + + it('should use creationCache indexing_technique when indexingType is not provided', () => { + // Arrange + const creationCache = createMockCreationCache() + creationCache.dataset!.indexing_technique = 'economy' as any + + // Act + renderStepThree({ creationCache }) + + // Assert + expect(screen.getByTestId('ep-indexing-type')).toHaveTextContent('economy') + }) + + it('should prefer creationCache indexing_technique over indexingType prop', () => { + // Arrange + const creationCache = createMockCreationCache() + creationCache.dataset!.indexing_technique = 'cache_technique' as any + + // Act + renderStepThree({ creationCache, indexingType: 'prop_technique' }) + + // Assert - creationCache takes precedence + expect(screen.getByTestId('ep-indexing-type')).toHaveTextContent('cache_technique') + }) + }) + + describe('retrievalMethod prop', () => { + it('should pass retrievalMethod to EmbeddingProcess', () => { + // Arrange & Act + renderStepThree({ retrievalMethod: 'semantic_search' }) + + // Assert + expect(screen.getByTestId('ep-retrieval-method')).toHaveTextContent('semantic_search') + }) + + it('should use creationCache retrieval method when retrievalMethod is not provided', () => { + // Arrange + const creationCache = createMockCreationCache() + creationCache.dataset!.retrieval_model_dict = { search_method: 'full_text_search' } as any + + // Act + renderStepThree({ creationCache }) + + // Assert + expect(screen.getByTestId('ep-retrieval-method')).toHaveTextContent('full_text_search') + }) + }) + + describe('creationCache prop', () => { + it('should pass batchId from creationCache to EmbeddingProcess', () => { + // Arrange + const creationCache = createMockCreationCache() + creationCache.batch = 'custom-batch-123' + + // Act + renderStepThree({ creationCache }) + + // Assert + expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('custom-batch-123') + }) + + it('should pass documents from creationCache to EmbeddingProcess', () => { + // Arrange + const creationCache = createMockCreationCache() + creationCache.documents = [createMockDocument(), createMockDocument(), createMockDocument()] as any + + // Act + renderStepThree({ creationCache }) + + // Assert + expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('3') + }) + + it('should use icon_info from creationCache dataset', () => { + // Arrange + const creationCache = createMockCreationCache() + creationCache.dataset!.icon_info = createMockIconInfo({ + icon: '🚀', + icon_background: '#FF0000', + }) + + // Act + const { container } = renderStepThree({ creationCache }) + + // Assert - Check AppIcon component receives correct props + const appIcon = container.querySelector('span[style*="background"]') + expect(appIcon).toBeInTheDocument() + }) + + it('should handle undefined creationCache', () => { + // Arrange & Act + renderStepThree({ creationCache: undefined }) + + // Assert - Should not crash, use fallback values + expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('') + expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('') + }) + + it('should handle creationCache with undefined dataset', () => { + // Arrange + const creationCache: createDocumentResponse = { + dataset: undefined, + batch: 'batch-123', + documents: [], + } + + // Act + renderStepThree({ creationCache }) + + // Assert - Should use default icon info + expect(screen.getByTestId('embedding-process')).toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // Edge Cases Tests - Test null, undefined, empty values and boundaries + // -------------------------------------------------------------------------- + describe('Edge Cases', () => { + it('should handle all props being undefined', () => { + // Arrange & Act + renderStepThree({ + datasetId: undefined, + datasetName: undefined, + indexingType: undefined, + retrievalMethod: undefined, + creationCache: undefined, + }) + + // Assert - Should render creation mode with fallbacks + expect(screen.getByText('datasetCreation.stepThree.creationTitle')).toBeInTheDocument() + expect(screen.getByTestId('embedding-process')).toBeInTheDocument() + }) + + it('should handle empty string datasetId', () => { + // Arrange & Act + renderStepThree({ datasetId: '' }) + + // Assert - Empty string is falsy, should show creation mode + expect(screen.getByText('datasetCreation.stepThree.creationTitle')).toBeInTheDocument() + }) + + it('should handle empty string datasetName', () => { + // Arrange & Act + renderStepThree({ datasetName: '' }) + + // Assert - Should not crash + expect(screen.getByTestId('embedding-process')).toBeInTheDocument() + }) + + it('should handle empty documents array in creationCache', () => { + // Arrange + const creationCache = createMockCreationCache() + creationCache.documents = [] + + // Act + renderStepThree({ creationCache }) + + // Assert + expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('0') + }) + + it('should handle creationCache with missing icon_info', () => { + // Arrange + const creationCache = createMockCreationCache() + creationCache.dataset!.icon_info = undefined as any + + // Act + renderStepThree({ creationCache }) + + // Assert - Should use default icon info + expect(screen.getByTestId('embedding-process')).toBeInTheDocument() + }) + + it('should handle very long datasetName', () => { + // Arrange + const longName = 'A'.repeat(500) + + // Act + renderStepThree({ datasetName: longName }) + + // Assert - Should render without crashing + expect(screen.getByText(longName)).toBeInTheDocument() + }) + + it('should handle special characters in datasetName', () => { + // Arrange + const specialName = 'Dataset & "quotes" \'apostrophe\'' + + // Act + renderStepThree({ datasetName: specialName }) + + // Assert - Should render safely as text + expect(screen.getByText(specialName)).toBeInTheDocument() + }) + + it('should handle unicode characters in datasetName', () => { + // Arrange + const unicodeName = '数据集名称 🚀 émojis & spëcîal çhàrs' + + // Act + renderStepThree({ datasetName: unicodeName }) + + // Assert + expect(screen.getByText(unicodeName)).toBeInTheDocument() + }) + + it('should handle creationCache with null dataset name', () => { + // Arrange + const creationCache = createMockCreationCache() + creationCache.dataset!.name = null as any + + // Act + const { container } = renderStepThree({ creationCache }) + + // Assert - Should not crash + expect(container.firstChild).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Conditional Rendering Tests - Test mode switching behavior + // -------------------------------------------------------------------------- + describe('Conditional Rendering', () => { + describe('Creation Mode (no datasetId)', () => { + it('should show AppIcon component', () => { + // Arrange & Act + const { container } = renderStepThree() + + // Assert - AppIcon should be rendered + const appIcon = container.querySelector('span') + expect(appIcon).toBeInTheDocument() + }) + + it('should show Divider component', () => { + // Arrange & Act + const { container } = renderStepThree() + + // Assert - Divider should be rendered (it adds hr with specific classes) + const dividers = container.querySelectorAll('[class*="divider"]') + expect(dividers.length).toBeGreaterThan(0) + }) + + it('should show dataset name input area', () => { + // Arrange + const datasetName = 'Test Dataset Name' + + // Act + renderStepThree({ datasetName }) + + // Assert + expect(screen.getByText(datasetName)).toBeInTheDocument() + }) + }) + + describe('Addition Mode (with datasetId)', () => { + it('should not show AppIcon component', () => { + // Arrange & Act + renderStepThree({ datasetId: 'dataset-123' }) + + // Assert - Creation section should not be rendered + expect(screen.queryByText('datasetCreation.stepThree.label')).not.toBeInTheDocument() + }) + + it('should show addition description with dataset name', () => { + // Arrange & Act + renderStepThree({ + datasetId: 'dataset-123', + datasetName: 'My Dataset', + }) + + // Assert - Description should include dataset name + expect(screen.getByText(/datasetCreation.stepThree.additionP1/)).toBeInTheDocument() + }) + }) + + describe('Mobile vs Desktop', () => { + it('should show side panel on tablet', () => { + // Arrange + mockMediaType = 'tablet' + + // Act + renderStepThree() + + // Assert - Tablet is not mobile, should show side panel + expect(screen.getByText('datasetCreation.stepThree.sideTipTitle')).toBeInTheDocument() + }) + + it('should not show side panel on mobile', () => { + // Arrange + mockMediaType = 'mobile' + + // Act + renderStepThree() + + // Assert + expect(screen.queryByText('datasetCreation.stepThree.sideTipTitle')).not.toBeInTheDocument() + }) + + it('should render EmbeddingProcess on mobile', () => { + // Arrange + mockMediaType = 'mobile' + + // Act + renderStepThree() + + // Assert - Main content should still be rendered + expect(screen.getByTestId('embedding-process')).toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // EmbeddingProcess Integration Tests - Verify correct props are passed + // -------------------------------------------------------------------------- + describe('EmbeddingProcess Integration', () => { + it('should pass correct datasetId to EmbeddingProcess with datasetId prop', () => { + // Arrange & Act + renderStepThree({ datasetId: 'direct-dataset-id' }) + + // Assert + expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('direct-dataset-id') + }) + + it('should pass creationCache dataset id when datasetId prop is undefined', () => { + // Arrange + const creationCache = createMockCreationCache() + creationCache.dataset!.id = 'cache-dataset-id' + + // Act + renderStepThree({ creationCache }) + + // Assert + expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('cache-dataset-id') + }) + + it('should pass empty string for datasetId when both sources are undefined', () => { + // Arrange & Act + renderStepThree() + + // Assert + expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('') + }) + + it('should pass batchId from creationCache', () => { + // Arrange + const creationCache = createMockCreationCache() + creationCache.batch = 'test-batch-456' + + // Act + renderStepThree({ creationCache }) + + // Assert + expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('test-batch-456') + }) + + it('should pass empty string for batchId when creationCache is undefined', () => { + // Arrange & Act + renderStepThree() + + // Assert + expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('') + }) + + it('should prefer datasetId prop over creationCache dataset id', () => { + // Arrange + const creationCache = createMockCreationCache() + creationCache.dataset!.id = 'cache-id' + + // Act + renderStepThree({ datasetId: 'prop-id', creationCache }) + + // Assert - datasetId prop takes precedence + expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('prop-id') + }) + }) + + // -------------------------------------------------------------------------- + // Icon Rendering Tests - Verify AppIcon behavior + // -------------------------------------------------------------------------- + describe('Icon Rendering', () => { + it('should use default icon info when creationCache is undefined', () => { + // Arrange & Act + const { container } = renderStepThree() + + // Assert - Default background color should be applied + const appIcon = container.querySelector('span[style*="background"]') + if (appIcon) + expect(appIcon).toHaveStyle({ background: '#FFF4ED' }) + }) + + it('should use icon_info from creationCache when available', () => { + // Arrange + const creationCache = createMockCreationCache() + creationCache.dataset!.icon_info = { + icon: '🎉', + icon_type: 'emoji', + icon_background: '#00FF00', + icon_url: '', + } + + // Act + const { container } = renderStepThree({ creationCache }) + + // Assert - Custom background color should be applied + const appIcon = container.querySelector('span[style*="background"]') + if (appIcon) + expect(appIcon).toHaveStyle({ background: '#00FF00' }) + }) + + it('should use default icon when creationCache dataset icon_info is undefined', () => { + // Arrange + const creationCache = createMockCreationCache() + delete (creationCache.dataset as any).icon_info + + // Act + const { container } = renderStepThree({ creationCache }) + + // Assert - Component should still render with default icon + expect(container.firstChild).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Layout Tests - Verify correct CSS classes and structure + // -------------------------------------------------------------------------- + describe('Layout', () => { + it('should have correct outer container classes', () => { + // Arrange & Act + const { container } = renderStepThree() + + // Assert + const outerDiv = container.firstChild as HTMLElement + expect(outerDiv).toHaveClass('flex') + expect(outerDiv).toHaveClass('h-full') + expect(outerDiv).toHaveClass('justify-center') + }) + + it('should have correct inner container classes', () => { + // Arrange & Act + const { container } = renderStepThree() + + // Assert + const innerDiv = container.querySelector('.max-w-\\[960px\\]') + expect(innerDiv).toBeInTheDocument() + expect(innerDiv).toHaveClass('shrink-0', 'grow') + }) + + it('should have content wrapper with correct max width', () => { + // Arrange & Act + const { container } = renderStepThree() + + // Assert + const contentWrapper = container.querySelector('.max-w-\\[640px\\]') + expect(contentWrapper).toBeInTheDocument() + }) + + it('should have side tip panel with correct width on desktop', () => { + // Arrange + mockMediaType = 'pc' + + // Act + const { container } = renderStepThree() + + // Assert + const sidePanel = container.querySelector('.w-\\[328px\\]') + expect(sidePanel).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Accessibility Tests - Verify accessibility features + // -------------------------------------------------------------------------- + describe('Accessibility', () => { + it('should have correct link attributes for external documentation link', () => { + // Arrange + mockMediaType = 'pc' + + // Act + renderStepThree() + + // Assert + const link = screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore') + expect(link.tagName).toBe('A') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noreferrer noopener') + }) + + it('should have semantic heading structure in creation mode', () => { + // Arrange & Act + renderStepThree() + + // Assert + const title = screen.getByText('datasetCreation.stepThree.creationTitle') + expect(title).toBeInTheDocument() + expect(title.className).toContain('title-2xl-semi-bold') + }) + + it('should have semantic heading structure in addition mode', () => { + // Arrange & Act + renderStepThree({ datasetId: 'dataset-123' }) + + // Assert + const title = screen.getByText('datasetCreation.stepThree.additionTitle') + expect(title).toBeInTheDocument() + expect(title.className).toContain('title-2xl-semi-bold') + }) + }) + + // -------------------------------------------------------------------------- + // Side Panel Tests - Verify side panel behavior + // -------------------------------------------------------------------------- + describe('Side Panel', () => { + it('should render RiBookOpenLine icon in side panel', () => { + // Arrange + mockMediaType = 'pc' + + // Act + const { container } = renderStepThree() + + // Assert - Icon should be present in side panel + const iconContainer = container.querySelector('.size-10') + expect(iconContainer).toBeInTheDocument() + }) + + it('should have correct side panel section background', () => { + // Arrange + mockMediaType = 'pc' + + // Act + const { container } = renderStepThree() + + // Assert + const sidePanel = container.querySelector('.bg-background-section') + expect(sidePanel).toBeInTheDocument() + }) + + it('should have correct padding for side panel', () => { + // Arrange + mockMediaType = 'pc' + + // Act + const { container } = renderStepThree() + + // Assert + const sidePanelWrapper = container.querySelector('.pr-8') + expect(sidePanelWrapper).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/create/stepper/index.spec.tsx b/web/app/components/datasets/create/stepper/index.spec.tsx new file mode 100644 index 0000000000..174c2d3472 --- /dev/null +++ b/web/app/components/datasets/create/stepper/index.spec.tsx @@ -0,0 +1,735 @@ +import { render, screen } from '@testing-library/react' +import { Stepper, type StepperProps } from './index' +import { type Step, StepperStep, type StepperStepProps } from './step' + +// Test data factory for creating steps +const createStep = (overrides: Partial = {}): Step => ({ + name: 'Test Step', + ...overrides, +}) + +const createSteps = (count: number, namePrefix = 'Step'): Step[] => + Array.from({ length: count }, (_, i) => createStep({ name: `${namePrefix} ${i + 1}` })) + +// Helper to render Stepper with default props +const renderStepper = (props: Partial = {}) => { + const defaultProps: StepperProps = { + steps: createSteps(3), + activeIndex: 0, + ...props, + } + return render() +} + +// Helper to render StepperStep with default props +const renderStepperStep = (props: Partial = {}) => { + const defaultProps: StepperStepProps = { + name: 'Test Step', + index: 0, + activeIndex: 0, + ...props, + } + return render() +} + +// ============================================================================ +// Stepper Component Tests +// ============================================================================ +describe('Stepper', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + // -------------------------------------------------------------------------- + // Rendering Tests - Verify component renders properly with various inputs + // -------------------------------------------------------------------------- + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + renderStepper() + + // Assert + expect(screen.getByText('Step 1')).toBeInTheDocument() + }) + + it('should render all step names', () => { + // Arrange + const steps = createSteps(3, 'Custom Step') + + // Act + renderStepper({ steps }) + + // Assert + expect(screen.getByText('Custom Step 1')).toBeInTheDocument() + expect(screen.getByText('Custom Step 2')).toBeInTheDocument() + expect(screen.getByText('Custom Step 3')).toBeInTheDocument() + }) + + it('should render dividers between steps', () => { + // Arrange + const steps = createSteps(3) + + // Act + const { container } = renderStepper({ steps }) + + // Assert - Should have 2 dividers for 3 steps + const dividers = container.querySelectorAll('.bg-divider-deep') + expect(dividers.length).toBe(2) + }) + + it('should not render divider after last step', () => { + // Arrange + const steps = createSteps(2) + + // Act + const { container } = renderStepper({ steps }) + + // Assert - Should have 1 divider for 2 steps + const dividers = container.querySelectorAll('.bg-divider-deep') + expect(dividers.length).toBe(1) + }) + + it('should render with flex container layout', () => { + // Arrange & Act + const { container } = renderStepper() + + // Assert + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('flex', 'items-center', 'gap-3') + }) + }) + + // -------------------------------------------------------------------------- + // Props Testing - Test all prop variations and combinations + // -------------------------------------------------------------------------- + describe('Props', () => { + describe('steps prop', () => { + it('should render correct number of steps', () => { + // Arrange + const steps = createSteps(5) + + // Act + renderStepper({ steps }) + + // Assert + expect(screen.getByText('Step 1')).toBeInTheDocument() + expect(screen.getByText('Step 2')).toBeInTheDocument() + expect(screen.getByText('Step 3')).toBeInTheDocument() + expect(screen.getByText('Step 4')).toBeInTheDocument() + expect(screen.getByText('Step 5')).toBeInTheDocument() + }) + + it('should handle single step correctly', () => { + // Arrange + const steps = [createStep({ name: 'Only Step' })] + + // Act + const { container } = renderStepper({ steps, activeIndex: 0 }) + + // Assert + expect(screen.getByText('Only Step')).toBeInTheDocument() + // No dividers for single step + const dividers = container.querySelectorAll('.bg-divider-deep') + expect(dividers.length).toBe(0) + }) + + it('should handle steps with long names', () => { + // Arrange + const longName = 'This is a very long step name that might overflow' + const steps = [createStep({ name: longName })] + + // Act + renderStepper({ steps, activeIndex: 0 }) + + // Assert + expect(screen.getByText(longName)).toBeInTheDocument() + }) + + it('should handle steps with special characters', () => { + // Arrange + const steps = [ + createStep({ name: 'Step & Configuration' }), + createStep({ name: 'Step ' }), + createStep({ name: 'Step "Complete"' }), + ] + + // Act + renderStepper({ steps, activeIndex: 0 }) + + // Assert + expect(screen.getByText('Step & Configuration')).toBeInTheDocument() + expect(screen.getByText('Step ')).toBeInTheDocument() + expect(screen.getByText('Step "Complete"')).toBeInTheDocument() + }) + }) + + describe('activeIndex prop', () => { + it('should highlight first step when activeIndex is 0', () => { + // Arrange & Act + renderStepper({ activeIndex: 0 }) + + // Assert - First step should show "STEP 1" label + expect(screen.getByText('STEP 1')).toBeInTheDocument() + }) + + it('should highlight second step when activeIndex is 1', () => { + // Arrange & Act + renderStepper({ activeIndex: 1 }) + + // Assert - Second step should show "STEP 2" label + expect(screen.getByText('STEP 2')).toBeInTheDocument() + }) + + it('should highlight last step when activeIndex equals steps length - 1', () => { + // Arrange + const steps = createSteps(3) + + // Act + renderStepper({ steps, activeIndex: 2 }) + + // Assert - Third step should show "STEP 3" label + expect(screen.getByText('STEP 3')).toBeInTheDocument() + }) + + it('should show completed steps with number only (no STEP prefix)', () => { + // Arrange + const steps = createSteps(3) + + // Act + renderStepper({ steps, activeIndex: 2 }) + + // Assert - Completed steps show just the number + expect(screen.getByText('1')).toBeInTheDocument() + expect(screen.getByText('2')).toBeInTheDocument() + expect(screen.getByText('STEP 3')).toBeInTheDocument() + }) + + it('should show disabled steps with number only (no STEP prefix)', () => { + // Arrange + const steps = createSteps(3) + + // Act + renderStepper({ steps, activeIndex: 0 }) + + // Assert - Disabled steps show just the number + expect(screen.getByText('STEP 1')).toBeInTheDocument() + expect(screen.getByText('2')).toBeInTheDocument() + expect(screen.getByText('3')).toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // Edge Cases - Test boundary conditions and unexpected inputs + // -------------------------------------------------------------------------- + describe('Edge Cases', () => { + it('should handle empty steps array', () => { + // Arrange & Act + const { container } = renderStepper({ steps: [] }) + + // Assert - Container should render but be empty + expect(container.firstChild).toBeInTheDocument() + expect(container.firstChild?.childNodes.length).toBe(0) + }) + + it('should handle activeIndex greater than steps length', () => { + // Arrange + const steps = createSteps(2) + + // Act - activeIndex 5 is beyond array bounds + renderStepper({ steps, activeIndex: 5 }) + + // Assert - All steps should render as completed (since activeIndex > all indices) + expect(screen.getByText('1')).toBeInTheDocument() + expect(screen.getByText('2')).toBeInTheDocument() + }) + + it('should handle negative activeIndex', () => { + // Arrange + const steps = createSteps(2) + + // Act - negative activeIndex + renderStepper({ steps, activeIndex: -1 }) + + // Assert - All steps should render as disabled (since activeIndex < all indices) + expect(screen.getByText('1')).toBeInTheDocument() + expect(screen.getByText('2')).toBeInTheDocument() + }) + + it('should handle large number of steps', () => { + // Arrange + const steps = createSteps(10) + + // Act + const { container } = renderStepper({ steps, activeIndex: 5 }) + + // Assert + expect(screen.getByText('STEP 6')).toBeInTheDocument() + // Should have 9 dividers for 10 steps + const dividers = container.querySelectorAll('.bg-divider-deep') + expect(dividers.length).toBe(9) + }) + + it('should handle steps with empty name', () => { + // Arrange + const steps = [createStep({ name: '' })] + + // Act + const { container } = renderStepper({ steps, activeIndex: 0 }) + + // Assert - Should still render the step structure + expect(screen.getByText('STEP 1')).toBeInTheDocument() + expect(container.firstChild).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Integration - Test step state combinations + // -------------------------------------------------------------------------- + describe('Step States', () => { + it('should render mixed states: completed, active, disabled', () => { + // Arrange + const steps = createSteps(5) + + // Act + renderStepper({ steps, activeIndex: 2 }) + + // Assert + // Steps 1-2 are completed (show number only) + expect(screen.getByText('1')).toBeInTheDocument() + expect(screen.getByText('2')).toBeInTheDocument() + // Step 3 is active (shows STEP prefix) + expect(screen.getByText('STEP 3')).toBeInTheDocument() + // Steps 4-5 are disabled (show number only) + expect(screen.getByText('4')).toBeInTheDocument() + expect(screen.getByText('5')).toBeInTheDocument() + }) + + it('should transition through all states correctly', () => { + // Arrange + const steps = createSteps(3) + + // Act & Assert - Step 1 active + const { rerender } = render() + expect(screen.getByText('STEP 1')).toBeInTheDocument() + + // Step 2 active + rerender() + expect(screen.getByText('1')).toBeInTheDocument() + expect(screen.getByText('STEP 2')).toBeInTheDocument() + + // Step 3 active + rerender() + expect(screen.getByText('1')).toBeInTheDocument() + expect(screen.getByText('2')).toBeInTheDocument() + expect(screen.getByText('STEP 3')).toBeInTheDocument() + }) + }) +}) + +// ============================================================================ +// StepperStep Component Tests +// ============================================================================ +describe('StepperStep', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + // -------------------------------------------------------------------------- + // Rendering Tests + // -------------------------------------------------------------------------- + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + renderStepperStep() + + // Assert + expect(screen.getByText('Test Step')).toBeInTheDocument() + }) + + it('should render step name', () => { + // Arrange & Act + renderStepperStep({ name: 'Configure Dataset' }) + + // Assert + expect(screen.getByText('Configure Dataset')).toBeInTheDocument() + }) + + it('should render with flex container layout', () => { + // Arrange & Act + const { container } = renderStepperStep() + + // Assert + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('flex', 'items-center', 'gap-2') + }) + }) + + // -------------------------------------------------------------------------- + // Active State Tests + // -------------------------------------------------------------------------- + describe('Active State', () => { + it('should show STEP prefix when active', () => { + // Arrange & Act + renderStepperStep({ index: 0, activeIndex: 0 }) + + // Assert + expect(screen.getByText('STEP 1')).toBeInTheDocument() + }) + + it('should apply active styles to label container', () => { + // Arrange & Act + const { container } = renderStepperStep({ index: 0, activeIndex: 0 }) + + // Assert + const labelContainer = container.querySelector('.bg-state-accent-solid') + expect(labelContainer).toBeInTheDocument() + expect(labelContainer).toHaveClass('px-2') + }) + + it('should apply active text color to label', () => { + // Arrange & Act + const { container } = renderStepperStep({ index: 0, activeIndex: 0 }) + + // Assert + const label = container.querySelector('.text-text-primary-on-surface') + expect(label).toBeInTheDocument() + }) + + it('should apply accent text color to name when active', () => { + // Arrange & Act + const { container } = renderStepperStep({ index: 0, activeIndex: 0 }) + + // Assert + const nameElement = container.querySelector('.text-text-accent') + expect(nameElement).toBeInTheDocument() + expect(nameElement).toHaveClass('system-xs-semibold-uppercase') + }) + + it('should calculate active correctly for different indices', () => { + // Test index 1 with activeIndex 1 + const { rerender } = render( + , + ) + expect(screen.getByText('STEP 2')).toBeInTheDocument() + + // Test index 5 with activeIndex 5 + rerender() + expect(screen.getByText('STEP 6')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Completed State Tests (index < activeIndex) + // -------------------------------------------------------------------------- + describe('Completed State', () => { + it('should show number only when completed (not active)', () => { + // Arrange & Act + renderStepperStep({ index: 0, activeIndex: 1 }) + + // Assert + expect(screen.getByText('1')).toBeInTheDocument() + expect(screen.queryByText('STEP 1')).not.toBeInTheDocument() + }) + + it('should apply completed styles to label container', () => { + // Arrange & Act + const { container } = renderStepperStep({ index: 0, activeIndex: 1 }) + + // Assert + const labelContainer = container.querySelector('.border-text-quaternary') + expect(labelContainer).toBeInTheDocument() + expect(labelContainer).toHaveClass('w-5') + }) + + it('should apply tertiary text color to label when completed', () => { + // Arrange & Act + const { container } = renderStepperStep({ index: 0, activeIndex: 1 }) + + // Assert + const label = container.querySelector('.text-text-tertiary') + expect(label).toBeInTheDocument() + }) + + it('should apply tertiary text color to name when completed', () => { + // Arrange & Act + const { container } = renderStepperStep({ index: 0, activeIndex: 2 }) + + // Assert + const nameElements = container.querySelectorAll('.text-text-tertiary') + expect(nameElements.length).toBeGreaterThan(0) + }) + }) + + // -------------------------------------------------------------------------- + // Disabled State Tests (index > activeIndex) + // -------------------------------------------------------------------------- + describe('Disabled State', () => { + it('should show number only when disabled', () => { + // Arrange & Act + renderStepperStep({ index: 2, activeIndex: 0 }) + + // Assert + expect(screen.getByText('3')).toBeInTheDocument() + expect(screen.queryByText('STEP 3')).not.toBeInTheDocument() + }) + + it('should apply disabled styles to label container', () => { + // Arrange & Act + const { container } = renderStepperStep({ index: 2, activeIndex: 0 }) + + // Assert + const labelContainer = container.querySelector('.border-divider-deep') + expect(labelContainer).toBeInTheDocument() + expect(labelContainer).toHaveClass('w-5') + }) + + it('should apply quaternary text color to label when disabled', () => { + // Arrange & Act + const { container } = renderStepperStep({ index: 2, activeIndex: 0 }) + + // Assert + const label = container.querySelector('.text-text-quaternary') + expect(label).toBeInTheDocument() + }) + + it('should apply quaternary text color to name when disabled', () => { + // Arrange & Act + const { container } = renderStepperStep({ index: 2, activeIndex: 0 }) + + // Assert + const nameElements = container.querySelectorAll('.text-text-quaternary') + expect(nameElements.length).toBeGreaterThan(0) + }) + }) + + // -------------------------------------------------------------------------- + // Props Testing + // -------------------------------------------------------------------------- + describe('Props', () => { + describe('name prop', () => { + it('should render provided name', () => { + // Arrange & Act + renderStepperStep({ name: 'Custom Name' }) + + // Assert + expect(screen.getByText('Custom Name')).toBeInTheDocument() + }) + + it('should handle empty name', () => { + // Arrange & Act + const { container } = renderStepperStep({ name: '' }) + + // Assert - Label should still render + expect(screen.getByText('STEP 1')).toBeInTheDocument() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle name with whitespace', () => { + // Arrange & Act + renderStepperStep({ name: ' Padded Name ' }) + + // Assert + expect(screen.getByText('Padded Name')).toBeInTheDocument() + }) + }) + + describe('index prop', () => { + it('should display correct 1-based number for index 0', () => { + // Arrange & Act + renderStepperStep({ index: 0, activeIndex: 0 }) + + // Assert + expect(screen.getByText('STEP 1')).toBeInTheDocument() + }) + + it('should display correct 1-based number for index 9', () => { + // Arrange & Act + renderStepperStep({ index: 9, activeIndex: 9 }) + + // Assert + expect(screen.getByText('STEP 10')).toBeInTheDocument() + }) + + it('should handle large index values', () => { + // Arrange & Act + renderStepperStep({ index: 99, activeIndex: 99 }) + + // Assert + expect(screen.getByText('STEP 100')).toBeInTheDocument() + }) + }) + + describe('activeIndex prop', () => { + it('should determine state based on activeIndex comparison', () => { + // Active: index === activeIndex + const { rerender } = render( + , + ) + expect(screen.getByText('STEP 2')).toBeInTheDocument() + + // Completed: index < activeIndex + rerender() + expect(screen.getByText('2')).toBeInTheDocument() + + // Disabled: index > activeIndex + rerender() + expect(screen.getByText('2')).toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // Edge Cases + // -------------------------------------------------------------------------- + describe('Edge Cases', () => { + it('should handle zero index correctly', () => { + // Arrange & Act + renderStepperStep({ index: 0, activeIndex: 0 }) + + // Assert + expect(screen.getByText('STEP 1')).toBeInTheDocument() + }) + + it('should handle negative activeIndex', () => { + // Arrange & Act + renderStepperStep({ index: 0, activeIndex: -1 }) + + // Assert - Step should be disabled (index > activeIndex) + expect(screen.getByText('1')).toBeInTheDocument() + }) + + it('should handle equal boundary (index equals activeIndex)', () => { + // Arrange & Act + renderStepperStep({ index: 5, activeIndex: 5 }) + + // Assert - Should be active + expect(screen.getByText('STEP 6')).toBeInTheDocument() + }) + + it('should handle name with HTML-like content safely', () => { + // Arrange & Act + renderStepperStep({ name: '' }) + + // Assert - Should render as text, not execute + expect(screen.getByText('')).toBeInTheDocument() + }) + + it('should handle name with unicode characters', () => { + // Arrange & Act + renderStepperStep({ name: 'Step 数据 🚀' }) + + // Assert + expect(screen.getByText('Step 数据 🚀')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Style Classes Verification + // -------------------------------------------------------------------------- + describe('Style Classes', () => { + it('should apply correct typography classes to label', () => { + // Arrange & Act + const { container } = renderStepperStep() + + // Assert + const label = container.querySelector('.system-2xs-semibold-uppercase') + expect(label).toBeInTheDocument() + }) + + it('should apply correct typography classes to name', () => { + // Arrange & Act + const { container } = renderStepperStep() + + // Assert + const name = container.querySelector('.system-xs-medium-uppercase') + expect(name).toBeInTheDocument() + }) + + it('should have rounded pill shape for label container', () => { + // Arrange & Act + const { container } = renderStepperStep() + + // Assert + const labelContainer = container.querySelector('.rounded-3xl') + expect(labelContainer).toBeInTheDocument() + }) + + it('should apply h-5 height to label container', () => { + // Arrange & Act + const { container } = renderStepperStep() + + // Assert + const labelContainer = container.querySelector('.h-5') + expect(labelContainer).toBeInTheDocument() + }) + }) +}) + +// ============================================================================ +// Integration Tests - Stepper and StepperStep working together +// ============================================================================ +describe('Stepper Integration', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should pass correct props to each StepperStep', () => { + // Arrange + const steps = [ + createStep({ name: 'First' }), + createStep({ name: 'Second' }), + createStep({ name: 'Third' }), + ] + + // Act + renderStepper({ steps, activeIndex: 1 }) + + // Assert - Each step receives correct index and displays correctly + expect(screen.getByText('1')).toBeInTheDocument() // Completed + expect(screen.getByText('First')).toBeInTheDocument() + expect(screen.getByText('STEP 2')).toBeInTheDocument() // Active + expect(screen.getByText('Second')).toBeInTheDocument() + expect(screen.getByText('3')).toBeInTheDocument() // Disabled + expect(screen.getByText('Third')).toBeInTheDocument() + }) + + it('should maintain correct visual hierarchy across steps', () => { + // Arrange + const steps = createSteps(4) + + // Act + const { container } = renderStepper({ steps, activeIndex: 2 }) + + // Assert - Check visual hierarchy + // Completed steps (0, 1) have border-text-quaternary + const completedLabels = container.querySelectorAll('.border-text-quaternary') + expect(completedLabels.length).toBe(2) + + // Active step has bg-state-accent-solid + const activeLabel = container.querySelector('.bg-state-accent-solid') + expect(activeLabel).toBeInTheDocument() + + // Disabled step (3) has border-divider-deep + const disabledLabels = container.querySelectorAll('.border-divider-deep') + expect(disabledLabels.length).toBe(1) + }) + + it('should render correctly with dynamic step updates', () => { + // Arrange + const initialSteps = createSteps(2) + + // Act + const { rerender } = render() + expect(screen.getByText('Step 1')).toBeInTheDocument() + expect(screen.getByText('Step 2')).toBeInTheDocument() + + // Update with more steps + const updatedSteps = createSteps(4) + rerender() + + // Assert + expect(screen.getByText('STEP 3')).toBeInTheDocument() + expect(screen.getByText('Step 4')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/create/stop-embedding-modal/index.spec.tsx b/web/app/components/datasets/create/stop-embedding-modal/index.spec.tsx new file mode 100644 index 0000000000..244f65ffb0 --- /dev/null +++ b/web/app/components/datasets/create/stop-embedding-modal/index.spec.tsx @@ -0,0 +1,738 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import StopEmbeddingModal from './index' + +// Helper type for component props +type StopEmbeddingModalProps = { + show: boolean + onConfirm: () => void + onHide: () => void +} + +// Helper to render StopEmbeddingModal with default props +const renderStopEmbeddingModal = (props: Partial = {}) => { + const defaultProps: StopEmbeddingModalProps = { + show: true, + onConfirm: jest.fn(), + onHide: jest.fn(), + ...props, + } + return { + ...render(), + props: defaultProps, + } +} + +// ============================================================================ +// StopEmbeddingModal Component Tests +// ============================================================================ +describe('StopEmbeddingModal', () => { + // Suppress Headless UI warnings in tests + // These warnings are from the library's internal behavior, not our code + let consoleWarnSpy: jest.SpyInstance + let consoleErrorSpy: jest.SpyInstance + + beforeAll(() => { + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(jest.fn()) + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn()) + }) + + afterAll(() => { + consoleWarnSpy.mockRestore() + consoleErrorSpy.mockRestore() + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + // -------------------------------------------------------------------------- + // Rendering Tests - Verify component renders properly + // -------------------------------------------------------------------------- + describe('Rendering', () => { + it('should render without crashing when show is true', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert + expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() + }) + + it('should render modal title', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert + expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() + }) + + it('should render modal content', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert + expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeInTheDocument() + }) + + it('should render confirm button with correct text', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert + expect(screen.getByText('datasetCreation.stepThree.modelButtonConfirm')).toBeInTheDocument() + }) + + it('should render cancel button with correct text', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert + expect(screen.getByText('datasetCreation.stepThree.modelButtonCancel')).toBeInTheDocument() + }) + + it('should not render modal content when show is false', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: false }) + + // Assert + expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument() + }) + + it('should render buttons in correct order (cancel first, then confirm)', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert - Due to flex-row-reverse, confirm appears first visually but cancel is first in DOM + const buttons = screen.getAllByRole('button') + expect(buttons).toHaveLength(2) + }) + + it('should render confirm button with primary variant styling', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert + const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') + expect(confirmButton).toHaveClass('ml-2', 'w-24') + }) + + it('should render cancel button with default styling', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert + const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel') + expect(cancelButton).toHaveClass('w-24') + }) + + it('should render all modal elements', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert - Modal should contain title, content, and buttons + expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepThree.modelButtonConfirm')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepThree.modelButtonCancel')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Props Testing - Test all prop variations + // -------------------------------------------------------------------------- + describe('Props', () => { + describe('show prop', () => { + it('should show modal when show is true', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert + expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() + }) + + it('should hide modal when show is false', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: false }) + + // Assert + expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument() + }) + + it('should use default value false when show is not provided', () => { + // Arrange & Act + const onConfirm = jest.fn() + const onHide = jest.fn() + render() + + // Assert + expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument() + }) + + it('should toggle visibility when show prop changes to true', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + + // Act - Initially hidden + const { rerender } = render( + , + ) + expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument() + + // Act - Show modal + await act(async () => { + rerender() + }) + + // Assert - Modal should be visible + await waitFor(() => { + expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() + }) + }) + }) + + describe('onConfirm prop', () => { + it('should accept onConfirm callback function', () => { + // Arrange + const onConfirm = jest.fn() + + // Act + renderStopEmbeddingModal({ onConfirm }) + + // Assert - No errors thrown + expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() + }) + }) + + describe('onHide prop', () => { + it('should accept onHide callback function', () => { + // Arrange + const onHide = jest.fn() + + // Act + renderStopEmbeddingModal({ onHide }) + + // Assert - No errors thrown + expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // User Interactions Tests - Test click events and event handlers + // -------------------------------------------------------------------------- + describe('User Interactions', () => { + describe('Confirm Button', () => { + it('should call onConfirm when confirm button is clicked', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act + const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') + await act(async () => { + fireEvent.click(confirmButton) + }) + + // Assert + expect(onConfirm).toHaveBeenCalledTimes(1) + }) + + it('should call onHide when confirm button is clicked', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act + const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') + await act(async () => { + fireEvent.click(confirmButton) + }) + + // Assert + expect(onHide).toHaveBeenCalledTimes(1) + }) + + it('should call both onConfirm and onHide in correct order when confirm button is clicked', async () => { + // Arrange + const callOrder: string[] = [] + const onConfirm = jest.fn(() => callOrder.push('confirm')) + const onHide = jest.fn(() => callOrder.push('hide')) + renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act + const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') + await act(async () => { + fireEvent.click(confirmButton) + }) + + // Assert - onConfirm should be called before onHide + expect(callOrder).toEqual(['confirm', 'hide']) + }) + + it('should handle multiple clicks on confirm button', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act + const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') + await act(async () => { + fireEvent.click(confirmButton) + fireEvent.click(confirmButton) + fireEvent.click(confirmButton) + }) + + // Assert + expect(onConfirm).toHaveBeenCalledTimes(3) + expect(onHide).toHaveBeenCalledTimes(3) + }) + }) + + describe('Cancel Button', () => { + it('should call onHide when cancel button is clicked', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act + const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel') + await act(async () => { + fireEvent.click(cancelButton) + }) + + // Assert + expect(onHide).toHaveBeenCalledTimes(1) + }) + + it('should not call onConfirm when cancel button is clicked', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act + const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel') + await act(async () => { + fireEvent.click(cancelButton) + }) + + // Assert + expect(onConfirm).not.toHaveBeenCalled() + }) + + it('should handle multiple clicks on cancel button', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act + const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel') + await act(async () => { + fireEvent.click(cancelButton) + fireEvent.click(cancelButton) + }) + + // Assert + expect(onHide).toHaveBeenCalledTimes(2) + expect(onConfirm).not.toHaveBeenCalled() + }) + }) + + describe('Close Icon', () => { + it('should call onHide when close span is clicked', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + const { container } = renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act - Find the close span (it should be the span with onClick handler) + const spans = container.querySelectorAll('span') + const closeSpan = Array.from(spans).find(span => + span.className && span.getAttribute('class')?.includes('close'), + ) + + if (closeSpan) { + await act(async () => { + fireEvent.click(closeSpan) + }) + + // Assert + expect(onHide).toHaveBeenCalledTimes(1) + } + else { + // If no close span found with class, just verify the modal renders + expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() + } + }) + + it('should not call onConfirm when close span is clicked', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + const { container } = renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act + const spans = container.querySelectorAll('span') + const closeSpan = Array.from(spans).find(span => + span.className && span.getAttribute('class')?.includes('close'), + ) + + if (closeSpan) { + await act(async () => { + fireEvent.click(closeSpan) + }) + + // Assert + expect(onConfirm).not.toHaveBeenCalled() + } + }) + }) + + describe('Different Close Methods', () => { + it('should distinguish between confirm and cancel actions', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act - Click cancel + const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel') + await act(async () => { + fireEvent.click(cancelButton) + }) + + // Assert + expect(onConfirm).not.toHaveBeenCalled() + expect(onHide).toHaveBeenCalledTimes(1) + + // Reset + jest.clearAllMocks() + + // Act - Click confirm + const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') + await act(async () => { + fireEvent.click(confirmButton) + }) + + // Assert + expect(onConfirm).toHaveBeenCalledTimes(1) + expect(onHide).toHaveBeenCalledTimes(1) + }) + }) + }) + + // -------------------------------------------------------------------------- + // Edge Cases Tests - Test null, undefined, empty values and boundaries + // -------------------------------------------------------------------------- + describe('Edge Cases', () => { + it('should handle rapid confirm button clicks', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act - Rapid clicks + const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') + await act(async () => { + for (let i = 0; i < 10; i++) + fireEvent.click(confirmButton) + }) + + // Assert + expect(onConfirm).toHaveBeenCalledTimes(10) + expect(onHide).toHaveBeenCalledTimes(10) + }) + + it('should handle rapid cancel button clicks', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act - Rapid clicks + const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel') + await act(async () => { + for (let i = 0; i < 10; i++) + fireEvent.click(cancelButton) + }) + + // Assert + expect(onHide).toHaveBeenCalledTimes(10) + expect(onConfirm).not.toHaveBeenCalled() + }) + + it('should handle callbacks being replaced', async () => { + // Arrange + const onConfirm1 = jest.fn() + const onHide1 = jest.fn() + const onConfirm2 = jest.fn() + const onHide2 = jest.fn() + + // Act + const { rerender } = render( + , + ) + + // Replace callbacks + await act(async () => { + rerender() + }) + + // Click confirm with new callbacks + const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') + await act(async () => { + fireEvent.click(confirmButton) + }) + + // Assert - New callbacks should be called + expect(onConfirm1).not.toHaveBeenCalled() + expect(onHide1).not.toHaveBeenCalled() + expect(onConfirm2).toHaveBeenCalledTimes(1) + expect(onHide2).toHaveBeenCalledTimes(1) + }) + + it('should render with all required props', () => { + // Arrange & Act + render( + , + ) + + // Assert + expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Layout and Styling Tests - Verify correct structure + // -------------------------------------------------------------------------- + describe('Layout and Styling', () => { + it('should have buttons container with flex-row-reverse', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert + const buttons = screen.getAllByRole('button') + expect(buttons[0].closest('div')).toHaveClass('flex', 'flex-row-reverse') + }) + + it('should render title and content elements', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert + expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeInTheDocument() + }) + + it('should render two buttons', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert + const buttons = screen.getAllByRole('button') + expect(buttons).toHaveLength(2) + }) + }) + + // -------------------------------------------------------------------------- + // submit Function Tests - Test the internal submit function behavior + // -------------------------------------------------------------------------- + describe('submit Function', () => { + it('should execute onConfirm first then onHide', async () => { + // Arrange + let confirmTime = 0 + let hideTime = 0 + let counter = 0 + const onConfirm = jest.fn(() => { + confirmTime = ++counter + }) + const onHide = jest.fn(() => { + hideTime = ++counter + }) + renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act + const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') + await act(async () => { + fireEvent.click(confirmButton) + }) + + // Assert + expect(confirmTime).toBe(1) + expect(hideTime).toBe(2) + }) + + it('should call both callbacks exactly once per click', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act + const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') + await act(async () => { + fireEvent.click(confirmButton) + }) + + // Assert + expect(onConfirm).toHaveBeenCalledTimes(1) + expect(onHide).toHaveBeenCalledTimes(1) + }) + + it('should pass no arguments to onConfirm', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act + const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') + await act(async () => { + fireEvent.click(confirmButton) + }) + + // Assert + expect(onConfirm).toHaveBeenCalledWith() + }) + + it('should pass no arguments to onHide when called from submit', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act + const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') + await act(async () => { + fireEvent.click(confirmButton) + }) + + // Assert + expect(onHide).toHaveBeenCalledWith() + }) + }) + + // -------------------------------------------------------------------------- + // Modal Integration Tests - Verify Modal component integration + // -------------------------------------------------------------------------- + describe('Modal Integration', () => { + it('should pass show prop to Modal as isShow', async () => { + // Arrange & Act + const { rerender } = render( + , + ) + + // Assert - Modal should be visible + expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() + + // Act - Hide modal + await act(async () => { + rerender() + }) + + // Assert - Modal should transition to hidden (wait for transition) + await waitFor(() => { + expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument() + }, { timeout: 3000 }) + }) + }) + + // -------------------------------------------------------------------------- + // Accessibility Tests + // -------------------------------------------------------------------------- + describe('Accessibility', () => { + it('should have buttons that are focusable', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert + const buttons = screen.getAllByRole('button') + buttons.forEach((button) => { + expect(button).not.toHaveAttribute('tabindex', '-1') + }) + }) + + it('should have semantic button elements', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert + const buttons = screen.getAllByRole('button') + expect(buttons).toHaveLength(2) + }) + + it('should have accessible text content', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert + expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeVisible() + expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeVisible() + expect(screen.getByText('datasetCreation.stepThree.modelButtonConfirm')).toBeVisible() + expect(screen.getByText('datasetCreation.stepThree.modelButtonCancel')).toBeVisible() + }) + }) + + // -------------------------------------------------------------------------- + // Component Lifecycle Tests + // -------------------------------------------------------------------------- + describe('Component Lifecycle', () => { + it('should unmount cleanly', () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + const { unmount } = renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act & Assert - Should not throw + expect(() => unmount()).not.toThrow() + }) + + it('should not call callbacks after unmount', () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + const { unmount } = renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act + unmount() + + // Assert - No callbacks should be called after unmount + expect(onConfirm).not.toHaveBeenCalled() + expect(onHide).not.toHaveBeenCalled() + }) + + it('should re-render correctly when props update', async () => { + // Arrange + const onConfirm1 = jest.fn() + const onHide1 = jest.fn() + const onConfirm2 = jest.fn() + const onHide2 = jest.fn() + + // Act - Initial render + const { rerender } = render( + , + ) + + // Verify initial render + expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() + + // Update props + await act(async () => { + rerender() + }) + + // Assert - Still renders correctly + expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/create/top-bar/index.spec.tsx b/web/app/components/datasets/create/top-bar/index.spec.tsx new file mode 100644 index 0000000000..92fb97c839 --- /dev/null +++ b/web/app/components/datasets/create/top-bar/index.spec.tsx @@ -0,0 +1,539 @@ +import { render, screen } from '@testing-library/react' +import { TopBar, type TopBarProps } from './index' + +// Mock next/link to capture href values +jest.mock('next/link', () => { + return ({ children, href, replace, className }: { children: React.ReactNode; href: string; replace?: boolean; className?: string }) => ( + + {children} + + ) +}) + +// Helper to render TopBar with default props +const renderTopBar = (props: Partial = {}) => { + const defaultProps: TopBarProps = { + activeIndex: 0, + ...props, + } + return { + ...render(), + props: defaultProps, + } +} + +// ============================================================================ +// TopBar Component Tests +// ============================================================================ +describe('TopBar', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + // -------------------------------------------------------------------------- + // Rendering Tests - Verify component renders properly + // -------------------------------------------------------------------------- + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + renderTopBar() + + // Assert + expect(screen.getByTestId('back-link')).toBeInTheDocument() + }) + + it('should render back link with arrow icon', () => { + // Arrange & Act + const { container } = renderTopBar() + + // Assert + const backLink = screen.getByTestId('back-link') + expect(backLink).toBeInTheDocument() + // Check for the arrow icon (svg element) + const arrowIcon = container.querySelector('svg') + expect(arrowIcon).toBeInTheDocument() + }) + + it('should render fallback route text', () => { + // Arrange & Act + renderTopBar() + + // Assert + expect(screen.getByText('datasetCreation.steps.header.fallbackRoute')).toBeInTheDocument() + }) + + it('should render Stepper component with 3 steps', () => { + // Arrange & Act + renderTopBar({ activeIndex: 0 }) + + // Assert - Check for step translations + expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.steps.two')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.steps.three')).toBeInTheDocument() + }) + + it('should apply default container classes', () => { + // Arrange & Act + const { container } = renderTopBar() + + // Assert + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('relative') + expect(wrapper).toHaveClass('flex') + expect(wrapper).toHaveClass('h-[52px]') + expect(wrapper).toHaveClass('shrink-0') + expect(wrapper).toHaveClass('items-center') + expect(wrapper).toHaveClass('justify-between') + expect(wrapper).toHaveClass('border-b') + expect(wrapper).toHaveClass('border-b-divider-subtle') + }) + }) + + // -------------------------------------------------------------------------- + // Props Testing - Test all prop variations + // -------------------------------------------------------------------------- + describe('Props', () => { + describe('className prop', () => { + it('should apply custom className when provided', () => { + // Arrange & Act + const { container } = renderTopBar({ className: 'custom-class' }) + + // Assert + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('custom-class') + }) + + it('should merge custom className with default classes', () => { + // Arrange & Act + const { container } = renderTopBar({ className: 'my-custom-class another-class' }) + + // Assert + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('relative') + expect(wrapper).toHaveClass('flex') + expect(wrapper).toHaveClass('my-custom-class') + expect(wrapper).toHaveClass('another-class') + }) + + it('should render correctly without className', () => { + // Arrange & Act + const { container } = renderTopBar({ className: undefined }) + + // Assert + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('relative') + expect(wrapper).toHaveClass('flex') + }) + + it('should handle empty string className', () => { + // Arrange & Act + const { container } = renderTopBar({ className: '' }) + + // Assert + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('relative') + }) + }) + + describe('datasetId prop', () => { + it('should set fallback route to /datasets when datasetId is undefined', () => { + // Arrange & Act + renderTopBar({ datasetId: undefined }) + + // Assert + const backLink = screen.getByTestId('back-link') + expect(backLink).toHaveAttribute('href', '/datasets') + }) + + it('should set fallback route to /datasets/:id/documents when datasetId is provided', () => { + // Arrange & Act + renderTopBar({ datasetId: 'dataset-123' }) + + // Assert + const backLink = screen.getByTestId('back-link') + expect(backLink).toHaveAttribute('href', '/datasets/dataset-123/documents') + }) + + it('should handle various datasetId formats', () => { + // Arrange & Act + renderTopBar({ datasetId: 'abc-def-ghi-123' }) + + // Assert + const backLink = screen.getByTestId('back-link') + expect(backLink).toHaveAttribute('href', '/datasets/abc-def-ghi-123/documents') + }) + + it('should handle empty string datasetId', () => { + // Arrange & Act + renderTopBar({ datasetId: '' }) + + // Assert - Empty string is falsy, so fallback to /datasets + const backLink = screen.getByTestId('back-link') + expect(backLink).toHaveAttribute('href', '/datasets') + }) + }) + + describe('activeIndex prop', () => { + it('should pass activeIndex to Stepper component (index 0)', () => { + // Arrange & Act + const { container } = renderTopBar({ activeIndex: 0 }) + + // Assert - First step should be active (has specific styling) + const steps = container.querySelectorAll('[class*="system-2xs-semibold-uppercase"]') + expect(steps.length).toBeGreaterThan(0) + }) + + it('should pass activeIndex to Stepper component (index 1)', () => { + // Arrange & Act + renderTopBar({ activeIndex: 1 }) + + // Assert - Stepper is rendered with correct props + expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.steps.two')).toBeInTheDocument() + }) + + it('should pass activeIndex to Stepper component (index 2)', () => { + // Arrange & Act + renderTopBar({ activeIndex: 2 }) + + // Assert + expect(screen.getByText('datasetCreation.steps.three')).toBeInTheDocument() + }) + + it('should handle edge case activeIndex of -1', () => { + // Arrange & Act + const { container } = renderTopBar({ activeIndex: -1 }) + + // Assert - Component should render without crashing + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle edge case activeIndex beyond steps length', () => { + // Arrange & Act + const { container } = renderTopBar({ activeIndex: 10 }) + + // Assert - Component should render without crashing + expect(container.firstChild).toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // Memoization Tests - Test useMemo logic and dependencies + // -------------------------------------------------------------------------- + describe('Memoization Logic', () => { + it('should compute fallbackRoute based on datasetId', () => { + // Arrange & Act - With datasetId + const { rerender } = render() + + // Assert + expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/test-id/documents') + + // Act - Rerender with different datasetId + rerender() + + // Assert - Route should update + expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/new-id/documents') + }) + + it('should update fallbackRoute when datasetId changes from undefined to defined', () => { + // Arrange + const { rerender } = render() + expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets') + + // Act + rerender() + + // Assert + expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/new-dataset/documents') + }) + + it('should update fallbackRoute when datasetId changes from defined to undefined', () => { + // Arrange + const { rerender } = render() + expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/existing-id/documents') + + // Act + rerender() + + // Assert + expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets') + }) + + it('should not change fallbackRoute when activeIndex changes but datasetId stays same', () => { + // Arrange + const { rerender } = render() + const initialHref = screen.getByTestId('back-link').getAttribute('href') + + // Act + rerender() + + // Assert - href should remain the same + expect(screen.getByTestId('back-link')).toHaveAttribute('href', initialHref) + }) + + it('should not change fallbackRoute when className changes but datasetId stays same', () => { + // Arrange + const { rerender } = render() + const initialHref = screen.getByTestId('back-link').getAttribute('href') + + // Act + rerender() + + // Assert - href should remain the same + expect(screen.getByTestId('back-link')).toHaveAttribute('href', initialHref) + }) + }) + + // -------------------------------------------------------------------------- + // Link Component Tests + // -------------------------------------------------------------------------- + describe('Link Component', () => { + it('should render Link with replace prop', () => { + // Arrange & Act + renderTopBar() + + // Assert + const backLink = screen.getByTestId('back-link') + expect(backLink).toHaveAttribute('data-replace', 'true') + }) + + it('should render Link with correct classes', () => { + // Arrange & Act + renderTopBar() + + // Assert + const backLink = screen.getByTestId('back-link') + expect(backLink).toHaveClass('inline-flex') + expect(backLink).toHaveClass('h-12') + expect(backLink).toHaveClass('items-center') + expect(backLink).toHaveClass('justify-start') + expect(backLink).toHaveClass('gap-1') + expect(backLink).toHaveClass('py-2') + expect(backLink).toHaveClass('pl-2') + expect(backLink).toHaveClass('pr-6') + }) + }) + + // -------------------------------------------------------------------------- + // STEP_T_MAP Tests - Verify step translations + // -------------------------------------------------------------------------- + describe('STEP_T_MAP Translations', () => { + it('should render step one translation', () => { + // Arrange & Act + renderTopBar({ activeIndex: 0 }) + + // Assert + expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument() + }) + + it('should render step two translation', () => { + // Arrange & Act + renderTopBar({ activeIndex: 1 }) + + // Assert + expect(screen.getByText('datasetCreation.steps.two')).toBeInTheDocument() + }) + + it('should render step three translation', () => { + // Arrange & Act + renderTopBar({ activeIndex: 2 }) + + // Assert + expect(screen.getByText('datasetCreation.steps.three')).toBeInTheDocument() + }) + + it('should render all three step translations', () => { + // Arrange & Act + renderTopBar({ activeIndex: 0 }) + + // Assert + expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.steps.two')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.steps.three')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Edge Cases and Error Handling Tests + // -------------------------------------------------------------------------- + describe('Edge Cases', () => { + it('should handle special characters in datasetId', () => { + // Arrange & Act + renderTopBar({ datasetId: 'dataset-with-special_chars.123' }) + + // Assert + const backLink = screen.getByTestId('back-link') + expect(backLink).toHaveAttribute('href', '/datasets/dataset-with-special_chars.123/documents') + }) + + it('should handle very long datasetId', () => { + // Arrange + const longId = 'a'.repeat(100) + + // Act + renderTopBar({ datasetId: longId }) + + // Assert + const backLink = screen.getByTestId('back-link') + expect(backLink).toHaveAttribute('href', `/datasets/${longId}/documents`) + }) + + it('should handle UUID format datasetId', () => { + // Arrange + const uuid = '550e8400-e29b-41d4-a716-446655440000' + + // Act + renderTopBar({ datasetId: uuid }) + + // Assert + const backLink = screen.getByTestId('back-link') + expect(backLink).toHaveAttribute('href', `/datasets/${uuid}/documents`) + }) + + it('should handle whitespace in className', () => { + // Arrange & Act + const { container } = renderTopBar({ className: ' spaced-class ' }) + + // Assert - classNames utility handles whitespace + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toBeInTheDocument() + }) + + it('should render correctly with all props provided', () => { + // Arrange & Act + const { container } = renderTopBar({ + className: 'custom-class', + datasetId: 'full-props-id', + activeIndex: 2, + }) + + // Assert + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('custom-class') + expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/full-props-id/documents') + }) + + it('should render correctly with minimal props (only activeIndex)', () => { + // Arrange & Act + const { container } = renderTopBar({ activeIndex: 0 }) + + // Assert + expect(container.firstChild).toBeInTheDocument() + expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets') + }) + }) + + // -------------------------------------------------------------------------- + // Stepper Integration Tests + // -------------------------------------------------------------------------- + describe('Stepper Integration', () => { + it('should pass steps array with correct structure to Stepper', () => { + // Arrange & Act + renderTopBar({ activeIndex: 0 }) + + // Assert - All step names should be rendered + const stepOne = screen.getByText('datasetCreation.steps.one') + const stepTwo = screen.getByText('datasetCreation.steps.two') + const stepThree = screen.getByText('datasetCreation.steps.three') + + expect(stepOne).toBeInTheDocument() + expect(stepTwo).toBeInTheDocument() + expect(stepThree).toBeInTheDocument() + }) + + it('should render Stepper in centered position', () => { + // Arrange & Act + const { container } = renderTopBar({ activeIndex: 0 }) + + // Assert - Check for centered positioning classes + const centeredContainer = container.querySelector('.absolute.left-1\\/2.top-1\\/2.-translate-x-1\\/2.-translate-y-1\\/2') + expect(centeredContainer).toBeInTheDocument() + }) + + it('should render step dividers between steps', () => { + // Arrange & Act + const { container } = renderTopBar({ activeIndex: 0 }) + + // Assert - Check for dividers (h-px w-4 bg-divider-deep) + const dividers = container.querySelectorAll('.h-px.w-4.bg-divider-deep') + expect(dividers.length).toBe(2) // 2 dividers between 3 steps + }) + }) + + // -------------------------------------------------------------------------- + // Accessibility Tests + // -------------------------------------------------------------------------- + describe('Accessibility', () => { + it('should have accessible back link', () => { + // Arrange & Act + renderTopBar() + + // Assert + const backLink = screen.getByTestId('back-link') + expect(backLink).toBeInTheDocument() + // Link should have visible text + expect(screen.getByText('datasetCreation.steps.header.fallbackRoute')).toBeInTheDocument() + }) + + it('should have visible arrow icon in back link', () => { + // Arrange & Act + const { container } = renderTopBar() + + // Assert - Arrow icon should be visible + const arrowIcon = container.querySelector('svg') + expect(arrowIcon).toBeInTheDocument() + expect(arrowIcon).toHaveClass('text-text-primary') + }) + }) + + // -------------------------------------------------------------------------- + // Re-render Tests + // -------------------------------------------------------------------------- + describe('Re-render Behavior', () => { + it('should update activeIndex on re-render', () => { + // Arrange + const { rerender, container } = render() + + // Initial check + expect(container.firstChild).toBeInTheDocument() + + // Act - Update activeIndex + rerender() + + // Assert - Component should still render + expect(container.firstChild).toBeInTheDocument() + }) + + it('should update className on re-render', () => { + // Arrange + const { rerender, container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('initial-class') + + // Act + rerender() + + // Assert + expect(wrapper).toHaveClass('updated-class') + expect(wrapper).not.toHaveClass('initial-class') + }) + + it('should handle multiple rapid re-renders', () => { + // Arrange + const { rerender, container } = render() + + // Act - Multiple rapid re-renders + rerender() + rerender() + rerender() + rerender() + + // Assert - Component should be stable + expect(container.firstChild).toBeInTheDocument() + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('new-class') + expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/another-id/documents') + }) + }) +}) diff --git a/web/app/components/datasets/create/website/base.spec.tsx b/web/app/components/datasets/create/website/base.spec.tsx new file mode 100644 index 0000000000..426fc259ea --- /dev/null +++ b/web/app/components/datasets/create/website/base.spec.tsx @@ -0,0 +1,555 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import Input from './base/input' +import Header from './base/header' +import CrawledResult from './base/crawled-result' +import CrawledResultItem from './base/crawled-result-item' +import type { CrawlResultItem } from '@/models/datasets' + +// ============================================================================ +// Test Data Factories +// ============================================================================ + +const createCrawlResultItem = (overrides: Partial = {}): CrawlResultItem => ({ + title: 'Test Page Title', + markdown: '# Test Content', + description: 'Test description', + source_url: 'https://example.com/page', + ...overrides, +}) + +// ============================================================================ +// Input Component Tests +// ============================================================================ + +describe('Input', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + const createInputProps = (overrides: Partial[0]> = {}) => ({ + value: '', + onChange: jest.fn(), + ...overrides, + }) + + describe('Rendering', () => { + it('should render text input by default', () => { + const props = createInputProps() + render() + + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() + expect(input).toHaveAttribute('type', 'text') + }) + + it('should render number input when isNumber is true', () => { + const props = createInputProps({ isNumber: true, value: 0 }) + render() + + const input = screen.getByRole('spinbutton') + expect(input).toBeInTheDocument() + expect(input).toHaveAttribute('type', 'number') + expect(input).toHaveAttribute('min', '0') + }) + + it('should render with placeholder', () => { + const props = createInputProps({ placeholder: 'Enter URL' }) + render() + + expect(screen.getByPlaceholderText('Enter URL')).toBeInTheDocument() + }) + + it('should render with initial value', () => { + const props = createInputProps({ value: 'test value' }) + render() + + expect(screen.getByDisplayValue('test value')).toBeInTheDocument() + }) + }) + + describe('Text Input Behavior', () => { + it('should call onChange with string value for text input', async () => { + const onChange = jest.fn() + const props = createInputProps({ onChange }) + + render() + const input = screen.getByRole('textbox') + + await userEvent.type(input, 'hello') + + expect(onChange).toHaveBeenCalledWith('h') + expect(onChange).toHaveBeenCalledWith('e') + expect(onChange).toHaveBeenCalledWith('l') + expect(onChange).toHaveBeenCalledWith('l') + expect(onChange).toHaveBeenCalledWith('o') + }) + }) + + describe('Number Input Behavior', () => { + it('should call onChange with parsed integer for number input', () => { + const onChange = jest.fn() + const props = createInputProps({ isNumber: true, onChange, value: 0 }) + + render() + const input = screen.getByRole('spinbutton') + + fireEvent.change(input, { target: { value: '42' } }) + + expect(onChange).toHaveBeenCalledWith(42) + }) + + it('should call onChange with empty string when input is NaN', () => { + const onChange = jest.fn() + const props = createInputProps({ isNumber: true, onChange, value: 0 }) + + render() + const input = screen.getByRole('spinbutton') + + fireEvent.change(input, { target: { value: 'abc' } }) + + expect(onChange).toHaveBeenCalledWith('') + }) + + it('should call onChange with empty string when input is empty', () => { + const onChange = jest.fn() + const props = createInputProps({ isNumber: true, onChange, value: 5 }) + + render() + const input = screen.getByRole('spinbutton') + + fireEvent.change(input, { target: { value: '' } }) + + expect(onChange).toHaveBeenCalledWith('') + }) + + it('should clamp negative values to MIN_VALUE (0)', () => { + const onChange = jest.fn() + const props = createInputProps({ isNumber: true, onChange, value: 0 }) + + render() + const input = screen.getByRole('spinbutton') + + fireEvent.change(input, { target: { value: '-5' } }) + + expect(onChange).toHaveBeenCalledWith(0) + }) + + it('should handle decimal input by parsing as integer', () => { + const onChange = jest.fn() + const props = createInputProps({ isNumber: true, onChange, value: 0 }) + + render() + const input = screen.getByRole('spinbutton') + + fireEvent.change(input, { target: { value: '3.7' } }) + + expect(onChange).toHaveBeenCalledWith(3) + }) + }) + + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + expect(Input.$$typeof).toBeDefined() + }) + }) +}) + +// ============================================================================ +// Header Component Tests +// ============================================================================ + +describe('Header', () => { + const createHeaderProps = (overrides: Partial[0]> = {}) => ({ + title: 'Test Title', + docTitle: 'Documentation', + docLink: 'https://docs.example.com', + ...overrides, + }) + + describe('Rendering', () => { + it('should render title', () => { + const props = createHeaderProps() + render(
) + + expect(screen.getByText('Test Title')).toBeInTheDocument() + }) + + it('should render doc link', () => { + const props = createHeaderProps() + render(
) + + const link = screen.getByRole('link') + expect(link).toHaveAttribute('href', 'https://docs.example.com') + expect(link).toHaveAttribute('target', '_blank') + }) + + it('should render button text when not in pipeline', () => { + const props = createHeaderProps({ buttonText: 'Configure' }) + render(
) + + expect(screen.getByText('Configure')).toBeInTheDocument() + }) + + it('should not render button text when in pipeline', () => { + const props = createHeaderProps({ isInPipeline: true, buttonText: 'Configure' }) + render(
) + + expect(screen.queryByText('Configure')).not.toBeInTheDocument() + }) + }) + + describe('isInPipeline Prop', () => { + it('should apply pipeline styles when isInPipeline is true', () => { + const props = createHeaderProps({ isInPipeline: true }) + render(
) + + const titleElement = screen.getByText('Test Title') + expect(titleElement).toHaveClass('system-sm-semibold') + }) + + it('should apply default styles when isInPipeline is false', () => { + const props = createHeaderProps({ isInPipeline: false }) + render(
) + + const titleElement = screen.getByText('Test Title') + expect(titleElement).toHaveClass('system-md-semibold') + }) + + it('should apply compact button styles when isInPipeline is true', () => { + const props = createHeaderProps({ isInPipeline: true }) + render(
) + + const button = screen.getByRole('button') + expect(button).toHaveClass('size-6') + expect(button).toHaveClass('px-1') + }) + + it('should apply default button styles when isInPipeline is false', () => { + const props = createHeaderProps({ isInPipeline: false }) + render(
) + + const button = screen.getByRole('button') + expect(button).toHaveClass('gap-x-0.5') + expect(button).toHaveClass('px-1.5') + }) + }) + + describe('User Interactions', () => { + it('should call onClickConfiguration when button is clicked', async () => { + const onClickConfiguration = jest.fn() + const props = createHeaderProps({ onClickConfiguration }) + + render(
) + await userEvent.click(screen.getByRole('button')) + + expect(onClickConfiguration).toHaveBeenCalledTimes(1) + }) + }) + + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + expect(Header.$$typeof).toBeDefined() + }) + }) +}) + +// ============================================================================ +// CrawledResultItem Component Tests +// ============================================================================ + +describe('CrawledResultItem', () => { + const createItemProps = (overrides: Partial[0]> = {}) => ({ + payload: createCrawlResultItem(), + isChecked: false, + isPreview: false, + onCheckChange: jest.fn(), + onPreview: jest.fn(), + testId: 'test-item', + ...overrides, + }) + + describe('Rendering', () => { + it('should render title and source URL', () => { + const props = createItemProps({ + payload: createCrawlResultItem({ + title: 'My Page', + source_url: 'https://mysite.com', + }), + }) + render() + + expect(screen.getByText('My Page')).toBeInTheDocument() + expect(screen.getByText('https://mysite.com')).toBeInTheDocument() + }) + + it('should render checkbox (custom Checkbox component)', () => { + const props = createItemProps() + render() + + // Find checkbox by data-testid + const checkbox = screen.getByTestId('checkbox-test-item') + expect(checkbox).toBeInTheDocument() + }) + + it('should render preview button', () => { + const props = createItemProps() + render() + + expect(screen.getByText('datasetCreation.stepOne.website.preview')).toBeInTheDocument() + }) + }) + + describe('Checkbox Behavior', () => { + it('should call onCheckChange with true when unchecked item is clicked', async () => { + const onCheckChange = jest.fn() + const props = createItemProps({ isChecked: false, onCheckChange }) + + render() + const checkbox = screen.getByTestId('checkbox-test-item') + await userEvent.click(checkbox) + + expect(onCheckChange).toHaveBeenCalledWith(true) + }) + + it('should call onCheckChange with false when checked item is clicked', async () => { + const onCheckChange = jest.fn() + const props = createItemProps({ isChecked: true, onCheckChange }) + + render() + const checkbox = screen.getByTestId('checkbox-test-item') + await userEvent.click(checkbox) + + expect(onCheckChange).toHaveBeenCalledWith(false) + }) + }) + + describe('Preview Behavior', () => { + it('should call onPreview when preview button is clicked', async () => { + const onPreview = jest.fn() + const props = createItemProps({ onPreview }) + + render() + await userEvent.click(screen.getByText('datasetCreation.stepOne.website.preview')) + + expect(onPreview).toHaveBeenCalledTimes(1) + }) + + it('should apply active style when isPreview is true', () => { + const props = createItemProps({ isPreview: true }) + const { container } = render() + + const wrapper = container.firstChild + expect(wrapper).toHaveClass('bg-state-base-active') + }) + + it('should not apply active style when isPreview is false', () => { + const props = createItemProps({ isPreview: false }) + const { container } = render() + + const wrapper = container.firstChild + expect(wrapper).not.toHaveClass('bg-state-base-active') + }) + }) + + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + expect(CrawledResultItem.$$typeof).toBeDefined() + }) + }) +}) + +// ============================================================================ +// CrawledResult Component Tests +// ============================================================================ + +describe('CrawledResult', () => { + const createResultProps = (overrides: Partial[0]> = {}) => ({ + list: [ + createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }), + createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }), + createCrawlResultItem({ source_url: 'https://page3.com', title: 'Page 3' }), + ], + checkedList: [], + onSelectedChange: jest.fn(), + onPreview: jest.fn(), + usedTime: 2.5, + ...overrides, + }) + + // Helper functions to get checkboxes by data-testid + const getSelectAllCheckbox = () => screen.getByTestId('checkbox-select-all') + const getItemCheckbox = (index: number) => screen.getByTestId(`checkbox-item-${index}`) + + describe('Rendering', () => { + it('should render all items in list', () => { + const props = createResultProps() + render() + + expect(screen.getByText('Page 1')).toBeInTheDocument() + expect(screen.getByText('Page 2')).toBeInTheDocument() + expect(screen.getByText('Page 3')).toBeInTheDocument() + }) + + it('should render time info', () => { + const props = createResultProps({ usedTime: 3.456 }) + render() + + // The component uses i18n, so we check for the key pattern + expect(screen.getByText(/scrapTimeInfo/)).toBeInTheDocument() + }) + + it('should render select all checkbox', () => { + const props = createResultProps() + render() + + expect(screen.getByText('datasetCreation.stepOne.website.selectAll')).toBeInTheDocument() + }) + + it('should render reset all when all items are checked', () => { + const list = [ + createCrawlResultItem({ source_url: 'https://page1.com' }), + createCrawlResultItem({ source_url: 'https://page2.com' }), + ] + const props = createResultProps({ list, checkedList: list }) + render() + + expect(screen.getByText('datasetCreation.stepOne.website.resetAll')).toBeInTheDocument() + }) + }) + + describe('Select All / Deselect All', () => { + it('should call onSelectedChange with all items when select all is clicked', async () => { + const onSelectedChange = jest.fn() + const list = [ + createCrawlResultItem({ source_url: 'https://page1.com' }), + createCrawlResultItem({ source_url: 'https://page2.com' }), + ] + const props = createResultProps({ list, checkedList: [], onSelectedChange }) + + render() + await userEvent.click(getSelectAllCheckbox()) + + expect(onSelectedChange).toHaveBeenCalledWith(list) + }) + + it('should call onSelectedChange with empty array when reset all is clicked', async () => { + const onSelectedChange = jest.fn() + const list = [ + createCrawlResultItem({ source_url: 'https://page1.com' }), + createCrawlResultItem({ source_url: 'https://page2.com' }), + ] + const props = createResultProps({ list, checkedList: list, onSelectedChange }) + + render() + await userEvent.click(getSelectAllCheckbox()) + + expect(onSelectedChange).toHaveBeenCalledWith([]) + }) + }) + + describe('Individual Item Selection', () => { + it('should add item to checkedList when unchecked item is checked', async () => { + const onSelectedChange = jest.fn() + const list = [ + createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }), + createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }), + ] + const props = createResultProps({ list, checkedList: [], onSelectedChange }) + + render() + await userEvent.click(getItemCheckbox(0)) + + expect(onSelectedChange).toHaveBeenCalledWith([list[0]]) + }) + + it('should remove item from checkedList when checked item is unchecked', async () => { + const onSelectedChange = jest.fn() + const list = [ + createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }), + createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }), + ] + const props = createResultProps({ list, checkedList: [list[0]], onSelectedChange }) + + render() + await userEvent.click(getItemCheckbox(0)) + + expect(onSelectedChange).toHaveBeenCalledWith([]) + }) + + it('should preserve other checked items when unchecking one item', async () => { + const onSelectedChange = jest.fn() + const list = [ + createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }), + createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }), + createCrawlResultItem({ source_url: 'https://page3.com', title: 'Page 3' }), + ] + const props = createResultProps({ list, checkedList: [list[0], list[1]], onSelectedChange }) + + render() + // Click the first item's checkbox to uncheck it + await userEvent.click(getItemCheckbox(0)) + + expect(onSelectedChange).toHaveBeenCalledWith([list[1]]) + }) + }) + + describe('Preview Behavior', () => { + it('should call onPreview with correct item when preview is clicked', async () => { + const onPreview = jest.fn() + const list = [ + createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }), + createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }), + ] + const props = createResultProps({ list, onPreview }) + + render() + + // Click preview on second item + const previewButtons = screen.getAllByText('datasetCreation.stepOne.website.preview') + await userEvent.click(previewButtons[1]) + + expect(onPreview).toHaveBeenCalledWith(list[1]) + }) + + it('should track preview index correctly', async () => { + const onPreview = jest.fn() + const list = [ + createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }), + createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }), + ] + const props = createResultProps({ list, onPreview }) + + render() + + // Click preview on first item + const previewButtons = screen.getAllByText('datasetCreation.stepOne.website.preview') + await userEvent.click(previewButtons[0]) + + expect(onPreview).toHaveBeenCalledWith(list[0]) + }) + }) + + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + expect(CrawledResult.$$typeof).toBeDefined() + }) + }) + + describe('Edge Cases', () => { + it('should handle empty list', () => { + const props = createResultProps({ list: [], checkedList: [] }) + render() + + // Should still render the header with resetAll (empty list = all checked) + expect(screen.getByText('datasetCreation.stepOne.website.resetAll')).toBeInTheDocument() + }) + + it('should handle className prop', () => { + const props = createResultProps({ className: 'custom-class' }) + const { container } = render() + + expect(container.firstChild).toHaveClass('custom-class') + }) + }) +}) diff --git a/web/app/components/datasets/create/website/base/checkbox-with-label.tsx b/web/app/components/datasets/create/website/base/checkbox-with-label.tsx index f5451af074..d5be00354a 100644 --- a/web/app/components/datasets/create/website/base/checkbox-with-label.tsx +++ b/web/app/components/datasets/create/website/base/checkbox-with-label.tsx @@ -12,6 +12,7 @@ type Props = { label: string labelClassName?: string tooltip?: string + testId?: string } const CheckboxWithLabel: FC = ({ @@ -21,10 +22,11 @@ const CheckboxWithLabel: FC = ({ label, labelClassName, tooltip, + testId, }) => { return (