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 01/10] 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 =
+
+ // 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()
+ })
+ })
+})
From a915b8a584e8c51851e81e20551dd0c03fb56bb6 Mon Sep 17 00:00:00 2001
From: crazywoola <100913391+crazywoola@users.noreply.github.com>
Date: Tue, 16 Dec 2025 14:19:33 +0800
Subject: [PATCH 02/10] revert: "security/fix-swagger-info-leak-m02" (#29721)
---
api/.env.example | 12 +-----------
api/configs/feature/__init__.py | 33 +++------------------------------
api/extensions/ext_login.py | 4 ++--
api/libs/external_api.py | 20 ++------------------
4 files changed, 8 insertions(+), 61 deletions(-)
diff --git a/api/.env.example b/api/.env.example
index 8c4ea617d4..ace4c4ea1b 100644
--- a/api/.env.example
+++ b/api/.env.example
@@ -626,17 +626,7 @@ QUEUE_MONITOR_ALERT_EMAILS=
QUEUE_MONITOR_INTERVAL=30
# Swagger UI configuration
-# SECURITY: Swagger UI is automatically disabled in PRODUCTION environment (DEPLOY_ENV=PRODUCTION)
-# to prevent API information disclosure.
-#
-# Behavior:
-# - DEPLOY_ENV=PRODUCTION + SWAGGER_UI_ENABLED not set -> Swagger DISABLED (secure default)
-# - DEPLOY_ENV=DEVELOPMENT/TESTING + SWAGGER_UI_ENABLED not set -> Swagger ENABLED
-# - SWAGGER_UI_ENABLED=true -> Swagger ENABLED (overrides environment check)
-# - SWAGGER_UI_ENABLED=false -> Swagger DISABLED (explicit disable)
-#
-# For development, you can uncomment below or set DEPLOY_ENV=DEVELOPMENT
-# SWAGGER_UI_ENABLED=false
+SWAGGER_UI_ENABLED=true
SWAGGER_UI_PATH=/swagger-ui.html
# Whether to encrypt dataset IDs when exporting DSL files (default: true)
diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py
index b9091b5e2f..e16ca52f46 100644
--- a/api/configs/feature/__init__.py
+++ b/api/configs/feature/__init__.py
@@ -1252,19 +1252,9 @@ class WorkflowLogConfig(BaseSettings):
class SwaggerUIConfig(BaseSettings):
- """
- Configuration for Swagger UI documentation.
-
- Security Note: Swagger UI is automatically disabled in PRODUCTION environment
- to prevent API information disclosure. Set SWAGGER_UI_ENABLED=true explicitly
- to enable in production if needed.
- """
-
- SWAGGER_UI_ENABLED: bool | None = Field(
- description="Whether to enable Swagger UI in api module. "
- "Automatically disabled in PRODUCTION environment for security. "
- "Set to true explicitly to enable in production.",
- default=None,
+ SWAGGER_UI_ENABLED: bool = Field(
+ description="Whether to enable Swagger UI in api module",
+ default=True,
)
SWAGGER_UI_PATH: str = Field(
@@ -1272,23 +1262,6 @@ class SwaggerUIConfig(BaseSettings):
default="/swagger-ui.html",
)
- @property
- def swagger_ui_enabled(self) -> bool:
- """
- Compute whether Swagger UI should be enabled.
-
- If SWAGGER_UI_ENABLED is explicitly set, use that value.
- Otherwise, disable in PRODUCTION environment for security.
- """
- if self.SWAGGER_UI_ENABLED is not None:
- return self.SWAGGER_UI_ENABLED
-
- # Auto-disable in production environment
- import os
-
- deploy_env = os.environ.get("DEPLOY_ENV", "PRODUCTION")
- return deploy_env.upper() != "PRODUCTION"
-
class TenantIsolatedTaskQueueConfig(BaseSettings):
TENANT_ISOLATED_TASK_CONCURRENCY: int = Field(
diff --git a/api/extensions/ext_login.py b/api/extensions/ext_login.py
index 5cbdd4db12..74299956c0 100644
--- a/api/extensions/ext_login.py
+++ b/api/extensions/ext_login.py
@@ -22,8 +22,8 @@ login_manager = flask_login.LoginManager()
@login_manager.request_loader
def load_user_from_request(request_from_flask_login):
"""Load user based on the request."""
- # Skip authentication for documentation endpoints (only when Swagger is enabled)
- if dify_config.swagger_ui_enabled and request.path.endswith((dify_config.SWAGGER_UI_PATH, "/swagger.json")):
+ # Skip authentication for documentation endpoints
+ if dify_config.SWAGGER_UI_ENABLED and request.path.endswith((dify_config.SWAGGER_UI_PATH, "/swagger.json")):
return None
auth_token = extract_access_token(request)
diff --git a/api/libs/external_api.py b/api/libs/external_api.py
index 31ca2b3e08..61a90ee4a9 100644
--- a/api/libs/external_api.py
+++ b/api/libs/external_api.py
@@ -131,28 +131,12 @@ class ExternalApi(Api):
}
def __init__(self, app: Blueprint | Flask, *args, **kwargs):
- import logging
- import os
-
kwargs.setdefault("authorizations", self._authorizations)
kwargs.setdefault("security", "Bearer")
-
- # Security: Use computed swagger_ui_enabled which respects DEPLOY_ENV
- swagger_enabled = dify_config.swagger_ui_enabled
- kwargs["add_specs"] = swagger_enabled
- kwargs["doc"] = dify_config.SWAGGER_UI_PATH if swagger_enabled else False
+ kwargs["add_specs"] = dify_config.SWAGGER_UI_ENABLED
+ kwargs["doc"] = dify_config.SWAGGER_UI_PATH if dify_config.SWAGGER_UI_ENABLED else False
# manual separate call on construction and init_app to ensure configs in kwargs effective
super().__init__(app=None, *args, **kwargs)
self.init_app(app, **kwargs)
register_external_error_handlers(self)
-
- # Security: Log warning when Swagger is enabled in production environment
- deploy_env = os.environ.get("DEPLOY_ENV", "PRODUCTION")
- if swagger_enabled and deploy_env.upper() == "PRODUCTION":
- logger = logging.getLogger(__name__)
- logger.warning(
- "SECURITY WARNING: Swagger UI is ENABLED in PRODUCTION environment. "
- "This may expose sensitive API documentation. "
- "Set SWAGGER_UI_ENABLED=false or remove the explicit setting to disable."
- )
From 240e1d155ae00ad0c8f9236d2a1285b7b4b0ad85 Mon Sep 17 00:00:00 2001
From: yyh <92089059+lyzno1@users.noreply.github.com>
Date: Tue, 16 Dec 2025 14:21:05 +0800
Subject: [PATCH 03/10] test: add comprehensive tests for CustomizeModal
component (#29709)
---
web/AGENTS.md | 1 +
web/CLAUDE.md | 1 +
web/app/components/app/overview/app-card.tsx | 1 -
.../app/overview/customize/index.spec.tsx | 434 ++++++++++++++++++
.../app/overview/customize/index.tsx | 1 -
5 files changed, 436 insertions(+), 2 deletions(-)
create mode 120000 web/CLAUDE.md
create mode 100644 web/app/components/app/overview/customize/index.spec.tsx
diff --git a/web/AGENTS.md b/web/AGENTS.md
index 70e251b738..7362cd51db 100644
--- a/web/AGENTS.md
+++ b/web/AGENTS.md
@@ -2,3 +2,4 @@
- Use `web/testing/testing.md` as the canonical instruction set for generating frontend automated tests.
- When proposing or saving tests, re-read that document and follow every requirement.
+- All frontend tests MUST also comply with the `frontend-testing` skill. Treat the skill as a mandatory constraint, not optional guidance.
diff --git a/web/CLAUDE.md b/web/CLAUDE.md
new file mode 120000
index 0000000000..47dc3e3d86
--- /dev/null
+++ b/web/CLAUDE.md
@@ -0,0 +1 @@
+AGENTS.md
\ No newline at end of file
diff --git a/web/app/components/app/overview/app-card.tsx b/web/app/components/app/overview/app-card.tsx
index a0f5780b71..15762923ff 100644
--- a/web/app/components/app/overview/app-card.tsx
+++ b/web/app/components/app/overview/app-card.tsx
@@ -401,7 +401,6 @@ function AppCard({
/>
setShowCustomizeModal(false)}
appId={appInfo.id}
api_base_url={appInfo.api_base_url}
diff --git a/web/app/components/app/overview/customize/index.spec.tsx b/web/app/components/app/overview/customize/index.spec.tsx
new file mode 100644
index 0000000000..c960101b66
--- /dev/null
+++ b/web/app/components/app/overview/customize/index.spec.tsx
@@ -0,0 +1,434 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import CustomizeModal from './index'
+import { AppModeEnum } from '@/types/app'
+
+// Mock useDocLink from context
+const mockDocLink = jest.fn((path?: string) => `https://docs.dify.ai/en-US${path || ''}`)
+jest.mock('@/context/i18n', () => ({
+ useDocLink: () => mockDocLink,
+}))
+
+// Mock window.open
+const mockWindowOpen = jest.fn()
+Object.defineProperty(window, 'open', {
+ value: mockWindowOpen,
+ writable: true,
+})
+
+describe('CustomizeModal', () => {
+ const defaultProps = {
+ isShow: true,
+ onClose: jest.fn(),
+ api_base_url: 'https://api.example.com',
+ appId: 'test-app-id-123',
+ mode: AppModeEnum.CHAT,
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ // Rendering tests - verify component renders correctly with various configurations
+ describe('Rendering', () => {
+ it('should render without crashing when isShow is true', async () => {
+ // Arrange
+ const props = { ...defaultProps }
+
+ // Act
+ render()
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText('appOverview.overview.appInfo.customize.title')).toBeInTheDocument()
+ })
+ })
+
+ it('should not render content when isShow is false', async () => {
+ // Arrange
+ const props = { ...defaultProps, isShow: false }
+
+ // Act
+ render()
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.queryByText('appOverview.overview.appInfo.customize.title')).not.toBeInTheDocument()
+ })
+ })
+
+ it('should render modal description', async () => {
+ // Arrange
+ const props = { ...defaultProps }
+
+ // Act
+ render()
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText('appOverview.overview.appInfo.customize.explanation')).toBeInTheDocument()
+ })
+ })
+
+ it('should render way 1 and way 2 tags', async () => {
+ // Arrange
+ const props = { ...defaultProps }
+
+ // Act
+ render()
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText('appOverview.overview.appInfo.customize.way 1')).toBeInTheDocument()
+ expect(screen.getByText('appOverview.overview.appInfo.customize.way 2')).toBeInTheDocument()
+ })
+ })
+
+ it('should render all step numbers (1, 2, 3)', async () => {
+ // Arrange
+ const props = { ...defaultProps }
+
+ // Act
+ render()
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText('1')).toBeInTheDocument()
+ expect(screen.getByText('2')).toBeInTheDocument()
+ expect(screen.getByText('3')).toBeInTheDocument()
+ })
+ })
+
+ it('should render step instructions', async () => {
+ // Arrange
+ const props = { ...defaultProps }
+
+ // Act
+ render()
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText('appOverview.overview.appInfo.customize.way1.step1')).toBeInTheDocument()
+ expect(screen.getByText('appOverview.overview.appInfo.customize.way1.step2')).toBeInTheDocument()
+ expect(screen.getByText('appOverview.overview.appInfo.customize.way1.step3')).toBeInTheDocument()
+ })
+ })
+
+ it('should render environment variables with appId and api_base_url', async () => {
+ // Arrange
+ const props = { ...defaultProps }
+
+ // Act
+ render()
+
+ // Assert
+ await waitFor(() => {
+ const preElement = screen.getByText(/NEXT_PUBLIC_APP_ID/i).closest('pre')
+ expect(preElement).toBeInTheDocument()
+ expect(preElement?.textContent).toContain('NEXT_PUBLIC_APP_ID=\'test-app-id-123\'')
+ expect(preElement?.textContent).toContain('NEXT_PUBLIC_API_URL=\'https://api.example.com\'')
+ })
+ })
+
+ it('should render GitHub icon in step 1 button', async () => {
+ // Arrange
+ const props = { ...defaultProps }
+
+ // Act
+ render()
+
+ // Assert - find the GitHub link and verify it contains an SVG icon
+ await waitFor(() => {
+ const githubLink = screen.getByRole('link', { name: /step1Operation/i })
+ expect(githubLink).toBeInTheDocument()
+ expect(githubLink.querySelector('svg')).toBeInTheDocument()
+ })
+ })
+ })
+
+ // Props tests - verify props are correctly applied
+ describe('Props', () => {
+ it('should display correct appId in environment variables', async () => {
+ // Arrange
+ const customAppId = 'custom-app-id-456'
+ const props = { ...defaultProps, appId: customAppId }
+
+ // Act
+ render()
+
+ // Assert
+ await waitFor(() => {
+ const preElement = screen.getByText(/NEXT_PUBLIC_APP_ID/i).closest('pre')
+ expect(preElement?.textContent).toContain(`NEXT_PUBLIC_APP_ID='${customAppId}'`)
+ })
+ })
+
+ it('should display correct api_base_url in environment variables', async () => {
+ // Arrange
+ const customApiUrl = 'https://custom-api.example.com'
+ const props = { ...defaultProps, api_base_url: customApiUrl }
+
+ // Act
+ render()
+
+ // Assert
+ await waitFor(() => {
+ const preElement = screen.getByText(/NEXT_PUBLIC_API_URL/i).closest('pre')
+ expect(preElement?.textContent).toContain(`NEXT_PUBLIC_API_URL='${customApiUrl}'`)
+ })
+ })
+ })
+
+ // Mode-based conditional rendering tests - verify GitHub link changes based on app mode
+ describe('Mode-based GitHub link', () => {
+ it('should link to webapp-conversation repo for CHAT mode', async () => {
+ // Arrange
+ const props = { ...defaultProps, mode: AppModeEnum.CHAT }
+
+ // Act
+ render()
+
+ // Assert
+ await waitFor(() => {
+ const githubLink = screen.getByRole('link', { name: /step1Operation/i })
+ expect(githubLink).toHaveAttribute('href', 'https://github.com/langgenius/webapp-conversation')
+ })
+ })
+
+ it('should link to webapp-conversation repo for ADVANCED_CHAT mode', async () => {
+ // Arrange
+ const props = { ...defaultProps, mode: AppModeEnum.ADVANCED_CHAT }
+
+ // Act
+ render()
+
+ // Assert
+ await waitFor(() => {
+ const githubLink = screen.getByRole('link', { name: /step1Operation/i })
+ expect(githubLink).toHaveAttribute('href', 'https://github.com/langgenius/webapp-conversation')
+ })
+ })
+
+ it('should link to webapp-text-generator repo for COMPLETION mode', async () => {
+ // Arrange
+ const props = { ...defaultProps, mode: AppModeEnum.COMPLETION }
+
+ // Act
+ render()
+
+ // Assert
+ await waitFor(() => {
+ const githubLink = screen.getByRole('link', { name: /step1Operation/i })
+ expect(githubLink).toHaveAttribute('href', 'https://github.com/langgenius/webapp-text-generator')
+ })
+ })
+
+ it('should link to webapp-text-generator repo for WORKFLOW mode', async () => {
+ // Arrange
+ const props = { ...defaultProps, mode: AppModeEnum.WORKFLOW }
+
+ // Act
+ render()
+
+ // Assert
+ await waitFor(() => {
+ const githubLink = screen.getByRole('link', { name: /step1Operation/i })
+ expect(githubLink).toHaveAttribute('href', 'https://github.com/langgenius/webapp-text-generator')
+ })
+ })
+
+ it('should link to webapp-text-generator repo for AGENT_CHAT mode', async () => {
+ // Arrange
+ const props = { ...defaultProps, mode: AppModeEnum.AGENT_CHAT }
+
+ // Act
+ render()
+
+ // Assert
+ await waitFor(() => {
+ const githubLink = screen.getByRole('link', { name: /step1Operation/i })
+ expect(githubLink).toHaveAttribute('href', 'https://github.com/langgenius/webapp-text-generator')
+ })
+ })
+ })
+
+ // External links tests - verify external links have correct security attributes
+ describe('External links', () => {
+ it('should have GitHub repo link that opens in new tab', async () => {
+ // Arrange
+ const props = { ...defaultProps }
+
+ // Act
+ render()
+
+ // Assert
+ await waitFor(() => {
+ const githubLink = screen.getByRole('link', { name: /step1Operation/i })
+ expect(githubLink).toHaveAttribute('target', '_blank')
+ expect(githubLink).toHaveAttribute('rel', 'noopener noreferrer')
+ })
+ })
+
+ it('should have Vercel docs link that opens in new tab', async () => {
+ // Arrange
+ const props = { ...defaultProps }
+
+ // Act
+ render()
+
+ // Assert
+ await waitFor(() => {
+ const vercelLink = screen.getByRole('link', { name: /step2Operation/i })
+ expect(vercelLink).toHaveAttribute('href', 'https://vercel.com/docs/concepts/deployments/git/vercel-for-github')
+ expect(vercelLink).toHaveAttribute('target', '_blank')
+ expect(vercelLink).toHaveAttribute('rel', 'noopener noreferrer')
+ })
+ })
+ })
+
+ // User interactions tests - verify user actions trigger expected behaviors
+ describe('User Interactions', () => {
+ it('should call window.open with doc link when way 2 button is clicked', async () => {
+ // Arrange
+ const props = { ...defaultProps }
+
+ // Act
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByText('appOverview.overview.appInfo.customize.way2.operation')).toBeInTheDocument()
+ })
+
+ const way2Button = screen.getByText('appOverview.overview.appInfo.customize.way2.operation').closest('button')
+ expect(way2Button).toBeInTheDocument()
+ fireEvent.click(way2Button!)
+
+ // Assert
+ expect(mockWindowOpen).toHaveBeenCalledTimes(1)
+ expect(mockWindowOpen).toHaveBeenCalledWith(
+ expect.stringContaining('/guides/application-publishing/developing-with-apis'),
+ '_blank',
+ )
+ })
+
+ it('should call onClose when modal close button is clicked', async () => {
+ // Arrange
+ const onClose = jest.fn()
+ const props = { ...defaultProps, onClose }
+
+ // Act
+ render()
+
+ // Wait for modal to be fully rendered
+ await waitFor(() => {
+ expect(screen.getByText('appOverview.overview.appInfo.customize.title')).toBeInTheDocument()
+ })
+
+ // Find the close button by navigating from the heading to the close icon
+ // The close icon is an SVG inside a sibling div of the title
+ const heading = screen.getByRole('heading', { name: /customize\.title/i })
+ const closeIcon = heading.parentElement!.querySelector('svg')
+
+ // Assert - closeIcon must exist for the test to be valid
+ expect(closeIcon).toBeInTheDocument()
+ fireEvent.click(closeIcon!)
+ expect(onClose).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ // Edge cases tests - verify component handles boundary conditions
+ describe('Edge Cases', () => {
+ it('should handle empty appId', async () => {
+ // Arrange
+ const props = { ...defaultProps, appId: '' }
+
+ // Act
+ render()
+
+ // Assert
+ await waitFor(() => {
+ const preElement = screen.getByText(/NEXT_PUBLIC_APP_ID/i).closest('pre')
+ expect(preElement?.textContent).toContain('NEXT_PUBLIC_APP_ID=\'\'')
+ })
+ })
+
+ it('should handle empty api_base_url', async () => {
+ // Arrange
+ const props = { ...defaultProps, api_base_url: '' }
+
+ // Act
+ render()
+
+ // Assert
+ await waitFor(() => {
+ const preElement = screen.getByText(/NEXT_PUBLIC_API_URL/i).closest('pre')
+ expect(preElement?.textContent).toContain('NEXT_PUBLIC_API_URL=\'\'')
+ })
+ })
+
+ it('should handle special characters in appId', async () => {
+ // Arrange
+ const specialAppId = 'app-id-with-special-chars_123'
+ const props = { ...defaultProps, appId: specialAppId }
+
+ // Act
+ render()
+
+ // Assert
+ await waitFor(() => {
+ const preElement = screen.getByText(/NEXT_PUBLIC_APP_ID/i).closest('pre')
+ expect(preElement?.textContent).toContain(`NEXT_PUBLIC_APP_ID='${specialAppId}'`)
+ })
+ })
+
+ it('should handle URL with special characters in api_base_url', async () => {
+ // Arrange
+ const specialApiUrl = 'https://api.example.com:8080/v1'
+ const props = { ...defaultProps, api_base_url: specialApiUrl }
+
+ // Act
+ render()
+
+ // Assert
+ await waitFor(() => {
+ const preElement = screen.getByText(/NEXT_PUBLIC_API_URL/i).closest('pre')
+ expect(preElement?.textContent).toContain(`NEXT_PUBLIC_API_URL='${specialApiUrl}'`)
+ })
+ })
+ })
+
+ // StepNum component tests - verify step number styling
+ describe('StepNum component', () => {
+ it('should render step numbers with correct styling class', async () => {
+ // Arrange
+ const props = { ...defaultProps }
+
+ // Act
+ render()
+
+ // Assert - The StepNum component is the direct container of the text
+ await waitFor(() => {
+ const stepNumber1 = screen.getByText('1')
+ expect(stepNumber1).toHaveClass('rounded-2xl')
+ })
+ })
+ })
+
+ // GithubIcon component tests - verify GitHub icon renders correctly
+ describe('GithubIcon component', () => {
+ it('should render GitHub icon SVG within GitHub link button', async () => {
+ // Arrange
+ const props = { ...defaultProps }
+
+ // Act
+ render()
+
+ // Assert - Find GitHub link and verify it contains an SVG icon with expected class
+ await waitFor(() => {
+ const githubLink = screen.getByRole('link', { name: /step1Operation/i })
+ const githubIcon = githubLink.querySelector('svg')
+ expect(githubIcon).toBeInTheDocument()
+ expect(githubIcon).toHaveClass('text-text-secondary')
+ })
+ })
+ })
+})
diff --git a/web/app/components/app/overview/customize/index.tsx b/web/app/components/app/overview/customize/index.tsx
index e440a8cf26..698bc98efd 100644
--- a/web/app/components/app/overview/customize/index.tsx
+++ b/web/app/components/app/overview/customize/index.tsx
@@ -12,7 +12,6 @@ import Tag from '@/app/components/base/tag'
type IShareLinkProps = {
isShow: boolean
onClose: () => void
- linkUrl: string
api_base_url: string
appId: string
mode: AppModeEnum
From e5cf0d0bf619abb1afcbada0f89e67674799e229 Mon Sep 17 00:00:00 2001
From: -LAN-
Date: Tue, 16 Dec 2025 15:01:51 +0800
Subject: [PATCH 04/10] chore: Disable Swagger UI by default in docker samples
(#29723)
---
docker/.env.example | 2 +-
docker/docker-compose.yaml | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/docker/.env.example b/docker/.env.example
index 8be75420b1..feca68fa02 100644
--- a/docker/.env.example
+++ b/docker/.env.example
@@ -1421,7 +1421,7 @@ QUEUE_MONITOR_ALERT_EMAILS=
QUEUE_MONITOR_INTERVAL=30
# Swagger UI configuration
-SWAGGER_UI_ENABLED=true
+SWAGGER_UI_ENABLED=false
SWAGGER_UI_PATH=/swagger-ui.html
# Whether to encrypt dataset IDs when exporting DSL files (default: true)
diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml
index cc17b2853a..1e50792b6d 100644
--- a/docker/docker-compose.yaml
+++ b/docker/docker-compose.yaml
@@ -631,7 +631,7 @@ x-shared-env: &shared-api-worker-env
QUEUE_MONITOR_THRESHOLD: ${QUEUE_MONITOR_THRESHOLD:-200}
QUEUE_MONITOR_ALERT_EMAILS: ${QUEUE_MONITOR_ALERT_EMAILS:-}
QUEUE_MONITOR_INTERVAL: ${QUEUE_MONITOR_INTERVAL:-30}
- SWAGGER_UI_ENABLED: ${SWAGGER_UI_ENABLED:-true}
+ SWAGGER_UI_ENABLED: ${SWAGGER_UI_ENABLED:-false}
SWAGGER_UI_PATH: ${SWAGGER_UI_PATH:-/swagger-ui.html}
DSL_EXPORT_ENCRYPT_DATASET_ID: ${DSL_EXPORT_ENCRYPT_DATASET_ID:-true}
DATASET_MAX_SEGMENTS_PER_REQUEST: ${DATASET_MAX_SEGMENTS_PER_REQUEST:-0}
From 47cd94ec3e68c4c3b7e8adab2d179096e9b066ff Mon Sep 17 00:00:00 2001
From: Joel
Date: Tue, 16 Dec 2025 15:06:53 +0800
Subject: [PATCH 05/10] chore: tests for billings (#29720)
---
web/__mocks__/react-i18next.ts | 8 +-
.../plans/cloud-plan-item/button.spec.tsx | 50 +++++
.../plans/cloud-plan-item/index.spec.tsx | 188 ++++++++++++++++++
.../plans/cloud-plan-item/list/index.spec.tsx | 30 +++
.../billing/pricing/plans/index.spec.tsx | 87 ++++++++
.../self-hosted-plan-item/button.spec.tsx | 61 ++++++
.../self-hosted-plan-item/index.spec.tsx | 143 +++++++++++++
.../self-hosted-plan-item/list/index.spec.tsx | 25 +++
.../self-hosted-plan-item/list/item.spec.tsx | 12 ++
9 files changed, 603 insertions(+), 1 deletion(-)
create mode 100644 web/app/components/billing/pricing/plans/cloud-plan-item/button.spec.tsx
create mode 100644 web/app/components/billing/pricing/plans/cloud-plan-item/index.spec.tsx
create mode 100644 web/app/components/billing/pricing/plans/cloud-plan-item/list/index.spec.tsx
create mode 100644 web/app/components/billing/pricing/plans/index.spec.tsx
create mode 100644 web/app/components/billing/pricing/plans/self-hosted-plan-item/button.spec.tsx
create mode 100644 web/app/components/billing/pricing/plans/self-hosted-plan-item/index.spec.tsx
create mode 100644 web/app/components/billing/pricing/plans/self-hosted-plan-item/list/index.spec.tsx
create mode 100644 web/app/components/billing/pricing/plans/self-hosted-plan-item/list/item.spec.tsx
diff --git a/web/__mocks__/react-i18next.ts b/web/__mocks__/react-i18next.ts
index b0d22e0cc0..1e3f58927e 100644
--- a/web/__mocks__/react-i18next.ts
+++ b/web/__mocks__/react-i18next.ts
@@ -19,7 +19,13 @@
*/
export const useTranslation = () => ({
- t: (key: string) => key,
+ t: (key: string, options?: Record) => {
+ if (options?.returnObjects)
+ return [`${key}-feature-1`, `${key}-feature-2`]
+ if (options)
+ return `${key}:${JSON.stringify(options)}`
+ return key
+ },
i18n: {
language: 'en',
changeLanguage: jest.fn(),
diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/button.spec.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/button.spec.tsx
new file mode 100644
index 0000000000..0c50c80c87
--- /dev/null
+++ b/web/app/components/billing/pricing/plans/cloud-plan-item/button.spec.tsx
@@ -0,0 +1,50 @@
+import React from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import Button from './button'
+import { Plan } from '../../../type'
+
+describe('CloudPlanButton', () => {
+ describe('Disabled state', () => {
+ test('should disable button and hide arrow when plan is not available', () => {
+ const handleGetPayUrl = jest.fn()
+ // Arrange
+ render(
+ ,
+ )
+
+ const button = screen.getByRole('button', { name: /Get started/i })
+ // Assert
+ expect(button).toBeDisabled()
+ expect(button.className).toContain('cursor-not-allowed')
+ expect(handleGetPayUrl).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Enabled state', () => {
+ test('should invoke handler and render arrow when plan is available', () => {
+ const handleGetPayUrl = jest.fn()
+ // Arrange
+ render(
+ ,
+ )
+
+ const button = screen.getByRole('button', { name: /Start now/i })
+ // Act
+ fireEvent.click(button)
+
+ // Assert
+ expect(handleGetPayUrl).toHaveBeenCalledTimes(1)
+ expect(button).not.toBeDisabled()
+ })
+ })
+})
diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/index.spec.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/index.spec.tsx
new file mode 100644
index 0000000000..4e748adea0
--- /dev/null
+++ b/web/app/components/billing/pricing/plans/cloud-plan-item/index.spec.tsx
@@ -0,0 +1,188 @@
+import React from 'react'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import CloudPlanItem from './index'
+import { Plan } from '../../../type'
+import { PlanRange } from '../../plan-switcher/plan-range-switcher'
+import { useAppContext } from '@/context/app-context'
+import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
+import { fetchBillingUrl, fetchSubscriptionUrls } from '@/service/billing'
+import Toast from '../../../../base/toast'
+import { ALL_PLANS } from '../../../config'
+
+jest.mock('../../../../base/toast', () => ({
+ __esModule: true,
+ default: {
+ notify: jest.fn(),
+ },
+}))
+
+jest.mock('@/context/app-context', () => ({
+ useAppContext: jest.fn(),
+}))
+
+jest.mock('@/service/billing', () => ({
+ fetchBillingUrl: jest.fn(),
+ fetchSubscriptionUrls: jest.fn(),
+}))
+
+jest.mock('@/hooks/use-async-window-open', () => ({
+ useAsyncWindowOpen: jest.fn(),
+}))
+
+jest.mock('../../assets', () => ({
+ Sandbox: () => Sandbox Icon
,
+ Professional: () => Professional Icon
,
+ Team: () => Team Icon
,
+}))
+
+const mockUseAppContext = useAppContext as jest.Mock
+const mockUseAsyncWindowOpen = useAsyncWindowOpen as jest.Mock
+const mockFetchBillingUrl = fetchBillingUrl as jest.Mock
+const mockFetchSubscriptionUrls = fetchSubscriptionUrls as jest.Mock
+const mockToastNotify = Toast.notify as jest.Mock
+
+let assignedHref = ''
+const originalLocation = window.location
+
+beforeAll(() => {
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ value: {
+ get href() {
+ return assignedHref
+ },
+ set href(value: string) {
+ assignedHref = value
+ },
+ } as unknown as Location,
+ })
+})
+
+afterAll(() => {
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ value: originalLocation,
+ })
+})
+
+beforeEach(() => {
+ jest.clearAllMocks()
+ mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
+ mockUseAsyncWindowOpen.mockReturnValue(jest.fn(async open => await open()))
+ mockFetchBillingUrl.mockResolvedValue({ url: 'https://billing.example' })
+ mockFetchSubscriptionUrls.mockResolvedValue({ url: 'https://subscription.example' })
+ assignedHref = ''
+})
+
+describe('CloudPlanItem', () => {
+ // Static content for each plan
+ describe('Rendering', () => {
+ test('should show plan metadata and free label for sandbox plan', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('billing.plans.sandbox.name')).toBeInTheDocument()
+ expect(screen.getByText('billing.plans.sandbox.description')).toBeInTheDocument()
+ expect(screen.getByText('billing.plansCommon.free')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' })).toBeInTheDocument()
+ })
+
+ test('should display yearly pricing with discount when planRange is yearly', () => {
+ render(
+ ,
+ )
+
+ const professionalPlan = ALL_PLANS[Plan.professional]
+ expect(screen.getByText(`$${professionalPlan.price * 12}`)).toBeInTheDocument()
+ expect(screen.getByText(`$${professionalPlan.price * 10}`)).toBeInTheDocument()
+ expect(screen.getByText(/billing\.plansCommon\.priceTip.*billing\.plansCommon\.year/)).toBeInTheDocument()
+ })
+
+ test('should disable CTA when workspace already on higher tier', () => {
+ render(
+ ,
+ )
+
+ const button = screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' })
+ expect(button).toBeDisabled()
+ })
+ })
+
+ // Payment actions triggered from the CTA
+ describe('Plan purchase flow', () => {
+ test('should show toast when non-manager tries to buy a plan', () => {
+ mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: false })
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }))
+ expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ message: 'billing.buyPermissionDeniedTip',
+ }))
+ expect(mockFetchBillingUrl).not.toHaveBeenCalled()
+ })
+
+ test('should open billing portal when upgrading current paid plan', async () => {
+ const openWindow = jest.fn(async (cb: () => Promise) => await cb())
+ mockUseAsyncWindowOpen.mockReturnValue(openWindow)
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' }))
+
+ await waitFor(() => {
+ expect(mockFetchBillingUrl).toHaveBeenCalledTimes(1)
+ })
+ expect(openWindow).toHaveBeenCalledTimes(1)
+ })
+
+ test('should redirect to subscription url when selecting a new paid plan', async () => {
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }))
+
+ await waitFor(() => {
+ expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'month')
+ expect(assignedHref).toBe('https://subscription.example')
+ })
+ })
+ })
+})
diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/list/index.spec.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/list/index.spec.tsx
new file mode 100644
index 0000000000..fa49a6d8cf
--- /dev/null
+++ b/web/app/components/billing/pricing/plans/cloud-plan-item/list/index.spec.tsx
@@ -0,0 +1,30 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import List from './index'
+import { Plan } from '../../../../type'
+
+describe('CloudPlanItem/List', () => {
+ test('should show sandbox specific quotas', () => {
+ render(
)
+
+ expect(screen.getByText('billing.plansCommon.messageRequest.title:{"count":200}')).toBeInTheDocument()
+ expect(screen.getByText('billing.plansCommon.triggerEvents.sandbox:{"count":3000}')).toBeInTheDocument()
+ expect(screen.getByText('billing.plansCommon.startNodes.limited:{"count":2}')).toBeInTheDocument()
+ })
+
+ test('should show professional monthly quotas and tooltips', () => {
+ render(
)
+
+ expect(screen.getByText('billing.plansCommon.messageRequest.titlePerMonth:{"count":5000}')).toBeInTheDocument()
+ expect(screen.getByText('billing.plansCommon.vectorSpaceTooltip')).toBeInTheDocument()
+ expect(screen.getByText('billing.plansCommon.workflowExecution.faster')).toBeInTheDocument()
+ })
+
+ test('should show unlimited messaging details for team plan', () => {
+ render(
)
+
+ expect(screen.getByText('billing.plansCommon.triggerEvents.unlimited')).toBeInTheDocument()
+ expect(screen.getByText('billing.plansCommon.workflowExecution.priority')).toBeInTheDocument()
+ expect(screen.getByText('billing.plansCommon.unlimitedApiRate')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/billing/pricing/plans/index.spec.tsx b/web/app/components/billing/pricing/plans/index.spec.tsx
new file mode 100644
index 0000000000..cc2fe2d4ae
--- /dev/null
+++ b/web/app/components/billing/pricing/plans/index.spec.tsx
@@ -0,0 +1,87 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import Plans from './index'
+import { Plan, type UsagePlanInfo } from '../../type'
+import { PlanRange } from '../plan-switcher/plan-range-switcher'
+
+jest.mock('./cloud-plan-item', () => ({
+ __esModule: true,
+ default: jest.fn(props => (
+
+ Cloud {props.plan}
+
+ )),
+}))
+
+jest.mock('./self-hosted-plan-item', () => ({
+ __esModule: true,
+ default: jest.fn(props => (
+
+ Self {props.plan}
+
+ )),
+}))
+
+const buildPlan = (type: Plan) => {
+ const usage: UsagePlanInfo = {
+ buildApps: 0,
+ teamMembers: 0,
+ annotatedResponse: 0,
+ documentsUploadQuota: 0,
+ apiRateLimit: 0,
+ triggerEvents: 0,
+ vectorSpace: 0,
+ }
+ return {
+ type,
+ usage,
+ total: usage,
+ }
+}
+
+describe('Plans', () => {
+ // Cloud plans visible only when currentPlan is cloud
+ describe('Cloud plan rendering', () => {
+ test('should render sandbox, professional, and team cloud plans when workspace is cloud', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('cloud-plan-sandbox')).toBeInTheDocument()
+ expect(screen.getByTestId('cloud-plan-professional')).toBeInTheDocument()
+ expect(screen.getByTestId('cloud-plan-team')).toBeInTheDocument()
+
+ const cloudPlanItem = jest.requireMock('./cloud-plan-item').default as jest.Mock
+ const firstCallProps = cloudPlanItem.mock.calls[0][0]
+ expect(firstCallProps.plan).toBe(Plan.sandbox)
+ // Enterprise should be normalized to team when passed down
+ expect(firstCallProps.currentPlan).toBe(Plan.team)
+ })
+ })
+
+ // Self-hosted plans visible for self-managed workspaces
+ describe('Self-hosted plan rendering', () => {
+ test('should render all self-hosted plans when workspace type is self-hosted', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('self-plan-community')).toBeInTheDocument()
+ expect(screen.getByTestId('self-plan-premium')).toBeInTheDocument()
+ expect(screen.getByTestId('self-plan-enterprise')).toBeInTheDocument()
+
+ const selfPlanItem = jest.requireMock('./self-hosted-plan-item').default as jest.Mock
+ expect(selfPlanItem).toHaveBeenCalledTimes(3)
+ })
+ })
+})
diff --git a/web/app/components/billing/pricing/plans/self-hosted-plan-item/button.spec.tsx b/web/app/components/billing/pricing/plans/self-hosted-plan-item/button.spec.tsx
new file mode 100644
index 0000000000..4b812d4db3
--- /dev/null
+++ b/web/app/components/billing/pricing/plans/self-hosted-plan-item/button.spec.tsx
@@ -0,0 +1,61 @@
+import React from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import Button from './button'
+import { SelfHostedPlan } from '../../../type'
+import useTheme from '@/hooks/use-theme'
+import { Theme } from '@/types/app'
+
+jest.mock('@/hooks/use-theme')
+
+jest.mock('@/app/components/base/icons/src/public/billing', () => ({
+ AwsMarketplaceLight: () => AwsMarketplaceLight
,
+ AwsMarketplaceDark: () => AwsMarketplaceDark
,
+}))
+
+const mockUseTheme = useTheme as jest.MockedFunction
+
+beforeEach(() => {
+ jest.clearAllMocks()
+ mockUseTheme.mockReturnValue({ theme: Theme.light } as unknown as ReturnType)
+})
+
+describe('SelfHostedPlanButton', () => {
+ test('should invoke handler when clicked', () => {
+ const handleGetPayUrl = jest.fn()
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'billing.plans.community.btnText' }))
+ expect(handleGetPayUrl).toHaveBeenCalledTimes(1)
+ })
+
+ test('should render AWS marketplace badge for premium plan in light theme', () => {
+ const handleGetPayUrl = jest.fn()
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('AwsMarketplaceLight')).toBeInTheDocument()
+ })
+
+ test('should switch to dark AWS badge in dark theme', () => {
+ mockUseTheme.mockReturnValue({ theme: Theme.dark } as unknown as ReturnType)
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('AwsMarketplaceDark')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/billing/pricing/plans/self-hosted-plan-item/index.spec.tsx b/web/app/components/billing/pricing/plans/self-hosted-plan-item/index.spec.tsx
new file mode 100644
index 0000000000..fec17ca838
--- /dev/null
+++ b/web/app/components/billing/pricing/plans/self-hosted-plan-item/index.spec.tsx
@@ -0,0 +1,143 @@
+import React from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import SelfHostedPlanItem from './index'
+import { SelfHostedPlan } from '../../../type'
+import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '../../../config'
+import { useAppContext } from '@/context/app-context'
+import Toast from '../../../../base/toast'
+
+const featuresTranslations: Record = {
+ 'billing.plans.community.features': ['community-feature-1', 'community-feature-2'],
+ 'billing.plans.premium.features': ['premium-feature-1'],
+ 'billing.plans.enterprise.features': ['enterprise-feature-1'],
+}
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, options?: Record) => {
+ if (options?.returnObjects)
+ return featuresTranslations[key] || []
+ return key
+ },
+ }),
+ Trans: ({ i18nKey }: { i18nKey: string }) => {i18nKey},
+}))
+
+jest.mock('../../../../base/toast', () => ({
+ __esModule: true,
+ default: {
+ notify: jest.fn(),
+ },
+}))
+
+jest.mock('@/context/app-context', () => ({
+ useAppContext: jest.fn(),
+}))
+
+jest.mock('../../assets', () => ({
+ Community: () => Community Icon
,
+ Premium: () => Premium Icon
,
+ Enterprise: () => Enterprise Icon
,
+ PremiumNoise: () => PremiumNoise
,
+ EnterpriseNoise: () => EnterpriseNoise
,
+}))
+
+jest.mock('@/app/components/base/icons/src/public/billing', () => ({
+ Azure: () => Azure
,
+ GoogleCloud: () => Google Cloud
,
+ AwsMarketplaceDark: () => AwsMarketplaceDark
,
+ AwsMarketplaceLight: () => AwsMarketplaceLight
,
+}))
+
+const mockUseAppContext = useAppContext as jest.Mock
+const mockToastNotify = Toast.notify as jest.Mock
+
+let assignedHref = ''
+const originalLocation = window.location
+
+beforeAll(() => {
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ value: {
+ get href() {
+ return assignedHref
+ },
+ set href(value: string) {
+ assignedHref = value
+ },
+ } as unknown as Location,
+ })
+})
+
+afterAll(() => {
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ value: originalLocation,
+ })
+})
+
+beforeEach(() => {
+ jest.clearAllMocks()
+ mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
+ assignedHref = ''
+})
+
+describe('SelfHostedPlanItem', () => {
+ // Copy rendering for each plan
+ describe('Rendering', () => {
+ test('should display community plan info', () => {
+ render()
+
+ expect(screen.getByText('billing.plans.community.name')).toBeInTheDocument()
+ expect(screen.getByText('billing.plans.community.description')).toBeInTheDocument()
+ expect(screen.getByText('billing.plans.community.price')).toBeInTheDocument()
+ expect(screen.getByText('billing.plans.community.includesTitle')).toBeInTheDocument()
+ expect(screen.getByText('community-feature-1')).toBeInTheDocument()
+ })
+
+ test('should show premium extras such as cloud provider notice', () => {
+ render()
+
+ expect(screen.getByText('billing.plans.premium.price')).toBeInTheDocument()
+ expect(screen.getByText('billing.plans.premium.comingSoon')).toBeInTheDocument()
+ expect(screen.getByText('Azure')).toBeInTheDocument()
+ expect(screen.getByText('Google Cloud')).toBeInTheDocument()
+ })
+ })
+
+ // CTA behavior for each plan
+ describe('CTA interactions', () => {
+ test('should show toast when non-manager tries to proceed', () => {
+ mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: false })
+
+ render()
+ fireEvent.click(screen.getByRole('button', { name: /billing\.plans\.premium\.btnText/ }))
+
+ expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ message: 'billing.buyPermissionDeniedTip',
+ }))
+ })
+
+ test('should redirect to community url when community plan button clicked', () => {
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'billing.plans.community.btnText' }))
+ expect(assignedHref).toBe(getStartedWithCommunityUrl)
+ })
+
+ test('should redirect to premium marketplace url when premium button clicked', () => {
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: /billing\.plans\.premium\.btnText/ }))
+ expect(assignedHref).toBe(getWithPremiumUrl)
+ })
+
+ test('should redirect to contact sales form when enterprise button clicked', () => {
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'billing.plans.enterprise.btnText' }))
+ expect(assignedHref).toBe(contactSalesUrl)
+ })
+ })
+})
diff --git a/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/index.spec.tsx b/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/index.spec.tsx
new file mode 100644
index 0000000000..dfdb917cbf
--- /dev/null
+++ b/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/index.spec.tsx
@@ -0,0 +1,25 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import List from './index'
+import { SelfHostedPlan } from '@/app/components/billing/type'
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, options?: Record) => {
+ if (options?.returnObjects)
+ return ['Feature A', 'Feature B']
+ return key
+ },
+ }),
+ Trans: ({ i18nKey }: { i18nKey: string }) => {i18nKey},
+}))
+
+describe('SelfHostedPlanItem/List', () => {
+ test('should render plan info', () => {
+ render(
)
+
+ expect(screen.getByText('billing.plans.community.includesTitle')).toBeInTheDocument()
+ expect(screen.getByText('Feature A')).toBeInTheDocument()
+ expect(screen.getByText('Feature B')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/item.spec.tsx b/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/item.spec.tsx
new file mode 100644
index 0000000000..38e14373dc
--- /dev/null
+++ b/web/app/components/billing/pricing/plans/self-hosted-plan-item/list/item.spec.tsx
@@ -0,0 +1,12 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import Item from './item'
+
+describe('SelfHostedPlanItem/List/Item', () => {
+ test('should display provided feature label', () => {
+ const { container } = render( )
+
+ expect(screen.getByText('Dedicated support')).toBeInTheDocument()
+ expect(container.querySelector('svg')).not.toBeNull()
+ })
+})
From c036a129992334d18d63281340215ed59faf0b31 Mon Sep 17 00:00:00 2001
From: yyh <92089059+lyzno1@users.noreply.github.com>
Date: Tue, 16 Dec 2025 15:07:30 +0800
Subject: [PATCH 06/10] test: add comprehensive unit tests for APIKeyInfoPanel
component (#29719)
---
.../apikey-info-panel.test-utils.tsx | 209 ++++++++++++++++++
.../overview/apikey-info-panel/cloud.spec.tsx | 122 ++++++++++
.../overview/apikey-info-panel/index.spec.tsx | 162 ++++++++++++++
3 files changed, 493 insertions(+)
create mode 100644 web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx
create mode 100644 web/app/components/app/overview/apikey-info-panel/cloud.spec.tsx
create mode 100644 web/app/components/app/overview/apikey-info-panel/index.spec.tsx
diff --git a/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx b/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx
new file mode 100644
index 0000000000..36a1c5a008
--- /dev/null
+++ b/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx
@@ -0,0 +1,209 @@
+import type { RenderOptions } from '@testing-library/react'
+import { fireEvent, render } from '@testing-library/react'
+import { defaultPlan } from '@/app/components/billing/config'
+import { noop } from 'lodash-es'
+import type { ModalContextState } from '@/context/modal-context'
+import APIKeyInfoPanel from './index'
+
+// Mock the modules before importing the functions
+jest.mock('@/context/provider-context', () => ({
+ useProviderContext: jest.fn(),
+}))
+
+jest.mock('@/context/modal-context', () => ({
+ useModalContext: jest.fn(),
+}))
+
+import { useProviderContext as actualUseProviderContext } from '@/context/provider-context'
+import { useModalContext as actualUseModalContext } from '@/context/modal-context'
+
+// Type casting for mocks
+const mockUseProviderContext = actualUseProviderContext as jest.MockedFunction
+const mockUseModalContext = actualUseModalContext as jest.MockedFunction
+
+// Default mock data
+const defaultProviderContext = {
+ modelProviders: [],
+ refreshModelProviders: noop,
+ textGenerationModelList: [],
+ supportRetrievalMethods: [],
+ isAPIKeySet: false,
+ plan: defaultPlan,
+ isFetchedPlan: false,
+ enableBilling: false,
+ onPlanInfoChanged: noop,
+ enableReplaceWebAppLogo: false,
+ modelLoadBalancingEnabled: false,
+ datasetOperatorEnabled: false,
+ enableEducationPlan: false,
+ isEducationWorkspace: false,
+ isEducationAccount: false,
+ allowRefreshEducationVerify: false,
+ educationAccountExpireAt: null,
+ isLoadingEducationAccountInfo: false,
+ isFetchingEducationAccountInfo: false,
+ webappCopyrightEnabled: false,
+ licenseLimit: {
+ workspace_members: {
+ size: 0,
+ limit: 0,
+ },
+ },
+ refreshLicenseLimit: noop,
+ isAllowTransferWorkspace: false,
+ isAllowPublishAsCustomKnowledgePipelineTemplate: false,
+}
+
+const defaultModalContext: ModalContextState = {
+ setShowAccountSettingModal: noop,
+ setShowApiBasedExtensionModal: noop,
+ setShowModerationSettingModal: noop,
+ setShowExternalDataToolModal: noop,
+ setShowPricingModal: noop,
+ setShowAnnotationFullModal: noop,
+ setShowModelModal: noop,
+ setShowExternalKnowledgeAPIModal: noop,
+ setShowModelLoadBalancingModal: noop,
+ setShowOpeningModal: noop,
+ setShowUpdatePluginModal: noop,
+ setShowEducationExpireNoticeModal: noop,
+ setShowTriggerEventsLimitModal: noop,
+}
+
+export type MockOverrides = {
+ providerContext?: Partial
+ modalContext?: Partial
+}
+
+export type APIKeyInfoPanelRenderOptions = {
+ mockOverrides?: MockOverrides
+} & Omit
+
+// Setup function to configure mocks
+export function setupMocks(overrides: MockOverrides = {}) {
+ mockUseProviderContext.mockReturnValue({
+ ...defaultProviderContext,
+ ...overrides.providerContext,
+ })
+
+ mockUseModalContext.mockReturnValue({
+ ...defaultModalContext,
+ ...overrides.modalContext,
+ })
+}
+
+// Custom render function
+export function renderAPIKeyInfoPanel(options: APIKeyInfoPanelRenderOptions = {}) {
+ const { mockOverrides, ...renderOptions } = options
+
+ setupMocks(mockOverrides)
+
+ return render(, renderOptions)
+}
+
+// Helper functions for common test scenarios
+export const scenarios = {
+ // Render with API key not set (default)
+ withAPIKeyNotSet: (overrides: MockOverrides = {}) =>
+ renderAPIKeyInfoPanel({
+ mockOverrides: {
+ providerContext: { isAPIKeySet: false },
+ ...overrides,
+ },
+ }),
+
+ // Render with API key already set
+ withAPIKeySet: (overrides: MockOverrides = {}) =>
+ renderAPIKeyInfoPanel({
+ mockOverrides: {
+ providerContext: { isAPIKeySet: true },
+ ...overrides,
+ },
+ }),
+
+ // Render with mock modal function
+ withMockModal: (mockSetShowAccountSettingModal: jest.Mock, overrides: MockOverrides = {}) =>
+ renderAPIKeyInfoPanel({
+ mockOverrides: {
+ modalContext: { setShowAccountSettingModal: mockSetShowAccountSettingModal },
+ ...overrides,
+ },
+ }),
+}
+
+// Common test assertions
+export const assertions = {
+ // Should render main button
+ shouldRenderMainButton: () => {
+ const button = document.querySelector('button.btn-primary')
+ expect(button).toBeInTheDocument()
+ return button
+ },
+
+ // Should not render at all
+ shouldNotRender: (container: HTMLElement) => {
+ expect(container.firstChild).toBeNull()
+ },
+
+ // Should have correct panel styling
+ shouldHavePanelStyling: (panel: HTMLElement) => {
+ expect(panel).toHaveClass(
+ 'border-components-panel-border',
+ 'bg-components-panel-bg',
+ 'relative',
+ 'mb-6',
+ 'rounded-2xl',
+ 'border',
+ 'p-8',
+ 'shadow-md',
+ )
+ },
+
+ // Should have close button
+ shouldHaveCloseButton: (container: HTMLElement) => {
+ const closeButton = container.querySelector('.absolute.right-4.top-4')
+ expect(closeButton).toBeInTheDocument()
+ expect(closeButton).toHaveClass('cursor-pointer')
+ return closeButton
+ },
+}
+
+// Common user interactions
+export const interactions = {
+ // Click the main button
+ clickMainButton: () => {
+ const button = document.querySelector('button.btn-primary')
+ if (button) fireEvent.click(button)
+ return button
+ },
+
+ // Click the close button
+ clickCloseButton: (container: HTMLElement) => {
+ const closeButton = container.querySelector('.absolute.right-4.top-4')
+ if (closeButton) fireEvent.click(closeButton)
+ return closeButton
+ },
+}
+
+// Text content keys for assertions
+export const textKeys = {
+ selfHost: {
+ titleRow1: 'appOverview.apiKeyInfo.selfHost.title.row1',
+ titleRow2: 'appOverview.apiKeyInfo.selfHost.title.row2',
+ setAPIBtn: 'appOverview.apiKeyInfo.setAPIBtn',
+ tryCloud: 'appOverview.apiKeyInfo.tryCloud',
+ },
+ cloud: {
+ trialTitle: 'appOverview.apiKeyInfo.cloud.trial.title',
+ trialDescription: /appOverview\.apiKeyInfo\.cloud\.trial\.description/,
+ setAPIBtn: 'appOverview.apiKeyInfo.setAPIBtn',
+ },
+}
+
+// Setup and cleanup utilities
+export function clearAllMocks() {
+ jest.clearAllMocks()
+}
+
+// Export mock functions for external access
+export { mockUseProviderContext, mockUseModalContext, defaultModalContext }
diff --git a/web/app/components/app/overview/apikey-info-panel/cloud.spec.tsx b/web/app/components/app/overview/apikey-info-panel/cloud.spec.tsx
new file mode 100644
index 0000000000..c7cb061fde
--- /dev/null
+++ b/web/app/components/app/overview/apikey-info-panel/cloud.spec.tsx
@@ -0,0 +1,122 @@
+import { cleanup, screen } from '@testing-library/react'
+import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
+import {
+ assertions,
+ clearAllMocks,
+ defaultModalContext,
+ interactions,
+ mockUseModalContext,
+ scenarios,
+ textKeys,
+} from './apikey-info-panel.test-utils'
+
+// Mock config for Cloud edition
+jest.mock('@/config', () => ({
+ IS_CE_EDITION: false, // Test Cloud edition
+}))
+
+afterEach(cleanup)
+
+describe('APIKeyInfoPanel - Cloud Edition', () => {
+ const mockSetShowAccountSettingModal = jest.fn()
+
+ beforeEach(() => {
+ clearAllMocks()
+ mockUseModalContext.mockReturnValue({
+ ...defaultModalContext,
+ setShowAccountSettingModal: mockSetShowAccountSettingModal,
+ })
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing when API key is not set', () => {
+ scenarios.withAPIKeyNotSet()
+ assertions.shouldRenderMainButton()
+ })
+
+ it('should not render when API key is already set', () => {
+ const { container } = scenarios.withAPIKeySet()
+ assertions.shouldNotRender(container)
+ })
+
+ it('should not render when panel is hidden by user', () => {
+ const { container } = scenarios.withAPIKeyNotSet()
+ interactions.clickCloseButton(container)
+ assertions.shouldNotRender(container)
+ })
+ })
+
+ describe('Cloud Edition Content', () => {
+ it('should display cloud version title', () => {
+ scenarios.withAPIKeyNotSet()
+ expect(screen.getByText(textKeys.cloud.trialTitle)).toBeInTheDocument()
+ })
+
+ it('should display emoji for cloud version', () => {
+ const { container } = scenarios.withAPIKeyNotSet()
+ expect(container.querySelector('em-emoji')).toBeInTheDocument()
+ expect(container.querySelector('em-emoji')).toHaveAttribute('id', '😀')
+ })
+
+ it('should display cloud version description', () => {
+ scenarios.withAPIKeyNotSet()
+ expect(screen.getByText(textKeys.cloud.trialDescription)).toBeInTheDocument()
+ })
+
+ it('should not render external link for cloud version', () => {
+ const { container } = scenarios.withAPIKeyNotSet()
+ expect(container.querySelector('a[href="https://cloud.dify.ai/apps"]')).not.toBeInTheDocument()
+ })
+
+ it('should display set API button text', () => {
+ scenarios.withAPIKeyNotSet()
+ expect(screen.getByText(textKeys.cloud.setAPIBtn)).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call setShowAccountSettingModal when set API button is clicked', () => {
+ scenarios.withMockModal(mockSetShowAccountSettingModal)
+
+ interactions.clickMainButton()
+
+ expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
+ payload: ACCOUNT_SETTING_TAB.PROVIDER,
+ })
+ })
+
+ it('should hide panel when close button is clicked', () => {
+ const { container } = scenarios.withAPIKeyNotSet()
+ expect(container.firstChild).toBeInTheDocument()
+
+ interactions.clickCloseButton(container)
+ assertions.shouldNotRender(container)
+ })
+ })
+
+ describe('Props and Styling', () => {
+ it('should render button with primary variant', () => {
+ scenarios.withAPIKeyNotSet()
+ const button = screen.getByRole('button')
+ expect(button).toHaveClass('btn-primary')
+ })
+
+ it('should render panel container with correct classes', () => {
+ const { container } = scenarios.withAPIKeyNotSet()
+ const panel = container.firstChild as HTMLElement
+ assertions.shouldHavePanelStyling(panel)
+ })
+ })
+
+ describe('Accessibility', () => {
+ it('should have button with proper role', () => {
+ scenarios.withAPIKeyNotSet()
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should have clickable close button', () => {
+ const { container } = scenarios.withAPIKeyNotSet()
+ assertions.shouldHaveCloseButton(container)
+ })
+ })
+})
diff --git a/web/app/components/app/overview/apikey-info-panel/index.spec.tsx b/web/app/components/app/overview/apikey-info-panel/index.spec.tsx
new file mode 100644
index 0000000000..62eeb4299e
--- /dev/null
+++ b/web/app/components/app/overview/apikey-info-panel/index.spec.tsx
@@ -0,0 +1,162 @@
+import { cleanup, screen } from '@testing-library/react'
+import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
+import {
+ assertions,
+ clearAllMocks,
+ defaultModalContext,
+ interactions,
+ mockUseModalContext,
+ scenarios,
+ textKeys,
+} from './apikey-info-panel.test-utils'
+
+// Mock config for CE edition
+jest.mock('@/config', () => ({
+ IS_CE_EDITION: true, // Test CE edition by default
+}))
+
+afterEach(cleanup)
+
+describe('APIKeyInfoPanel - Community Edition', () => {
+ const mockSetShowAccountSettingModal = jest.fn()
+
+ beforeEach(() => {
+ clearAllMocks()
+ mockUseModalContext.mockReturnValue({
+ ...defaultModalContext,
+ setShowAccountSettingModal: mockSetShowAccountSettingModal,
+ })
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing when API key is not set', () => {
+ scenarios.withAPIKeyNotSet()
+ assertions.shouldRenderMainButton()
+ })
+
+ it('should not render when API key is already set', () => {
+ const { container } = scenarios.withAPIKeySet()
+ assertions.shouldNotRender(container)
+ })
+
+ it('should not render when panel is hidden by user', () => {
+ const { container } = scenarios.withAPIKeyNotSet()
+ interactions.clickCloseButton(container)
+ assertions.shouldNotRender(container)
+ })
+ })
+
+ describe('Content Display', () => {
+ it('should display self-host title content', () => {
+ scenarios.withAPIKeyNotSet()
+
+ expect(screen.getByText(textKeys.selfHost.titleRow1)).toBeInTheDocument()
+ expect(screen.getByText(textKeys.selfHost.titleRow2)).toBeInTheDocument()
+ })
+
+ it('should display set API button text', () => {
+ scenarios.withAPIKeyNotSet()
+ expect(screen.getByText(textKeys.selfHost.setAPIBtn)).toBeInTheDocument()
+ })
+
+ it('should render external link with correct href for self-host version', () => {
+ const { container } = scenarios.withAPIKeyNotSet()
+ const link = container.querySelector('a[href="https://cloud.dify.ai/apps"]')
+
+ expect(link).toBeInTheDocument()
+ expect(link).toHaveAttribute('target', '_blank')
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer')
+ expect(link).toHaveTextContent(textKeys.selfHost.tryCloud)
+ })
+
+ it('should have external link with proper styling for self-host version', () => {
+ const { container } = scenarios.withAPIKeyNotSet()
+ const link = container.querySelector('a[href="https://cloud.dify.ai/apps"]')
+
+ expect(link).toHaveClass(
+ 'mt-2',
+ 'flex',
+ 'h-[26px]',
+ 'items-center',
+ 'space-x-1',
+ 'p-1',
+ 'text-xs',
+ 'font-medium',
+ 'text-[#155EEF]',
+ )
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call setShowAccountSettingModal when set API button is clicked', () => {
+ scenarios.withMockModal(mockSetShowAccountSettingModal)
+
+ interactions.clickMainButton()
+
+ expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
+ payload: ACCOUNT_SETTING_TAB.PROVIDER,
+ })
+ })
+
+ it('should hide panel when close button is clicked', () => {
+ const { container } = scenarios.withAPIKeyNotSet()
+ expect(container.firstChild).toBeInTheDocument()
+
+ interactions.clickCloseButton(container)
+ assertions.shouldNotRender(container)
+ })
+ })
+
+ describe('Props and Styling', () => {
+ it('should render button with primary variant', () => {
+ scenarios.withAPIKeyNotSet()
+ const button = screen.getByRole('button')
+ expect(button).toHaveClass('btn-primary')
+ })
+
+ it('should render panel container with correct classes', () => {
+ const { container } = scenarios.withAPIKeyNotSet()
+ const panel = container.firstChild as HTMLElement
+ assertions.shouldHavePanelStyling(panel)
+ })
+ })
+
+ describe('State Management', () => {
+ it('should start with visible panel (isShow: true)', () => {
+ scenarios.withAPIKeyNotSet()
+ assertions.shouldRenderMainButton()
+ })
+
+ it('should toggle visibility when close button is clicked', () => {
+ const { container } = scenarios.withAPIKeyNotSet()
+ expect(container.firstChild).toBeInTheDocument()
+
+ interactions.clickCloseButton(container)
+ assertions.shouldNotRender(container)
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle provider context loading state', () => {
+ scenarios.withAPIKeyNotSet({
+ providerContext: {
+ modelProviders: [],
+ textGenerationModelList: [],
+ },
+ })
+ assertions.shouldRenderMainButton()
+ })
+ })
+
+ describe('Accessibility', () => {
+ it('should have button with proper role', () => {
+ scenarios.withAPIKeyNotSet()
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should have clickable close button', () => {
+ const { container } = scenarios.withAPIKeyNotSet()
+ assertions.shouldHaveCloseButton(container)
+ })
+ })
+})
From 37d4dbeb96aeded8ff220c649c559951cdbdf93a Mon Sep 17 00:00:00 2001
From: -LAN-
Date: Tue, 16 Dec 2025 15:39:42 +0800
Subject: [PATCH 07/10] feat: Remove TLS 1.1 from default NGINX protocols
(#29728)
---
docker/.env.example | 2 +-
docker/docker-compose-template.yaml | 2 +-
docker/docker-compose.yaml | 4 ++--
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/docker/.env.example b/docker/.env.example
index feca68fa02..3317fb3d9c 100644
--- a/docker/.env.example
+++ b/docker/.env.example
@@ -1229,7 +1229,7 @@ NGINX_SSL_PORT=443
# and modify the env vars below accordingly.
NGINX_SSL_CERT_FILENAME=dify.crt
NGINX_SSL_CERT_KEY_FILENAME=dify.key
-NGINX_SSL_PROTOCOLS=TLSv1.1 TLSv1.2 TLSv1.3
+NGINX_SSL_PROTOCOLS=TLSv1.2 TLSv1.3
# Nginx performance tuning
NGINX_WORKER_PROCESSES=auto
diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml
index 6ba3409288..4f6194b9e4 100644
--- a/docker/docker-compose-template.yaml
+++ b/docker/docker-compose-template.yaml
@@ -414,7 +414,7 @@ services:
# and modify the env vars below in .env if HTTPS_ENABLED is true.
NGINX_SSL_CERT_FILENAME: ${NGINX_SSL_CERT_FILENAME:-dify.crt}
NGINX_SSL_CERT_KEY_FILENAME: ${NGINX_SSL_CERT_KEY_FILENAME:-dify.key}
- NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.1 TLSv1.2 TLSv1.3}
+ NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.2 TLSv1.3}
NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto}
NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M}
NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65}
diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml
index 1e50792b6d..5c53788234 100644
--- a/docker/docker-compose.yaml
+++ b/docker/docker-compose.yaml
@@ -528,7 +528,7 @@ x-shared-env: &shared-api-worker-env
NGINX_SSL_PORT: ${NGINX_SSL_PORT:-443}
NGINX_SSL_CERT_FILENAME: ${NGINX_SSL_CERT_FILENAME:-dify.crt}
NGINX_SSL_CERT_KEY_FILENAME: ${NGINX_SSL_CERT_KEY_FILENAME:-dify.key}
- NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.1 TLSv1.2 TLSv1.3}
+ NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.2 TLSv1.3}
NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto}
NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M}
NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65}
@@ -1071,7 +1071,7 @@ services:
# and modify the env vars below in .env if HTTPS_ENABLED is true.
NGINX_SSL_CERT_FILENAME: ${NGINX_SSL_CERT_FILENAME:-dify.crt}
NGINX_SSL_CERT_KEY_FILENAME: ${NGINX_SSL_CERT_KEY_FILENAME:-dify.key}
- NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.1 TLSv1.2 TLSv1.3}
+ NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.2 TLSv1.3}
NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto}
NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M}
NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65}
From 4589157963e0a492e59268535cefb46e9e39ef7e Mon Sep 17 00:00:00 2001
From: yyh <92089059+lyzno1@users.noreply.github.com>
Date: Tue, 16 Dec 2025 15:44:51 +0800
Subject: [PATCH 08/10] test: Add comprehensive Jest test for AppCard component
(#29667)
---
.../templates/component-test.template.tsx | 21 +-
.../create-app-dialog/app-card/index.spec.tsx | 347 ++++++++++++++++++
.../app/create-app-dialog/app-card/index.tsx | 17 +-
.../apikey-info-panel.test-utils.tsx | 12 +-
4 files changed, 377 insertions(+), 20 deletions(-)
create mode 100644 web/app/components/app/create-app-dialog/app-card/index.spec.tsx
diff --git a/.claude/skills/frontend-testing/templates/component-test.template.tsx b/.claude/skills/frontend-testing/templates/component-test.template.tsx
index 9b1542b676..f1ea71a3fd 100644
--- a/.claude/skills/frontend-testing/templates/component-test.template.tsx
+++ b/.claude/skills/frontend-testing/templates/component-test.template.tsx
@@ -26,13 +26,20 @@ import userEvent from '@testing-library/user-event'
// WHY: Mocks must be hoisted to top of file (Jest requirement).
// They run BEFORE imports, so keep them before component imports.
-// i18n (always required in Dify)
-// WHY: Returns key instead of translation so tests don't depend on i18n files
-jest.mock('react-i18next', () => ({
- useTranslation: () => ({
- t: (key: string) => key,
- }),
-}))
+// i18n (automatically mocked)
+// WHY: Shared mock at web/__mocks__/react-i18next.ts is auto-loaded by Jest
+// No explicit mock needed - it returns translation keys as-is
+// Override only if custom translations are required:
+// jest.mock('react-i18next', () => ({
+// useTranslation: () => ({
+// t: (key: string) => {
+// const customTranslations: Record = {
+// 'my.custom.key': 'Custom Translation',
+// }
+// return customTranslations[key] || key
+// },
+// }),
+// }))
// Router (if component uses useRouter, usePathname, useSearchParams)
// WHY: Isolates tests from Next.js routing, enables testing navigation behavior
diff --git a/web/app/components/app/create-app-dialog/app-card/index.spec.tsx b/web/app/components/app/create-app-dialog/app-card/index.spec.tsx
new file mode 100644
index 0000000000..3122f06ec3
--- /dev/null
+++ b/web/app/components/app/create-app-dialog/app-card/index.spec.tsx
@@ -0,0 +1,347 @@
+import { render, screen, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import AppCard from './index'
+import type { AppIconType } from '@/types/app'
+import { AppModeEnum } from '@/types/app'
+import type { App } from '@/models/explore'
+
+jest.mock('@heroicons/react/20/solid', () => ({
+ PlusIcon: ({ className }: any) => +
,
+}))
+
+const mockApp: App = {
+ app: {
+ id: 'test-app-id',
+ mode: AppModeEnum.CHAT,
+ icon_type: 'emoji' as AppIconType,
+ icon: '🤖',
+ icon_background: '#FFEAD5',
+ icon_url: '',
+ name: 'Test Chat App',
+ description: 'A test chat application for demonstration purposes',
+ use_icon_as_answer_icon: false,
+ },
+ app_id: 'test-app-id',
+ description: 'A comprehensive chat application template',
+ copyright: 'Test Corp',
+ privacy_policy: null,
+ custom_disclaimer: null,
+ category: 'Assistant',
+ position: 1,
+ is_listed: true,
+ install_count: 100,
+ installed: false,
+ editable: true,
+ is_agent: false,
+}
+
+describe('AppCard', () => {
+ const defaultProps = {
+ app: mockApp,
+ canCreate: true,
+ onCreate: jest.fn(),
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const { container } = render()
+
+ expect(container.querySelector('em-emoji')).toBeInTheDocument()
+ expect(screen.getByText('Test Chat App')).toBeInTheDocument()
+ expect(screen.getByText(mockApp.description)).toBeInTheDocument()
+ })
+
+ it('should render app type icon and label', () => {
+ const { container } = render()
+
+ expect(container.querySelector('svg')).toBeInTheDocument()
+ expect(screen.getByText('app.typeSelector.chatbot')).toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ describe('canCreate behavior', () => {
+ it('should show create button when canCreate is true', () => {
+ render()
+
+ const button = screen.getByRole('button', { name: /app\.newApp\.useTemplate/ })
+ expect(button).toBeInTheDocument()
+ })
+
+ it('should hide create button when canCreate is false', () => {
+ render()
+
+ const button = screen.queryByRole('button', { name: /app\.newApp\.useTemplate/ })
+ expect(button).not.toBeInTheDocument()
+ })
+ })
+
+ it('should display app name from appBasicInfo', () => {
+ const customApp = {
+ ...mockApp,
+ app: {
+ ...mockApp.app,
+ name: 'Custom App Name',
+ },
+ }
+ render()
+
+ expect(screen.getByText('Custom App Name')).toBeInTheDocument()
+ })
+
+ it('should display app description from app level', () => {
+ const customApp = {
+ ...mockApp,
+ description: 'Custom description for the app',
+ }
+ render()
+
+ expect(screen.getByText('Custom description for the app')).toBeInTheDocument()
+ })
+
+ it('should truncate long app names', () => {
+ const longNameApp = {
+ ...mockApp,
+ app: {
+ ...mockApp.app,
+ name: 'This is a very long app name that should be truncated with line-clamp-1',
+ },
+ }
+ render()
+
+ const nameElement = screen.getByTitle('This is a very long app name that should be truncated with line-clamp-1')
+ expect(nameElement).toBeInTheDocument()
+ })
+ })
+
+ describe('App Modes - Data Driven Tests', () => {
+ const testCases = [
+ {
+ mode: AppModeEnum.CHAT,
+ expectedLabel: 'app.typeSelector.chatbot',
+ description: 'Chat application mode',
+ },
+ {
+ mode: AppModeEnum.AGENT_CHAT,
+ expectedLabel: 'app.typeSelector.agent',
+ description: 'Agent chat mode',
+ },
+ {
+ mode: AppModeEnum.COMPLETION,
+ expectedLabel: 'app.typeSelector.completion',
+ description: 'Completion mode',
+ },
+ {
+ mode: AppModeEnum.ADVANCED_CHAT,
+ expectedLabel: 'app.typeSelector.advanced',
+ description: 'Advanced chat mode',
+ },
+ {
+ mode: AppModeEnum.WORKFLOW,
+ expectedLabel: 'app.typeSelector.workflow',
+ description: 'Workflow mode',
+ },
+ ]
+
+ testCases.forEach(({ mode, expectedLabel, description }) => {
+ it(`should display correct type label for ${description}`, () => {
+ const appWithMode = {
+ ...mockApp,
+ app: {
+ ...mockApp.app,
+ mode,
+ },
+ }
+ render()
+
+ expect(screen.getByText(expectedLabel)).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Icon Type Tests', () => {
+ it('should render emoji icon without image element', () => {
+ const appWithIcon = {
+ ...mockApp,
+ app: {
+ ...mockApp.app,
+ icon_type: 'emoji' as AppIconType,
+ icon: '🤖',
+ },
+ }
+ const { container } = render()
+
+ const card = container.firstElementChild as HTMLElement
+ expect(within(card).queryByRole('img', { name: 'app icon' })).not.toBeInTheDocument()
+ expect(card.querySelector('em-emoji')).toBeInTheDocument()
+ })
+
+ it('should prioritize icon_url when both icon and icon_url are provided', () => {
+ const appWithImageUrl = {
+ ...mockApp,
+ app: {
+ ...mockApp.app,
+ icon_type: 'image' as AppIconType,
+ icon: 'local-icon.png',
+ icon_url: 'https://example.com/remote-icon.png',
+ },
+ }
+ render()
+
+ expect(screen.getByRole('img', { name: 'app icon' })).toHaveAttribute('src', 'https://example.com/remote-icon.png')
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onCreate when create button is clicked', async () => {
+ const mockOnCreate = jest.fn()
+ render()
+
+ const button = screen.getByRole('button', { name: /app\.newApp\.useTemplate/ })
+ await userEvent.click(button)
+ expect(mockOnCreate).toHaveBeenCalledTimes(1)
+ })
+
+ it('should handle click on card itself', async () => {
+ const mockOnCreate = jest.fn()
+ const { container } = render()
+
+ const card = container.firstElementChild as HTMLElement
+ await userEvent.click(card)
+ // Note: Card click doesn't trigger onCreate, only the button does
+ expect(mockOnCreate).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Keyboard Accessibility', () => {
+ it('should allow the create button to be focused', async () => {
+ const mockOnCreate = jest.fn()
+ render()
+
+ await userEvent.tab()
+ const button = screen.getByRole('button', { name: /app\.newApp\.useTemplate/ }) as HTMLButtonElement
+
+ // Test that button can be focused
+ expect(button).toHaveFocus()
+
+ // Test click event works (keyboard events on buttons typically trigger click)
+ await userEvent.click(button)
+ expect(mockOnCreate).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle app with null icon_type', () => {
+ const appWithNullIcon = {
+ ...mockApp,
+ app: {
+ ...mockApp.app,
+ icon_type: null,
+ },
+ }
+ const { container } = render()
+
+ const appIcon = container.querySelector('em-emoji')
+ expect(appIcon).toBeInTheDocument()
+ // AppIcon component should handle null icon_type gracefully
+ })
+
+ it('should handle app with empty description', () => {
+ const appWithEmptyDesc = {
+ ...mockApp,
+ description: '',
+ }
+ const { container } = render()
+
+ const descriptionContainer = container.querySelector('.line-clamp-3')
+ expect(descriptionContainer).toBeInTheDocument()
+ expect(descriptionContainer).toHaveTextContent('')
+ })
+
+ it('should handle app with very long description', () => {
+ const longDescription = 'This is a very long description that should be truncated with line-clamp-3. '.repeat(5)
+ const appWithLongDesc = {
+ ...mockApp,
+ description: longDescription,
+ }
+ render()
+
+ expect(screen.getByText(/This is a very long description/)).toBeInTheDocument()
+ })
+
+ it('should handle app with special characters in name', () => {
+ const appWithSpecialChars = {
+ ...mockApp,
+ app: {
+ ...mockApp.app,
+ name: 'App & Special "Chars"',
+ },
+ }
+ render()
+
+ expect(screen.getByText('App & Special "Chars"')).toBeInTheDocument()
+ })
+
+ it('should handle onCreate function throwing error', async () => {
+ const errorOnCreate = jest.fn(() => {
+ throw new Error('Create failed')
+ })
+
+ // Mock console.error to avoid test output noise
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn())
+
+ render()
+
+ const button = screen.getByRole('button', { name: /app\.newApp\.useTemplate/ })
+ let capturedError: unknown
+ try {
+ await userEvent.click(button)
+ }
+ catch (err) {
+ capturedError = err
+ }
+ expect(errorOnCreate).toHaveBeenCalledTimes(1)
+ expect(consoleSpy).toHaveBeenCalled()
+ if (capturedError instanceof Error)
+ expect(capturedError.message).toContain('Create failed')
+
+ consoleSpy.mockRestore()
+ })
+ })
+
+ describe('Accessibility', () => {
+ it('should have proper elements for accessibility', () => {
+ const { container } = render()
+
+ expect(container.querySelector('em-emoji')).toBeInTheDocument()
+ expect(container.querySelector('svg')).toBeInTheDocument()
+ })
+
+ it('should have title attribute for app name when truncated', () => {
+ render()
+
+ const nameElement = screen.getByText('Test Chat App')
+ expect(nameElement).toHaveAttribute('title', 'Test Chat App')
+ })
+
+ it('should have accessible button with proper label', () => {
+ render()
+
+ const button = screen.getByRole('button', { name: /app\.newApp\.useTemplate/ })
+ expect(button).toBeEnabled()
+ expect(button).toHaveTextContent('app.newApp.useTemplate')
+ })
+ })
+
+ describe('User-Visible Behavior Tests', () => {
+ it('should show plus icon in create button', () => {
+ render()
+
+ expect(screen.getByTestId('plus-icon')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/app/create-app-dialog/app-card/index.tsx b/web/app/components/app/create-app-dialog/app-card/index.tsx
index 7f7ede0065..a3bf91cb5d 100644
--- a/web/app/components/app/create-app-dialog/app-card/index.tsx
+++ b/web/app/components/app/create-app-dialog/app-card/index.tsx
@@ -15,6 +15,7 @@ export type AppCardProps = {
const AppCard = ({
app,
+ canCreate,
onCreate,
}: AppCardProps) => {
const { t } = useTranslation()
@@ -45,14 +46,16 @@ const AppCard = ({
{app.description}
-
-
-
+ {canCreate && (
+
+
+
+
-
+ )}
)
}
diff --git a/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx b/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx
index 36a1c5a008..1b1e729546 100644
--- a/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx
+++ b/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx
@@ -188,15 +188,15 @@ export const interactions = {
// Text content keys for assertions
export const textKeys = {
selfHost: {
- titleRow1: 'appOverview.apiKeyInfo.selfHost.title.row1',
- titleRow2: 'appOverview.apiKeyInfo.selfHost.title.row2',
- setAPIBtn: 'appOverview.apiKeyInfo.setAPIBtn',
- tryCloud: 'appOverview.apiKeyInfo.tryCloud',
+ titleRow1: /appOverview\.apiKeyInfo\.selfHost\.title\.row1/,
+ titleRow2: /appOverview\.apiKeyInfo\.selfHost\.title\.row2/,
+ setAPIBtn: /appOverview\.apiKeyInfo\.setAPIBtn/,
+ tryCloud: /appOverview\.apiKeyInfo\.tryCloud/,
},
cloud: {
- trialTitle: 'appOverview.apiKeyInfo.cloud.trial.title',
+ trialTitle: /appOverview\.apiKeyInfo\.cloud\.trial\.title/,
trialDescription: /appOverview\.apiKeyInfo\.cloud\.trial\.description/,
- setAPIBtn: 'appOverview.apiKeyInfo.setAPIBtn',
+ setAPIBtn: /appOverview\.apiKeyInfo\.setAPIBtn/,
},
}
From 0749e6e090c6ca23778d0121f9e7e51c326cf4ee Mon Sep 17 00:00:00 2001
From: -LAN-
Date: Tue, 16 Dec 2025 16:35:55 +0800
Subject: [PATCH 09/10] test: Stabilize sharded Redis broadcast
multi-subscriber test (#29733)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
---
.../redis/test_sharded_channel.py | 24 ++++++++++++++++---
1 file changed, 21 insertions(+), 3 deletions(-)
diff --git a/api/tests/test_containers_integration_tests/libs/broadcast_channel/redis/test_sharded_channel.py b/api/tests/test_containers_integration_tests/libs/broadcast_channel/redis/test_sharded_channel.py
index af60adf1fb..d612e70910 100644
--- a/api/tests/test_containers_integration_tests/libs/broadcast_channel/redis/test_sharded_channel.py
+++ b/api/tests/test_containers_integration_tests/libs/broadcast_channel/redis/test_sharded_channel.py
@@ -113,16 +113,31 @@ class TestShardedRedisBroadcastChannelIntegration:
topic = broadcast_channel.topic(topic_name)
producer = topic.as_producer()
subscriptions = [topic.subscribe() for _ in range(subscriber_count)]
+ ready_events = [threading.Event() for _ in range(subscriber_count)]
def producer_thread():
- time.sleep(0.2) # Allow all subscribers to connect
+ deadline = time.time() + 5.0
+ for ev in ready_events:
+ remaining = deadline - time.time()
+ if remaining <= 0:
+ break
+ if not ev.wait(timeout=max(0.0, remaining)):
+ pytest.fail("subscriber did not become ready before publish deadline")
producer.publish(message)
time.sleep(0.2)
for sub in subscriptions:
sub.close()
- def consumer_thread(subscription: Subscription) -> list[bytes]:
+ def consumer_thread(subscription: Subscription, ready_event: threading.Event) -> list[bytes]:
received_msgs = []
+ # Prime subscription so the underlying Pub/Sub listener thread starts before publishing
+ try:
+ _ = subscription.receive(0.01)
+ except SubscriptionClosedError:
+ return received_msgs
+ finally:
+ ready_event.set()
+
while True:
try:
msg = subscription.receive(0.1)
@@ -137,7 +152,10 @@ class TestShardedRedisBroadcastChannelIntegration:
with ThreadPoolExecutor(max_workers=subscriber_count + 1) as executor:
producer_future = executor.submit(producer_thread)
- consumer_futures = [executor.submit(consumer_thread, subscription) for subscription in subscriptions]
+ consumer_futures = [
+ executor.submit(consumer_thread, subscription, ready_events[idx])
+ for idx, subscription in enumerate(subscriptions)
+ ]
producer_future.result(timeout=10.0)
msgs_by_consumers = []
From d2b63df7a12e5115a6104a50edbc6d2a68f8dd8d Mon Sep 17 00:00:00 2001
From: Joel
Date: Tue, 16 Dec 2025 16:39:04 +0800
Subject: [PATCH 10/10] chore: tests for components in config (#29739)
---
.../cannot-query-dataset.spec.tsx | 22 +++++++++
.../warning-mask/formatting-changed.spec.tsx | 39 ++++++++++++++++
.../warning-mask/has-not-set-api.spec.tsx | 26 +++++++++++
.../base/warning-mask/index.spec.tsx | 25 +++++++++++
.../select-type-item/index.spec.tsx | 45 +++++++++++++++++++
5 files changed, 157 insertions(+)
create mode 100644 web/app/components/app/configuration/base/warning-mask/cannot-query-dataset.spec.tsx
create mode 100644 web/app/components/app/configuration/base/warning-mask/formatting-changed.spec.tsx
create mode 100644 web/app/components/app/configuration/base/warning-mask/has-not-set-api.spec.tsx
create mode 100644 web/app/components/app/configuration/base/warning-mask/index.spec.tsx
create mode 100644 web/app/components/app/configuration/config-var/select-type-item/index.spec.tsx
diff --git a/web/app/components/app/configuration/base/warning-mask/cannot-query-dataset.spec.tsx b/web/app/components/app/configuration/base/warning-mask/cannot-query-dataset.spec.tsx
new file mode 100644
index 0000000000..d625e9fb72
--- /dev/null
+++ b/web/app/components/app/configuration/base/warning-mask/cannot-query-dataset.spec.tsx
@@ -0,0 +1,22 @@
+import React from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import CannotQueryDataset from './cannot-query-dataset'
+
+describe('CannotQueryDataset WarningMask', () => {
+ test('should render dataset warning copy and action button', () => {
+ const onConfirm = jest.fn()
+ render()
+
+ expect(screen.getByText('appDebug.feature.dataSet.queryVariable.unableToQueryDataSet')).toBeInTheDocument()
+ expect(screen.getByText('appDebug.feature.dataSet.queryVariable.unableToQueryDataSetTip')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'appDebug.feature.dataSet.queryVariable.ok' })).toBeInTheDocument()
+ })
+
+ test('should invoke onConfirm when OK button clicked', () => {
+ const onConfirm = jest.fn()
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'appDebug.feature.dataSet.queryVariable.ok' }))
+ expect(onConfirm).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/web/app/components/app/configuration/base/warning-mask/formatting-changed.spec.tsx b/web/app/components/app/configuration/base/warning-mask/formatting-changed.spec.tsx
new file mode 100644
index 0000000000..a968bde272
--- /dev/null
+++ b/web/app/components/app/configuration/base/warning-mask/formatting-changed.spec.tsx
@@ -0,0 +1,39 @@
+import React from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import FormattingChanged from './formatting-changed'
+
+describe('FormattingChanged WarningMask', () => {
+ test('should display translation text and both actions', () => {
+ const onConfirm = jest.fn()
+ const onCancel = jest.fn()
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('appDebug.formattingChangedTitle')).toBeInTheDocument()
+ expect(screen.getByText('appDebug.formattingChangedText')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /common\.operation\.refresh/ })).toBeInTheDocument()
+ })
+
+ test('should call callbacks when buttons are clicked', () => {
+ const onConfirm = jest.fn()
+ const onCancel = jest.fn()
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: /common\.operation\.refresh/ }))
+ fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
+
+ expect(onConfirm).toHaveBeenCalledTimes(1)
+ expect(onCancel).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/web/app/components/app/configuration/base/warning-mask/has-not-set-api.spec.tsx b/web/app/components/app/configuration/base/warning-mask/has-not-set-api.spec.tsx
new file mode 100644
index 0000000000..46608374da
--- /dev/null
+++ b/web/app/components/app/configuration/base/warning-mask/has-not-set-api.spec.tsx
@@ -0,0 +1,26 @@
+import React from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import HasNotSetAPI from './has-not-set-api'
+
+describe('HasNotSetAPI WarningMask', () => {
+ test('should show default title when trial not finished', () => {
+ render()
+
+ expect(screen.getByText('appDebug.notSetAPIKey.title')).toBeInTheDocument()
+ expect(screen.getByText('appDebug.notSetAPIKey.description')).toBeInTheDocument()
+ })
+
+ test('should show trail finished title when flag is true', () => {
+ render()
+
+ expect(screen.getByText('appDebug.notSetAPIKey.trailFinished')).toBeInTheDocument()
+ })
+
+ test('should call onSetting when primary button clicked', () => {
+ const onSetting = jest.fn()
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'appDebug.notSetAPIKey.settingBtn' }))
+ expect(onSetting).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/web/app/components/app/configuration/base/warning-mask/index.spec.tsx b/web/app/components/app/configuration/base/warning-mask/index.spec.tsx
new file mode 100644
index 0000000000..6d533a423d
--- /dev/null
+++ b/web/app/components/app/configuration/base/warning-mask/index.spec.tsx
@@ -0,0 +1,25 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import WarningMask from './index'
+
+describe('WarningMask', () => {
+ // Rendering of title, description, and footer content
+ describe('Rendering', () => {
+ test('should display provided title, description, and footer node', () => {
+ const footer =
+ // Arrange
+ render(
+ ,
+ )
+
+ // Assert
+ expect(screen.getByText('Access Restricted')).toBeInTheDocument()
+ expect(screen.getByText('Only workspace owners may modify this section.')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/app/configuration/config-var/select-type-item/index.spec.tsx b/web/app/components/app/configuration/config-var/select-type-item/index.spec.tsx
new file mode 100644
index 0000000000..469164e607
--- /dev/null
+++ b/web/app/components/app/configuration/config-var/select-type-item/index.spec.tsx
@@ -0,0 +1,45 @@
+import React from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import SelectTypeItem from './index'
+import { InputVarType } from '@/app/components/workflow/types'
+
+describe('SelectTypeItem', () => {
+ // Rendering pathways based on type and selection state
+ describe('Rendering', () => {
+ test('should render ok', () => {
+ // Arrange
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ expect(screen.getByText('appDebug.variableConfig.text-input')).toBeInTheDocument()
+ expect(container.querySelector('svg')).not.toBeNull()
+ })
+ })
+
+ // User interaction outcomes
+ describe('Interactions', () => {
+ test('should trigger onClick when item is pressed', () => {
+ const handleClick = jest.fn()
+ // Arrange
+ render(
+ ,
+ )
+
+ // Act
+ fireEvent.click(screen.getByText('appDebug.variableConfig.paragraph'))
+
+ // Assert
+ expect(handleClick).toHaveBeenCalledTimes(1)
+ })
+ })
+})