From 4553e4c12f9852d430259f2a76f3e029e6f44755 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Tue, 16 Dec 2025 14:18:09 +0800 Subject: [PATCH] test: add comprehensive Jest tests for CustomPage and WorkflowOnboardingModal components (#29714) --- .../custom/custom-page/index.spec.tsx | 500 +++++++++++++ .../common/document-picker/index.spec.tsx | 7 - .../preview-document-picker.spec.tsx | 2 +- .../retrieval-method-config/index.spec.tsx | 7 - .../workflow-onboarding-modal/index.spec.tsx | 686 ++++++++++++++++++ .../start-node-option.spec.tsx | 348 +++++++++ .../start-node-selection-panel.spec.tsx | 586 +++++++++++++++ 7 files changed, 2121 insertions(+), 15 deletions(-) create mode 100644 web/app/components/custom/custom-page/index.spec.tsx create mode 100644 web/app/components/workflow-app/components/workflow-onboarding-modal/index.spec.tsx create mode 100644 web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.spec.tsx create mode 100644 web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.spec.tsx diff --git a/web/app/components/custom/custom-page/index.spec.tsx b/web/app/components/custom/custom-page/index.spec.tsx new file mode 100644 index 0000000000..f260236587 --- /dev/null +++ b/web/app/components/custom/custom-page/index.spec.tsx @@ -0,0 +1,500 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import CustomPage from './index' +import { Plan } from '@/app/components/billing/type' +import { createMockProviderContextValue } from '@/__mocks__/provider-context' +import { contactSalesUrl } from '@/app/components/billing/config' + +// Mock external dependencies only +jest.mock('@/context/provider-context', () => ({ + useProviderContext: jest.fn(), +})) + +jest.mock('@/context/modal-context', () => ({ + useModalContext: jest.fn(), +})) + +// Mock the complex CustomWebAppBrand component to avoid dependency issues +// This is acceptable because it has complex dependencies (fetch, APIs) +jest.mock('../custom-web-app-brand', () => ({ + __esModule: true, + default: () =>
CustomWebAppBrand
, +})) + +// Get the mocked functions +const { useProviderContext } = jest.requireMock('@/context/provider-context') +const { useModalContext } = jest.requireMock('@/context/modal-context') + +describe('CustomPage', () => { + const mockSetShowPricingModal = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + + // Default mock setup + useModalContext.mockReturnValue({ + setShowPricingModal: mockSetShowPricingModal, + }) + }) + + // Helper function to render with different provider contexts + const renderWithContext = (overrides = {}) => { + useProviderContext.mockReturnValue( + createMockProviderContextValue(overrides), + ) + return render() + } + + // Rendering tests (REQUIRED) + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + renderWithContext() + + // Assert + expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument() + }) + + it('should always render CustomWebAppBrand component', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.sandbox }, + }) + + // Assert + expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument() + }) + + it('should have correct layout structure', () => { + // Arrange & Act + const { container } = renderWithContext() + + // Assert + const mainContainer = container.querySelector('.flex.flex-col') + expect(mainContainer).toBeInTheDocument() + }) + }) + + // Conditional Rendering - Billing Tip + describe('Billing Tip Banner', () => { + it('should show billing tip when enableBilling is true and plan is sandbox', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.sandbox }, + }) + + // Assert + expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument() + expect(screen.getByText('custom.upgradeTip.des')).toBeInTheDocument() + expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument() + }) + + it('should not show billing tip when enableBilling is false', () => { + // Arrange & Act + renderWithContext({ + enableBilling: false, + plan: { type: Plan.sandbox }, + }) + + // Assert + expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument() + expect(screen.queryByText('custom.upgradeTip.des')).not.toBeInTheDocument() + }) + + it('should not show billing tip when plan is professional', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.professional }, + }) + + // Assert + expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument() + expect(screen.queryByText('custom.upgradeTip.des')).not.toBeInTheDocument() + }) + + it('should not show billing tip when plan is team', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.team }, + }) + + // Assert + expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument() + expect(screen.queryByText('custom.upgradeTip.des')).not.toBeInTheDocument() + }) + + it('should have correct gradient styling for billing tip banner', () => { + // Arrange & Act + const { container } = renderWithContext({ + enableBilling: true, + plan: { type: Plan.sandbox }, + }) + + // Assert + const banner = container.querySelector('.bg-gradient-to-r') + expect(banner).toBeInTheDocument() + expect(banner).toHaveClass('from-components-input-border-active-prompt-1') + expect(banner).toHaveClass('to-components-input-border-active-prompt-2') + expect(banner).toHaveClass('p-4') + expect(banner).toHaveClass('pl-6') + expect(banner).toHaveClass('shadow-lg') + }) + }) + + // Conditional Rendering - Contact Sales + describe('Contact Sales Section', () => { + it('should show contact section when enableBilling is true and plan is professional', () => { + // Arrange & Act + const { container } = renderWithContext({ + enableBilling: true, + plan: { type: Plan.professional }, + }) + + // Assert - Check that contact section exists with all parts + const contactSection = container.querySelector('.absolute.bottom-0') + expect(contactSection).toBeInTheDocument() + expect(contactSection).toHaveTextContent('custom.customize.prefix') + expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument() + expect(contactSection).toHaveTextContent('custom.customize.suffix') + }) + + it('should show contact section when enableBilling is true and plan is team', () => { + // Arrange & Act + const { container } = renderWithContext({ + enableBilling: true, + plan: { type: Plan.team }, + }) + + // Assert - Check that contact section exists with all parts + const contactSection = container.querySelector('.absolute.bottom-0') + expect(contactSection).toBeInTheDocument() + expect(contactSection).toHaveTextContent('custom.customize.prefix') + expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument() + expect(contactSection).toHaveTextContent('custom.customize.suffix') + }) + + it('should not show contact section when enableBilling is false', () => { + // Arrange & Act + renderWithContext({ + enableBilling: false, + plan: { type: Plan.professional }, + }) + + // Assert + expect(screen.queryByText('custom.customize.prefix')).not.toBeInTheDocument() + expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument() + }) + + it('should not show contact section when plan is sandbox', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.sandbox }, + }) + + // Assert + expect(screen.queryByText('custom.customize.prefix')).not.toBeInTheDocument() + expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument() + }) + + it('should render contact link with correct URL', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.professional }, + }) + + // Assert + const link = screen.getByText('custom.customize.contactUs').closest('a') + expect(link).toHaveAttribute('href', contactSalesUrl) + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it('should have correct positioning for contact section', () => { + // Arrange & Act + const { container } = renderWithContext({ + enableBilling: true, + plan: { type: Plan.professional }, + }) + + // Assert + const contactSection = container.querySelector('.absolute.bottom-0') + expect(contactSection).toBeInTheDocument() + expect(contactSection).toHaveClass('h-[50px]') + expect(contactSection).toHaveClass('text-xs') + expect(contactSection).toHaveClass('leading-[50px]') + }) + }) + + // User Interactions + describe('User Interactions', () => { + it('should call setShowPricingModal when upgrade button is clicked', async () => { + // Arrange + const user = userEvent.setup() + renderWithContext({ + enableBilling: true, + plan: { type: Plan.sandbox }, + }) + + // Act + const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort') + await user.click(upgradeButton) + + // Assert + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) + }) + + it('should call setShowPricingModal without arguments', async () => { + // Arrange + const user = userEvent.setup() + renderWithContext({ + enableBilling: true, + plan: { type: Plan.sandbox }, + }) + + // Act + const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort') + await user.click(upgradeButton) + + // Assert + expect(mockSetShowPricingModal).toHaveBeenCalledWith() + }) + + it('should handle multiple clicks on upgrade button', async () => { + // Arrange + const user = userEvent.setup() + renderWithContext({ + enableBilling: true, + plan: { type: Plan.sandbox }, + }) + + // Act + const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort') + await user.click(upgradeButton) + await user.click(upgradeButton) + await user.click(upgradeButton) + + // Assert + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(3) + }) + + it('should have correct button styling for upgrade button', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.sandbox }, + }) + + // Assert + const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort') + expect(upgradeButton).toHaveClass('cursor-pointer') + expect(upgradeButton).toHaveClass('bg-white') + expect(upgradeButton).toHaveClass('text-text-accent') + expect(upgradeButton).toHaveClass('rounded-3xl') + }) + }) + + // Edge Cases (REQUIRED) + describe('Edge Cases', () => { + it('should handle undefined plan type gracefully', () => { + // Arrange & Act + expect(() => { + renderWithContext({ + enableBilling: true, + plan: { type: undefined }, + }) + }).not.toThrow() + + // Assert + expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument() + }) + + it('should handle plan without type property', () => { + // Arrange & Act + expect(() => { + renderWithContext({ + enableBilling: true, + plan: { type: null }, + }) + }).not.toThrow() + + // Assert + expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument() + }) + + it('should not show any banners when both conditions are false', () => { + // Arrange & Act + renderWithContext({ + enableBilling: false, + plan: { type: Plan.sandbox }, + }) + + // Assert + expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument() + expect(screen.queryByText('custom.customize.prefix')).not.toBeInTheDocument() + }) + + it('should handle enableBilling undefined', () => { + // Arrange & Act + expect(() => { + renderWithContext({ + enableBilling: undefined, + plan: { type: Plan.sandbox }, + }) + }).not.toThrow() + + // Assert + expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument() + }) + + it('should show only billing tip for sandbox plan, not contact section', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.sandbox }, + }) + + // Assert + expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument() + expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument() + }) + + it('should show only contact section for professional plan, not billing tip', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.professional }, + }) + + // Assert + expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument() + expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument() + }) + + it('should show only contact section for team plan, not billing tip', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.team }, + }) + + // Assert + expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument() + expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument() + }) + + it('should handle empty plan object', () => { + // Arrange & Act + expect(() => { + renderWithContext({ + enableBilling: true, + plan: {}, + }) + }).not.toThrow() + + // Assert + expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument() + }) + }) + + // Accessibility Tests + describe('Accessibility', () => { + it('should have clickable upgrade button', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.sandbox }, + }) + + // Assert + const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort') + expect(upgradeButton).toBeInTheDocument() + expect(upgradeButton).toHaveClass('cursor-pointer') + }) + + it('should have proper external link attributes on contact link', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.professional }, + }) + + // Assert + const link = screen.getByText('custom.customize.contactUs').closest('a') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + expect(link).toHaveAttribute('target', '_blank') + }) + + it('should have proper text hierarchy in billing tip', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.sandbox }, + }) + + // Assert + const title = screen.getByText('custom.upgradeTip.title') + const description = screen.getByText('custom.upgradeTip.des') + + expect(title).toHaveClass('title-xl-semi-bold') + expect(description).toHaveClass('system-sm-regular') + }) + + it('should use semantic color classes', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.sandbox }, + }) + + // Assert - Check that the billing tip has text content (which implies semantic colors) + expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument() + }) + }) + + // Integration Tests + describe('Integration', () => { + it('should render both CustomWebAppBrand and billing tip together', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.sandbox }, + }) + + // Assert + expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument() + expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument() + }) + + it('should render both CustomWebAppBrand and contact section together', () => { + // Arrange & Act + renderWithContext({ + enableBilling: true, + plan: { type: Plan.professional }, + }) + + // Assert + expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument() + expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument() + }) + + it('should render only CustomWebAppBrand when no billing conditions met', () => { + // Arrange & Act + renderWithContext({ + enableBilling: false, + plan: { type: Plan.sandbox }, + }) + + // Assert + expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument() + expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument() + expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/common/document-picker/index.spec.tsx b/web/app/components/datasets/common/document-picker/index.spec.tsx index 3caa3d655b..0ce4d8afa5 100644 --- a/web/app/components/datasets/common/document-picker/index.spec.tsx +++ b/web/app/components/datasets/common/document-picker/index.spec.tsx @@ -5,13 +5,6 @@ import type { ParentMode, SimpleDocumentDetail } from '@/models/datasets' import { ChunkingMode, DataSourceType } from '@/models/datasets' import DocumentPicker from './index' -// Mock react-i18next -jest.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - // Mock portal-to-follow-elem - always render content for testing jest.mock('@/app/components/base/portal-to-follow-elem', () => ({ PortalToFollowElem: ({ children, open }: { diff --git a/web/app/components/datasets/common/document-picker/preview-document-picker.spec.tsx b/web/app/components/datasets/common/document-picker/preview-document-picker.spec.tsx index e6900d23db..737ef8b6dc 100644 --- a/web/app/components/datasets/common/document-picker/preview-document-picker.spec.tsx +++ b/web/app/components/datasets/common/document-picker/preview-document-picker.spec.tsx @@ -3,7 +3,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import type { DocumentItem } from '@/models/datasets' import PreviewDocumentPicker from './preview-document-picker' -// Mock react-i18next +// Override shared i18n mock for custom translations jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, params?: Record) => { diff --git a/web/app/components/datasets/common/retrieval-method-config/index.spec.tsx b/web/app/components/datasets/common/retrieval-method-config/index.spec.tsx index be509f1c6e..7d5edb3dbb 100644 --- a/web/app/components/datasets/common/retrieval-method-config/index.spec.tsx +++ b/web/app/components/datasets/common/retrieval-method-config/index.spec.tsx @@ -9,13 +9,6 @@ import { } from '@/models/datasets' import RetrievalMethodConfig from './index' -// Mock react-i18next -jest.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - // Mock provider context with controllable supportRetrievalMethods let mockSupportRetrievalMethods: RETRIEVE_METHOD[] = [ RETRIEVE_METHOD.semantic, diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/index.spec.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/index.spec.tsx new file mode 100644 index 0000000000..81d7dc8af6 --- /dev/null +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/index.spec.tsx @@ -0,0 +1,686 @@ +import React from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import WorkflowOnboardingModal from './index' +import { BlockEnum } from '@/app/components/workflow/types' + +// Mock Modal component +jest.mock('@/app/components/base/modal', () => { + return function MockModal({ + isShow, + onClose, + children, + closable, + }: any) { + if (!isShow) + return null + + return ( +
+ {closable && ( + + )} + {children} +
+ ) + } +}) + +// Mock useDocLink hook +jest.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs.example.com${path}`, +})) + +// Mock StartNodeSelectionPanel (using real component would be better for integration, +// but for this test we'll mock to control behavior) +jest.mock('./start-node-selection-panel', () => { + return function MockStartNodeSelectionPanel({ + onSelectUserInput, + onSelectTrigger, + }: any) { + return ( +
+ + + +
+ ) + } +}) + +describe('WorkflowOnboardingModal', () => { + const mockOnClose = jest.fn() + const mockOnSelectStartNode = jest.fn() + + const defaultProps = { + isShow: true, + onClose: mockOnClose, + onSelectStartNode: mockOnSelectStartNode, + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + // Helper function to render component + const renderComponent = (props = {}) => { + return render() + } + + // Rendering tests (REQUIRED) + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('should render modal when isShow is true', () => { + // Arrange & Act + renderComponent({ isShow: true }) + + // Assert + expect(screen.getByTestId('modal')).toBeInTheDocument() + }) + + it('should not render modal when isShow is false', () => { + // Arrange & Act + renderComponent({ isShow: false }) + + // Assert + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + }) + + it('should render modal title', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByText('workflow.onboarding.title')).toBeInTheDocument() + }) + + it('should render modal description', () => { + // Arrange & Act + const { container } = renderComponent() + + // Assert - Check both parts of description (separated by link) + const descriptionDiv = container.querySelector('.body-xs-regular.leading-4') + expect(descriptionDiv).toBeInTheDocument() + expect(descriptionDiv).toHaveTextContent('workflow.onboarding.description') + expect(descriptionDiv).toHaveTextContent('workflow.onboarding.aboutStartNode') + }) + + it('should render learn more link', () => { + // Arrange & Act + renderComponent() + + // Assert + const learnMoreLink = screen.getByText('workflow.onboarding.learnMore') + expect(learnMoreLink).toBeInTheDocument() + expect(learnMoreLink.closest('a')).toHaveAttribute('href', 'https://docs.example.com/guides/workflow/node/start') + expect(learnMoreLink.closest('a')).toHaveAttribute('target', '_blank') + expect(learnMoreLink.closest('a')).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it('should render StartNodeSelectionPanel', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByTestId('start-node-selection-panel')).toBeInTheDocument() + }) + + it('should render ESC tip when modal is shown', () => { + // Arrange & Act + renderComponent({ isShow: true }) + + // Assert + expect(screen.getByText('workflow.onboarding.escTip.press')).toBeInTheDocument() + expect(screen.getByText('workflow.onboarding.escTip.key')).toBeInTheDocument() + expect(screen.getByText('workflow.onboarding.escTip.toDismiss')).toBeInTheDocument() + }) + + it('should not render ESC tip when modal is hidden', () => { + // Arrange & Act + renderComponent({ isShow: false }) + + // Assert + expect(screen.queryByText('workflow.onboarding.escTip.press')).not.toBeInTheDocument() + }) + + it('should have correct styling for title', () => { + // Arrange & Act + renderComponent() + + // Assert + const title = screen.getByText('workflow.onboarding.title') + expect(title).toHaveClass('title-2xl-semi-bold') + expect(title).toHaveClass('text-text-primary') + }) + + it('should have modal close button', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByTestId('modal-close-button')).toBeInTheDocument() + }) + }) + + // Props tests (REQUIRED) + describe('Props', () => { + it('should accept isShow prop', () => { + // Arrange & Act + const { rerender } = renderComponent({ isShow: false }) + + // Assert + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + + // Act + rerender() + + // Assert + expect(screen.getByTestId('modal')).toBeInTheDocument() + }) + + it('should accept onClose prop', () => { + // Arrange + const customOnClose = jest.fn() + + // Act + renderComponent({ onClose: customOnClose }) + + // Assert + expect(screen.getByTestId('modal')).toBeInTheDocument() + }) + + it('should accept onSelectStartNode prop', () => { + // Arrange + const customHandler = jest.fn() + + // Act + renderComponent({ onSelectStartNode: customHandler }) + + // Assert + expect(screen.getByTestId('start-node-selection-panel')).toBeInTheDocument() + }) + + it('should handle undefined onClose gracefully', () => { + // Arrange & Act + expect(() => { + renderComponent({ onClose: undefined }) + }).not.toThrow() + + // Assert + expect(screen.getByTestId('modal')).toBeInTheDocument() + }) + + it('should handle undefined onSelectStartNode gracefully', () => { + // Arrange & Act + expect(() => { + renderComponent({ onSelectStartNode: undefined }) + }).not.toThrow() + + // Assert + expect(screen.getByTestId('modal')).toBeInTheDocument() + }) + }) + + // User Interactions - Start Node Selection + describe('User Interactions - Start Node Selection', () => { + it('should call onSelectStartNode with Start block when user input is selected', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const userInputButton = screen.getByTestId('select-user-input') + await user.click(userInputButton) + + // Assert + expect(mockOnSelectStartNode).toHaveBeenCalledTimes(1) + expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.Start) + }) + + it('should call onClose after selecting user input', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const userInputButton = screen.getByTestId('select-user-input') + await user.click(userInputButton) + + // Assert + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('should call onSelectStartNode with trigger type when trigger is selected', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const triggerButton = screen.getByTestId('select-trigger-schedule') + await user.click(triggerButton) + + // Assert + expect(mockOnSelectStartNode).toHaveBeenCalledTimes(1) + expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.TriggerSchedule, undefined) + }) + + it('should call onClose after selecting trigger', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const triggerButton = screen.getByTestId('select-trigger-schedule') + await user.click(triggerButton) + + // Assert + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('should pass tool config when selecting trigger with config', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const webhookButton = screen.getByTestId('select-trigger-webhook') + await user.click(webhookButton) + + // Assert + expect(mockOnSelectStartNode).toHaveBeenCalledTimes(1) + expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.TriggerWebhook, { config: 'test' }) + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + }) + + // User Interactions - Modal Close + describe('User Interactions - Modal Close', () => { + it('should call onClose when close button is clicked', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const closeButton = screen.getByTestId('modal-close-button') + await user.click(closeButton) + + // Assert + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('should not call onSelectStartNode when closing without selection', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const closeButton = screen.getByTestId('modal-close-button') + await user.click(closeButton) + + // Assert + expect(mockOnSelectStartNode).not.toHaveBeenCalled() + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + }) + + // Keyboard Event Handling + describe('Keyboard Event Handling', () => { + it('should call onClose when ESC key is pressed', () => { + // Arrange + renderComponent({ isShow: true }) + + // Act + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + // Assert + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('should not call onClose when other keys are pressed', () => { + // Arrange + renderComponent({ isShow: true }) + + // Act + fireEvent.keyDown(document, { key: 'Enter', code: 'Enter' }) + fireEvent.keyDown(document, { key: 'Tab', code: 'Tab' }) + fireEvent.keyDown(document, { key: 'a', code: 'KeyA' }) + + // Assert + expect(mockOnClose).not.toHaveBeenCalled() + }) + + it('should not call onClose when ESC is pressed but modal is hidden', () => { + // Arrange + renderComponent({ isShow: false }) + + // Act + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + // Assert + expect(mockOnClose).not.toHaveBeenCalled() + }) + + it('should clean up event listener on unmount', () => { + // Arrange + const { unmount } = renderComponent({ isShow: true }) + + // Act + unmount() + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + // Assert + expect(mockOnClose).not.toHaveBeenCalled() + }) + + it('should update event listener when isShow changes', () => { + // Arrange + const { rerender } = renderComponent({ isShow: true }) + + // Act - Press ESC when shown + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + // Assert + expect(mockOnClose).toHaveBeenCalledTimes(1) + + // Act - Hide modal and clear mock + mockOnClose.mockClear() + rerender() + + // Act - Press ESC when hidden + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + // Assert + expect(mockOnClose).not.toHaveBeenCalled() + }) + + it('should handle multiple ESC key presses', () => { + // Arrange + renderComponent({ isShow: true }) + + // Act + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + // Assert + expect(mockOnClose).toHaveBeenCalledTimes(3) + }) + }) + + // Edge Cases (REQUIRED) + describe('Edge Cases', () => { + it('should handle rapid modal show/hide toggling', async () => { + // Arrange + const { rerender } = renderComponent({ isShow: false }) + + // Assert + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + + // Act + rerender() + + // Assert + expect(screen.getByTestId('modal')).toBeInTheDocument() + + // Act + rerender() + + // Assert + await waitFor(() => { + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + }) + }) + + it('should handle selecting multiple nodes in sequence', async () => { + // Arrange + const user = userEvent.setup() + const { rerender } = renderComponent() + + // Act - Select user input + await user.click(screen.getByTestId('select-user-input')) + + // Assert + expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.Start) + expect(mockOnClose).toHaveBeenCalledTimes(1) + + // Act - Re-show modal and select trigger + mockOnClose.mockClear() + mockOnSelectStartNode.mockClear() + rerender() + + await user.click(screen.getByTestId('select-trigger-schedule')) + + // Assert + expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.TriggerSchedule, undefined) + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('should handle prop updates correctly', () => { + // Arrange + const { rerender } = renderComponent({ isShow: true }) + + // Assert + expect(screen.getByTestId('modal')).toBeInTheDocument() + + // Act - Update props + const newOnClose = jest.fn() + const newOnSelectStartNode = jest.fn() + rerender( + , + ) + + // Assert - Modal still renders with new props + expect(screen.getByTestId('modal')).toBeInTheDocument() + }) + + it('should handle onClose being called multiple times', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + await user.click(screen.getByTestId('modal-close-button')) + await user.click(screen.getByTestId('modal-close-button')) + + // Assert + expect(mockOnClose).toHaveBeenCalledTimes(2) + }) + + it('should maintain modal state when props change', () => { + // Arrange + const { rerender } = renderComponent({ isShow: true }) + + // Assert + expect(screen.getByTestId('modal')).toBeInTheDocument() + + // Act - Change onClose handler + const newOnClose = jest.fn() + rerender() + + // Assert - Modal should still be visible + expect(screen.getByTestId('modal')).toBeInTheDocument() + }) + }) + + // Accessibility Tests + describe('Accessibility', () => { + it('should have dialog role', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('should have proper heading hierarchy', () => { + // Arrange & Act + const { container } = renderComponent() + + // Assert + const heading = container.querySelector('h3') + expect(heading).toBeInTheDocument() + expect(heading).toHaveTextContent('workflow.onboarding.title') + }) + + it('should have external link with proper attributes', () => { + // Arrange & Act + renderComponent() + + // Assert + const link = screen.getByText('workflow.onboarding.learnMore').closest('a') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it('should have keyboard navigation support via ESC key', () => { + // Arrange + renderComponent({ isShow: true }) + + // Act + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + // Assert + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('should have visible ESC key hint', () => { + // Arrange & Act + renderComponent({ isShow: true }) + + // Assert + const escKey = screen.getByText('workflow.onboarding.escTip.key') + expect(escKey.closest('kbd')).toBeInTheDocument() + expect(escKey.closest('kbd')).toHaveClass('system-kbd') + }) + + it('should have descriptive text for ESC functionality', () => { + // Arrange & Act + renderComponent({ isShow: true }) + + // Assert + expect(screen.getByText('workflow.onboarding.escTip.press')).toBeInTheDocument() + expect(screen.getByText('workflow.onboarding.escTip.toDismiss')).toBeInTheDocument() + }) + + it('should have proper text color classes', () => { + // Arrange & Act + renderComponent() + + // Assert + const title = screen.getByText('workflow.onboarding.title') + expect(title).toHaveClass('text-text-primary') + }) + + it('should have underlined learn more link', () => { + // Arrange & Act + renderComponent() + + // Assert + const link = screen.getByText('workflow.onboarding.learnMore').closest('a') + expect(link).toHaveClass('underline') + expect(link).toHaveClass('cursor-pointer') + }) + }) + + // Integration Tests + describe('Integration', () => { + it('should complete full flow of selecting user input node', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Assert - Initial state + expect(screen.getByTestId('modal')).toBeInTheDocument() + expect(screen.getByText('workflow.onboarding.title')).toBeInTheDocument() + expect(screen.getByTestId('start-node-selection-panel')).toBeInTheDocument() + + // Act - Select user input + await user.click(screen.getByTestId('select-user-input')) + + // Assert - Callbacks called + expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.Start) + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('should complete full flow of selecting trigger node', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Assert - Initial state + expect(screen.getByTestId('modal')).toBeInTheDocument() + + // Act - Select trigger + await user.click(screen.getByTestId('select-trigger-webhook')) + + // Assert - Callbacks called with config + expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.TriggerWebhook, { config: 'test' }) + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('should render all components in correct hierarchy', () => { + // Arrange & Act + const { container } = renderComponent() + + // Assert - Modal is the root + expect(screen.getByTestId('modal')).toBeInTheDocument() + + // Assert - Header elements + const heading = container.querySelector('h3') + expect(heading).toBeInTheDocument() + + // Assert - Description with link + expect(screen.getByText('workflow.onboarding.learnMore').closest('a')).toBeInTheDocument() + + // Assert - Selection panel + expect(screen.getByTestId('start-node-selection-panel')).toBeInTheDocument() + + // Assert - ESC tip + expect(screen.getByText('workflow.onboarding.escTip.key')).toBeInTheDocument() + }) + + it('should coordinate between keyboard and click interactions', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act - Click close button + await user.click(screen.getByTestId('modal-close-button')) + + // Assert + expect(mockOnClose).toHaveBeenCalledTimes(1) + + // Act - Clear and try ESC key + mockOnClose.mockClear() + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + // Assert + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.spec.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.spec.tsx new file mode 100644 index 0000000000..d8ef1a3149 --- /dev/null +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.spec.tsx @@ -0,0 +1,348 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import StartNodeOption from './start-node-option' + +describe('StartNodeOption', () => { + const mockOnClick = jest.fn() + const defaultProps = { + icon:
Icon
, + title: 'Test Title', + description: 'Test description for the option', + onClick: mockOnClick, + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + // Helper function to render component + const renderComponent = (props = {}) => { + return render() + } + + // Rendering tests (REQUIRED) + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByText('Test Title')).toBeInTheDocument() + }) + + it('should render icon correctly', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByTestId('test-icon')).toBeInTheDocument() + expect(screen.getByText('Icon')).toBeInTheDocument() + }) + + it('should render title correctly', () => { + // Arrange & Act + renderComponent() + + // Assert + const title = screen.getByText('Test Title') + expect(title).toBeInTheDocument() + expect(title).toHaveClass('system-md-semi-bold') + expect(title).toHaveClass('text-text-primary') + }) + + it('should render description correctly', () => { + // Arrange & Act + renderComponent() + + // Assert + const description = screen.getByText('Test description for the option') + expect(description).toBeInTheDocument() + expect(description).toHaveClass('system-xs-regular') + expect(description).toHaveClass('text-text-tertiary') + }) + + it('should be rendered as a clickable card', () => { + // Arrange & Act + const { container } = renderComponent() + + // Assert + const card = container.querySelector('.cursor-pointer') + expect(card).toBeInTheDocument() + // Check that it has cursor-pointer class to indicate clickability + expect(card).toHaveClass('cursor-pointer') + }) + }) + + // Props tests (REQUIRED) + describe('Props', () => { + it('should render with subtitle when provided', () => { + // Arrange & Act + renderComponent({ subtitle: 'Optional Subtitle' }) + + // Assert + expect(screen.getByText('Optional Subtitle')).toBeInTheDocument() + }) + + it('should not render subtitle when not provided', () => { + // Arrange & Act + renderComponent() + + // Assert + const titleElement = screen.getByText('Test Title').parentElement + expect(titleElement).not.toHaveTextContent('Optional Subtitle') + }) + + it('should render subtitle with correct styling', () => { + // Arrange & Act + renderComponent({ subtitle: 'Subtitle Text' }) + + // Assert + const subtitle = screen.getByText('Subtitle Text') + expect(subtitle).toHaveClass('system-md-regular') + expect(subtitle).toHaveClass('text-text-quaternary') + }) + + it('should render custom icon component', () => { + // Arrange + const customIcon = Custom + + // Act + renderComponent({ icon: customIcon }) + + // Assert + expect(screen.getByTestId('custom-svg')).toBeInTheDocument() + }) + + it('should render long title correctly', () => { + // Arrange + const longTitle = 'This is a very long title that should still render correctly' + + // Act + renderComponent({ title: longTitle }) + + // Assert + expect(screen.getByText(longTitle)).toBeInTheDocument() + }) + + it('should render long description correctly', () => { + // Arrange + const longDescription = 'This is a very long description that explains the option in great detail and should still render correctly within the component layout' + + // Act + renderComponent({ description: longDescription }) + + // Assert + expect(screen.getByText(longDescription)).toBeInTheDocument() + }) + + it('should render with proper layout structure', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByText('Test Title')).toBeInTheDocument() + expect(screen.getByText('Test description for the option')).toBeInTheDocument() + expect(screen.getByTestId('test-icon')).toBeInTheDocument() + }) + }) + + // User Interactions + describe('User Interactions', () => { + it('should call onClick when card is clicked', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const card = screen.getByText('Test Title').closest('div[class*="cursor-pointer"]') + await user.click(card!) + + // Assert + expect(mockOnClick).toHaveBeenCalledTimes(1) + }) + + it('should call onClick when icon is clicked', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const icon = screen.getByTestId('test-icon') + await user.click(icon) + + // Assert + expect(mockOnClick).toHaveBeenCalledTimes(1) + }) + + it('should call onClick when title is clicked', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const title = screen.getByText('Test Title') + await user.click(title) + + // Assert + expect(mockOnClick).toHaveBeenCalledTimes(1) + }) + + it('should call onClick when description is clicked', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const description = screen.getByText('Test description for the option') + await user.click(description) + + // Assert + expect(mockOnClick).toHaveBeenCalledTimes(1) + }) + + it('should handle multiple rapid clicks', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const card = screen.getByText('Test Title').closest('div[class*="cursor-pointer"]') + await user.click(card!) + await user.click(card!) + await user.click(card!) + + // Assert + expect(mockOnClick).toHaveBeenCalledTimes(3) + }) + + it('should not throw error if onClick is undefined', async () => { + // Arrange + const user = userEvent.setup() + renderComponent({ onClick: undefined }) + + // Act & Assert + const card = screen.getByText('Test Title').closest('div[class*="cursor-pointer"]') + await expect(user.click(card!)).resolves.not.toThrow() + }) + }) + + // Edge Cases (REQUIRED) + describe('Edge Cases', () => { + it('should handle empty string title', () => { + // Arrange & Act + renderComponent({ title: '' }) + + // Assert + const titleContainer = screen.getByText('Test description for the option').parentElement?.parentElement + expect(titleContainer).toBeInTheDocument() + }) + + it('should handle empty string description', () => { + // Arrange & Act + renderComponent({ description: '' }) + + // Assert + expect(screen.getByText('Test Title')).toBeInTheDocument() + }) + + it('should handle undefined subtitle gracefully', () => { + // Arrange & Act + renderComponent({ subtitle: undefined }) + + // Assert + expect(screen.getByText('Test Title')).toBeInTheDocument() + }) + + it('should handle empty string subtitle', () => { + // Arrange & Act + renderComponent({ subtitle: '' }) + + // Assert + // Empty subtitle should still render but be empty + expect(screen.getByText('Test Title')).toBeInTheDocument() + }) + + it('should handle null subtitle', () => { + // Arrange & Act + renderComponent({ subtitle: null }) + + // Assert + expect(screen.getByText('Test Title')).toBeInTheDocument() + }) + + it('should render with subtitle containing special characters', () => { + // Arrange + const specialSubtitle = '(optional) - [Beta]' + + // Act + renderComponent({ subtitle: specialSubtitle }) + + // Assert + expect(screen.getByText(specialSubtitle)).toBeInTheDocument() + }) + + it('should render with title and subtitle together', () => { + // Arrange & Act + const { container } = renderComponent({ + title: 'Main Title', + subtitle: 'Secondary Text', + }) + + // Assert + expect(screen.getByText('Main Title')).toBeInTheDocument() + expect(screen.getByText('Secondary Text')).toBeInTheDocument() + + // Both should be in the same heading element + const heading = container.querySelector('h3') + expect(heading).toHaveTextContent('Main Title') + expect(heading).toHaveTextContent('Secondary Text') + }) + }) + + // Accessibility Tests + describe('Accessibility', () => { + it('should have semantic heading structure', () => { + // Arrange & Act + const { container } = renderComponent() + + // Assert + const heading = container.querySelector('h3') + expect(heading).toBeInTheDocument() + expect(heading).toHaveTextContent('Test Title') + }) + + it('should have semantic paragraph for description', () => { + // Arrange & Act + const { container } = renderComponent() + + // Assert + const paragraph = container.querySelector('p') + expect(paragraph).toBeInTheDocument() + expect(paragraph).toHaveTextContent('Test description for the option') + }) + + it('should have proper cursor style for accessibility', () => { + // Arrange & Act + const { container } = renderComponent() + + // Assert + const card = container.querySelector('.cursor-pointer') + expect(card).toBeInTheDocument() + expect(card).toHaveClass('cursor-pointer') + }) + }) + + // Additional Edge Cases + describe('Additional Edge Cases', () => { + it('should handle click when onClick handler is missing', async () => { + // Arrange + const user = userEvent.setup() + renderComponent({ onClick: undefined }) + + // Act & Assert - Should not throw error + const card = screen.getByText('Test Title').closest('div[class*="cursor-pointer"]') + await expect(user.click(card!)).resolves.not.toThrow() + }) + }) +}) diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.spec.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.spec.tsx new file mode 100644 index 0000000000..5612d4e423 --- /dev/null +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.spec.tsx @@ -0,0 +1,586 @@ +import React from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import StartNodeSelectionPanel from './start-node-selection-panel' +import { BlockEnum } from '@/app/components/workflow/types' + +// Mock NodeSelector component +jest.mock('@/app/components/workflow/block-selector', () => { + return function MockNodeSelector({ + open, + onOpenChange, + onSelect, + trigger, + }: any) { + // trigger is a function that returns a React element + const triggerElement = typeof trigger === 'function' ? trigger() : trigger + + return ( +
+ {triggerElement} + {open && ( +
+ + + +
+ )} +
+ ) + } +}) + +// Mock icons +jest.mock('@/app/components/base/icons/src/vender/workflow', () => ({ + Home: () =>
Home
, + TriggerAll: () =>
TriggerAll
, +})) + +describe('StartNodeSelectionPanel', () => { + const mockOnSelectUserInput = jest.fn() + const mockOnSelectTrigger = jest.fn() + + const defaultProps = { + onSelectUserInput: mockOnSelectUserInput, + onSelectTrigger: mockOnSelectTrigger, + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + // Helper function to render component + const renderComponent = (props = {}) => { + return render() + } + + // Rendering tests (REQUIRED) + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByText('workflow.onboarding.userInputFull')).toBeInTheDocument() + }) + + it('should render user input option', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByText('workflow.onboarding.userInputFull')).toBeInTheDocument() + expect(screen.getByText('workflow.onboarding.userInputDescription')).toBeInTheDocument() + expect(screen.getByTestId('home-icon')).toBeInTheDocument() + }) + + it('should render trigger option', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByText('workflow.onboarding.trigger')).toBeInTheDocument() + expect(screen.getByText('workflow.onboarding.triggerDescription')).toBeInTheDocument() + expect(screen.getByTestId('trigger-all-icon')).toBeInTheDocument() + }) + + it('should render node selector component', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByTestId('node-selector')).toBeInTheDocument() + }) + + it('should have correct grid layout', () => { + // Arrange & Act + const { container } = renderComponent() + + // Assert + const grid = container.querySelector('.grid') + expect(grid).toBeInTheDocument() + expect(grid).toHaveClass('grid-cols-2') + expect(grid).toHaveClass('gap-4') + }) + + it('should not show trigger selector initially', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.queryByTestId('node-selector-content')).not.toBeInTheDocument() + }) + }) + + // Props tests (REQUIRED) + describe('Props', () => { + it('should accept onSelectUserInput prop', () => { + // Arrange + const customHandler = jest.fn() + + // Act + renderComponent({ onSelectUserInput: customHandler }) + + // Assert + expect(screen.getByText('workflow.onboarding.userInputFull')).toBeInTheDocument() + }) + + it('should accept onSelectTrigger prop', () => { + // Arrange + const customHandler = jest.fn() + + // Act + renderComponent({ onSelectTrigger: customHandler }) + + // Assert + expect(screen.getByText('workflow.onboarding.trigger')).toBeInTheDocument() + }) + + it('should handle missing onSelectUserInput gracefully', () => { + // Arrange & Act + expect(() => { + renderComponent({ onSelectUserInput: undefined }) + }).not.toThrow() + + // Assert + expect(screen.getByText('workflow.onboarding.userInputFull')).toBeInTheDocument() + }) + + it('should handle missing onSelectTrigger gracefully', () => { + // Arrange & Act + expect(() => { + renderComponent({ onSelectTrigger: undefined }) + }).not.toThrow() + + // Assert + expect(screen.getByText('workflow.onboarding.trigger')).toBeInTheDocument() + }) + }) + + // User Interactions - User Input Option + describe('User Interactions - User Input', () => { + it('should call onSelectUserInput when user input option is clicked', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const userInputOption = screen.getByText('workflow.onboarding.userInputFull') + await user.click(userInputOption) + + // Assert + expect(mockOnSelectUserInput).toHaveBeenCalledTimes(1) + }) + + it('should not call onSelectTrigger when user input option is clicked', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const userInputOption = screen.getByText('workflow.onboarding.userInputFull') + await user.click(userInputOption) + + // Assert + expect(mockOnSelectTrigger).not.toHaveBeenCalled() + }) + + it('should handle multiple clicks on user input option', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const userInputOption = screen.getByText('workflow.onboarding.userInputFull') + await user.click(userInputOption) + await user.click(userInputOption) + await user.click(userInputOption) + + // Assert + expect(mockOnSelectUserInput).toHaveBeenCalledTimes(3) + }) + }) + + // User Interactions - Trigger Option + describe('User Interactions - Trigger', () => { + it('should show trigger selector when trigger option is clicked', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const triggerOption = screen.getByText('workflow.onboarding.trigger') + await user.click(triggerOption) + + // Assert + await waitFor(() => { + expect(screen.getByTestId('node-selector-content')).toBeInTheDocument() + }) + }) + + it('should not call onSelectTrigger immediately when trigger option is clicked', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const triggerOption = screen.getByText('workflow.onboarding.trigger') + await user.click(triggerOption) + + // Assert + expect(mockOnSelectTrigger).not.toHaveBeenCalled() + }) + + it('should call onSelectTrigger when a trigger is selected from selector', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act - Open trigger selector + const triggerOption = screen.getByText('workflow.onboarding.trigger') + await user.click(triggerOption) + + // Act - Select a trigger + await waitFor(() => { + expect(screen.getByTestId('select-schedule')).toBeInTheDocument() + }) + const scheduleButton = screen.getByTestId('select-schedule') + await user.click(scheduleButton) + + // Assert + expect(mockOnSelectTrigger).toHaveBeenCalledTimes(1) + expect(mockOnSelectTrigger).toHaveBeenCalledWith(BlockEnum.TriggerSchedule, undefined) + }) + + it('should call onSelectTrigger with correct node type for webhook', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act - Open trigger selector + const triggerOption = screen.getByText('workflow.onboarding.trigger') + await user.click(triggerOption) + + // Act - Select webhook trigger + await waitFor(() => { + expect(screen.getByTestId('select-webhook')).toBeInTheDocument() + }) + const webhookButton = screen.getByTestId('select-webhook') + await user.click(webhookButton) + + // Assert + expect(mockOnSelectTrigger).toHaveBeenCalledTimes(1) + expect(mockOnSelectTrigger).toHaveBeenCalledWith(BlockEnum.TriggerWebhook, undefined) + }) + + it('should hide trigger selector after selection', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act - Open trigger selector + const triggerOption = screen.getByText('workflow.onboarding.trigger') + await user.click(triggerOption) + + // Act - Select a trigger + await waitFor(() => { + expect(screen.getByTestId('select-schedule')).toBeInTheDocument() + }) + const scheduleButton = screen.getByTestId('select-schedule') + await user.click(scheduleButton) + + // Assert - Selector should be hidden + await waitFor(() => { + expect(screen.queryByTestId('node-selector-content')).not.toBeInTheDocument() + }) + }) + + it('should pass tool config parameter through onSelectTrigger', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act - Open trigger selector + const triggerOption = screen.getByText('workflow.onboarding.trigger') + await user.click(triggerOption) + + // Act - Select a trigger (our mock doesn't pass toolConfig, but real NodeSelector would) + await waitFor(() => { + expect(screen.getByTestId('select-schedule')).toBeInTheDocument() + }) + const scheduleButton = screen.getByTestId('select-schedule') + await user.click(scheduleButton) + + // Assert - Verify handler was called + // In real usage, NodeSelector would pass toolConfig as second parameter + expect(mockOnSelectTrigger).toHaveBeenCalled() + }) + }) + + // State Management + describe('State Management', () => { + it('should toggle trigger selector visibility', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Assert - Initially hidden + expect(screen.queryByTestId('node-selector-content')).not.toBeInTheDocument() + + // Act - Show selector + const triggerOption = screen.getByText('workflow.onboarding.trigger') + await user.click(triggerOption) + + // Assert - Now visible + await waitFor(() => { + expect(screen.getByTestId('node-selector-content')).toBeInTheDocument() + }) + + // Act - Close selector + const closeButton = screen.getByTestId('close-selector') + await user.click(closeButton) + + // Assert - Hidden again + await waitFor(() => { + expect(screen.queryByTestId('node-selector-content')).not.toBeInTheDocument() + }) + }) + + it('should maintain state across user input selections', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act - Click user input multiple times + const userInputOption = screen.getByText('workflow.onboarding.userInputFull') + await user.click(userInputOption) + await user.click(userInputOption) + + // Assert - Trigger selector should remain hidden + expect(screen.queryByTestId('node-selector-content')).not.toBeInTheDocument() + }) + + it('should reset trigger selector visibility after selection', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act - Open and select trigger + const triggerOption = screen.getByText('workflow.onboarding.trigger') + await user.click(triggerOption) + + await waitFor(() => { + expect(screen.getByTestId('select-schedule')).toBeInTheDocument() + }) + const scheduleButton = screen.getByTestId('select-schedule') + await user.click(scheduleButton) + + // Assert - Selector should be closed + await waitFor(() => { + expect(screen.queryByTestId('node-selector-content')).not.toBeInTheDocument() + }) + + // Act - Click trigger option again + await user.click(triggerOption) + + // Assert - Selector should open again + await waitFor(() => { + expect(screen.getByTestId('node-selector-content')).toBeInTheDocument() + }) + }) + }) + + // Edge Cases (REQUIRED) + describe('Edge Cases', () => { + it('should handle rapid clicks on trigger option', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const triggerOption = screen.getByText('workflow.onboarding.trigger') + await user.click(triggerOption) + await user.click(triggerOption) + await user.click(triggerOption) + + // Assert - Should still be open (last click) + await waitFor(() => { + expect(screen.getByTestId('node-selector-content')).toBeInTheDocument() + }) + }) + + it('should handle selecting different trigger types in sequence', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act - Open and select schedule + const triggerOption = screen.getByText('workflow.onboarding.trigger') + await user.click(triggerOption) + + await waitFor(() => { + expect(screen.getByTestId('select-schedule')).toBeInTheDocument() + }) + await user.click(screen.getByTestId('select-schedule')) + + // Assert + expect(mockOnSelectTrigger).toHaveBeenNthCalledWith(1, BlockEnum.TriggerSchedule, undefined) + + // Act - Open again and select webhook + await user.click(triggerOption) + await waitFor(() => { + expect(screen.getByTestId('select-webhook')).toBeInTheDocument() + }) + await user.click(screen.getByTestId('select-webhook')) + + // Assert + expect(mockOnSelectTrigger).toHaveBeenNthCalledWith(2, BlockEnum.TriggerWebhook, undefined) + expect(mockOnSelectTrigger).toHaveBeenCalledTimes(2) + }) + + it('should not crash with undefined callbacks', async () => { + // Arrange + const user = userEvent.setup() + renderComponent({ + onSelectUserInput: undefined, + onSelectTrigger: undefined, + }) + + // Act & Assert - Should not throw + const userInputOption = screen.getByText('workflow.onboarding.userInputFull') + await expect(user.click(userInputOption)).resolves.not.toThrow() + }) + + it('should handle opening and closing selector without selection', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act - Open selector + const triggerOption = screen.getByText('workflow.onboarding.trigger') + await user.click(triggerOption) + + // Act - Close without selecting + await waitFor(() => { + expect(screen.getByTestId('close-selector')).toBeInTheDocument() + }) + await user.click(screen.getByTestId('close-selector')) + + // Assert - No selection callback should be called + expect(mockOnSelectTrigger).not.toHaveBeenCalled() + + // Assert - Selector should be closed + await waitFor(() => { + expect(screen.queryByTestId('node-selector-content')).not.toBeInTheDocument() + }) + }) + }) + + // Accessibility Tests + describe('Accessibility', () => { + it('should have both options visible and accessible', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByText('workflow.onboarding.userInputFull')).toBeVisible() + expect(screen.getByText('workflow.onboarding.trigger')).toBeVisible() + }) + + it('should have descriptive text for both options', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByText('workflow.onboarding.userInputDescription')).toBeInTheDocument() + expect(screen.getByText('workflow.onboarding.triggerDescription')).toBeInTheDocument() + }) + + it('should have icons for visual identification', () => { + // Arrange & Act + renderComponent() + + // Assert + expect(screen.getByTestId('home-icon')).toBeInTheDocument() + expect(screen.getByTestId('trigger-all-icon')).toBeInTheDocument() + }) + + it('should maintain focus after interactions', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act + const userInputOption = screen.getByText('workflow.onboarding.userInputFull') + await user.click(userInputOption) + + // Assert - Component should still be in document + expect(screen.getByText('workflow.onboarding.userInputFull')).toBeInTheDocument() + expect(screen.getByText('workflow.onboarding.trigger')).toBeInTheDocument() + }) + }) + + // Integration Tests + describe('Integration', () => { + it('should coordinate between both options correctly', async () => { + // Arrange + const user = userEvent.setup() + renderComponent() + + // Act - Click user input + const userInputOption = screen.getByText('workflow.onboarding.userInputFull') + await user.click(userInputOption) + + // Assert + expect(mockOnSelectUserInput).toHaveBeenCalledTimes(1) + expect(mockOnSelectTrigger).not.toHaveBeenCalled() + + // Act - Click trigger + const triggerOption = screen.getByText('workflow.onboarding.trigger') + await user.click(triggerOption) + + // Assert - Trigger selector should open + await waitFor(() => { + expect(screen.getByTestId('node-selector-content')).toBeInTheDocument() + }) + + // Act - Select trigger + await user.click(screen.getByTestId('select-schedule')) + + // Assert + expect(mockOnSelectTrigger).toHaveBeenCalledTimes(1) + expect(mockOnSelectUserInput).toHaveBeenCalledTimes(1) + }) + + it('should render all components in correct hierarchy', () => { + // Arrange & Act + const { container } = renderComponent() + + // Assert + const grid = container.querySelector('.grid') + expect(grid).toBeInTheDocument() + + // Both StartNodeOption components should be rendered + expect(screen.getByText('workflow.onboarding.userInputFull')).toBeInTheDocument() + expect(screen.getByText('workflow.onboarding.trigger')).toBeInTheDocument() + + // NodeSelector should be rendered + expect(screen.getByTestId('node-selector')).toBeInTheDocument() + }) + }) +})