Merge branch 'main' into feat-agent-mask

This commit is contained in:
GuanMu 2025-12-16 16:42:24 +08:00 committed by GitHub
commit 2cea4e733d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 4216 additions and 102 deletions

View File

@ -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<string, string> = {
// '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

View File

@ -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)

View File

@ -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(

View File

@ -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)

View File

@ -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."
)

View File

@ -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 = []

View File

@ -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
@ -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)

View File

@ -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}

View File

@ -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}
@ -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}
@ -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}

View File

@ -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.

1
web/CLAUDE.md Symbolic link
View File

@ -0,0 +1 @@
AGENTS.md

View File

@ -19,7 +19,13 @@
*/
export const useTranslation = () => ({
t: (key: string) => key,
t: (key: string, options?: Record<string, unknown>) => {
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(),

View File

@ -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(<CannotQueryDataset onConfirm={onConfirm} />)
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(<CannotQueryDataset onConfirm={onConfirm} />)
fireEvent.click(screen.getByRole('button', { name: 'appDebug.feature.dataSet.queryVariable.ok' }))
expect(onConfirm).toHaveBeenCalledTimes(1)
})
})

View File

@ -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(
<FormattingChanged
onConfirm={onConfirm}
onCancel={onCancel}
/>,
)
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(
<FormattingChanged
onConfirm={onConfirm}
onCancel={onCancel}
/>,
)
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)
})
})

View File

@ -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(<HasNotSetAPI isTrailFinished={false} onSetting={jest.fn()} />)
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(<HasNotSetAPI isTrailFinished onSetting={jest.fn()} />)
expect(screen.getByText('appDebug.notSetAPIKey.trailFinished')).toBeInTheDocument()
})
test('should call onSetting when primary button clicked', () => {
const onSetting = jest.fn()
render(<HasNotSetAPI isTrailFinished={false} onSetting={onSetting} />)
fireEvent.click(screen.getByRole('button', { name: 'appDebug.notSetAPIKey.settingBtn' }))
expect(onSetting).toHaveBeenCalledTimes(1)
})
})

View File

@ -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 = <button type="button">Retry</button>
// Arrange
render(
<WarningMask
title="Access Restricted"
description="Only workspace owners may modify this section."
footer={footer}
/>,
)
// 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()
})
})
})

View File

@ -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(
<SelectTypeItem
type={InputVarType.textInput}
selected={false}
onClick={jest.fn()}
/>,
)
// 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(
<SelectTypeItem
type={InputVarType.paragraph}
selected={false}
onClick={handleClick}
/>,
)
// Act
fireEvent.click(screen.getByText('appDebug.variableConfig.paragraph'))
// Assert
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -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) => <div data-testid="plus-icon" className={className} aria-label="Add icon">+</div>,
}))
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(<AppCard {...defaultProps} />)
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(<AppCard {...defaultProps} />)
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(<AppCard {...defaultProps} canCreate={true} />)
const button = screen.getByRole('button', { name: /app\.newApp\.useTemplate/ })
expect(button).toBeInTheDocument()
})
it('should hide create button when canCreate is false', () => {
render(<AppCard {...defaultProps} canCreate={false} />)
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(<AppCard {...defaultProps} app={customApp} />)
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(<AppCard {...defaultProps} app={customApp} />)
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(<AppCard {...defaultProps} app={longNameApp} />)
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(<AppCard {...defaultProps} app={appWithMode} />)
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(<AppCard {...defaultProps} app={appWithIcon} />)
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(<AppCard {...defaultProps} app={appWithImageUrl} />)
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(<AppCard {...defaultProps} onCreate={mockOnCreate} />)
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(<AppCard {...defaultProps} onCreate={mockOnCreate} />)
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(<AppCard {...defaultProps} onCreate={mockOnCreate} />)
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(<AppCard {...defaultProps} app={appWithNullIcon} />)
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(<AppCard {...defaultProps} app={appWithEmptyDesc} />)
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(<AppCard {...defaultProps} app={appWithLongDesc} />)
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 <script>alert("test")</script> & Special "Chars"',
},
}
render(<AppCard {...defaultProps} app={appWithSpecialChars} />)
expect(screen.getByText('App <script>alert("test")</script> & 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(<AppCard {...defaultProps} onCreate={errorOnCreate} />)
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(<AppCard {...defaultProps} />)
expect(container.querySelector('em-emoji')).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should have title attribute for app name when truncated', () => {
render(<AppCard {...defaultProps} />)
const nameElement = screen.getByText('Test Chat App')
expect(nameElement).toHaveAttribute('title', 'Test Chat App')
})
it('should have accessible button with proper label', () => {
render(<AppCard {...defaultProps} />)
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(<AppCard {...defaultProps} />)
expect(screen.getByTestId('plus-icon')).toBeInTheDocument()
})
})
})

View File

@ -15,6 +15,7 @@ export type AppCardProps = {
const AppCard = ({
app,
canCreate,
onCreate,
}: AppCardProps) => {
const { t } = useTranslation()
@ -45,14 +46,16 @@ const AppCard = ({
{app.description}
</div>
</div>
<div className={cn('absolute bottom-0 left-0 right-0 hidden bg-gradient-to-t from-components-panel-gradient-2 from-[60.27%] to-transparent p-4 pt-8 group-hover:flex')}>
<div className={cn('flex h-8 w-full items-center space-x-2')}>
<Button variant='primary' className='grow' onClick={() => onCreate()}>
<PlusIcon className='mr-1 h-4 w-4' />
<span className='text-xs'>{t('app.newApp.useTemplate')}</span>
</Button>
{canCreate && (
<div className={cn('absolute bottom-0 left-0 right-0 hidden bg-gradient-to-t from-components-panel-gradient-2 from-[60.27%] to-transparent p-4 pt-8 group-hover:flex')}>
<div className={cn('flex h-8 w-full items-center space-x-2')}>
<Button variant='primary' className='grow' onClick={() => onCreate()}>
<PlusIcon className='mr-1 h-4 w-4' />
<span className='text-xs'>{t('app.newApp.useTemplate')}</span>
</Button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -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<typeof actualUseProviderContext>
const mockUseModalContext = actualUseModalContext as jest.MockedFunction<typeof actualUseModalContext>
// 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<typeof defaultProviderContext>
modalContext?: Partial<typeof defaultModalContext>
}
export type APIKeyInfoPanelRenderOptions = {
mockOverrides?: MockOverrides
} & Omit<RenderOptions, 'wrapper'>
// 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(<APIKeyInfoPanel />, 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 }

View File

@ -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)
})
})
})

View File

@ -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)
})
})
})

View File

@ -401,7 +401,6 @@ function AppCard({
/>
<CustomizeModal
isShow={showCustomizeModal}
linkUrl=""
onClose={() => setShowCustomizeModal(false)}
appId={appInfo.id}
api_base_url={appInfo.api_base_url}

View File

@ -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(<CustomizeModal {...props} />)
// 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(<CustomizeModal {...props} />)
// 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(<CustomizeModal {...props} />)
// 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(<CustomizeModal {...props} />)
// 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(<CustomizeModal {...props} />)
// 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(<CustomizeModal {...props} />)
// 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(<CustomizeModal {...props} />)
// 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(<CustomizeModal {...props} />)
// 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(<CustomizeModal {...props} />)
// 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(<CustomizeModal {...props} />)
// 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(<CustomizeModal {...props} />)
// 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(<CustomizeModal {...props} />)
// 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(<CustomizeModal {...props} />)
// 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(<CustomizeModal {...props} />)
// 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(<CustomizeModal {...props} />)
// 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(<CustomizeModal {...props} />)
// 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(<CustomizeModal {...props} />)
// 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(<CustomizeModal {...props} />)
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(<CustomizeModal {...props} />)
// 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(<CustomizeModal {...props} />)
// 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(<CustomizeModal {...props} />)
// 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(<CustomizeModal {...props} />)
// 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(<CustomizeModal {...props} />)
// 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(<CustomizeModal {...props} />)
// 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(<CustomizeModal {...props} />)
// 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')
})
})
})
})

View File

@ -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

View File

@ -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(
<Button
plan={Plan.team}
isPlanDisabled
btnText="Get started"
handleGetPayUrl={handleGetPayUrl}
/>,
)
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(
<Button
plan={Plan.sandbox}
isPlanDisabled={false}
btnText="Start now"
handleGetPayUrl={handleGetPayUrl}
/>,
)
const button = screen.getByRole('button', { name: /Start now/i })
// Act
fireEvent.click(button)
// Assert
expect(handleGetPayUrl).toHaveBeenCalledTimes(1)
expect(button).not.toBeDisabled()
})
})
})

View File

@ -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: () => <div>Sandbox Icon</div>,
Professional: () => <div>Professional Icon</div>,
Team: () => <div>Team Icon</div>,
}))
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(
<CloudPlanItem
plan={Plan.sandbox}
currentPlan={Plan.sandbox}
planRange={PlanRange.monthly}
canPay
/>,
)
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(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.sandbox}
planRange={PlanRange.yearly}
canPay
/>,
)
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(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.team}
planRange={PlanRange.monthly}
canPay
/>,
)
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(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.sandbox}
planRange={PlanRange.monthly}
canPay
/>,
)
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<string>) => await cb())
mockUseAsyncWindowOpen.mockReturnValue(openWindow)
render(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.professional}
planRange={PlanRange.monthly}
canPay
/>,
)
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(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.sandbox}
planRange={PlanRange.monthly}
canPay
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }))
await waitFor(() => {
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'month')
expect(assignedHref).toBe('https://subscription.example')
})
})
})
})

View File

@ -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(<List plan={Plan.sandbox} />)
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(<List plan={Plan.professional} />)
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(<List plan={Plan.team} />)
expect(screen.getByText('billing.plansCommon.triggerEvents.unlimited')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.workflowExecution.priority')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.unlimitedApiRate')).toBeInTheDocument()
})
})

View File

@ -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 => (
<div data-testid={`cloud-plan-${props.plan}`} data-current-plan={props.currentPlan}>
Cloud {props.plan}
</div>
)),
}))
jest.mock('./self-hosted-plan-item', () => ({
__esModule: true,
default: jest.fn(props => (
<div data-testid={`self-plan-${props.plan}`}>
Self {props.plan}
</div>
)),
}))
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(
<Plans
plan={buildPlan(Plan.enterprise)}
currentPlan="cloud"
planRange={PlanRange.monthly}
canPay
/>,
)
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(
<Plans
plan={buildPlan(Plan.sandbox)}
currentPlan="self"
planRange={PlanRange.yearly}
canPay={false}
/>,
)
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)
})
})
})

View File

@ -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: () => <div>AwsMarketplaceLight</div>,
AwsMarketplaceDark: () => <div>AwsMarketplaceDark</div>,
}))
const mockUseTheme = useTheme as jest.MockedFunction<typeof useTheme>
beforeEach(() => {
jest.clearAllMocks()
mockUseTheme.mockReturnValue({ theme: Theme.light } as unknown as ReturnType<typeof useTheme>)
})
describe('SelfHostedPlanButton', () => {
test('should invoke handler when clicked', () => {
const handleGetPayUrl = jest.fn()
render(
<Button
plan={SelfHostedPlan.community}
handleGetPayUrl={handleGetPayUrl}
/>,
)
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(
<Button
plan={SelfHostedPlan.premium}
handleGetPayUrl={handleGetPayUrl}
/>,
)
expect(screen.getByText('AwsMarketplaceLight')).toBeInTheDocument()
})
test('should switch to dark AWS badge in dark theme', () => {
mockUseTheme.mockReturnValue({ theme: Theme.dark } as unknown as ReturnType<typeof useTheme>)
render(
<Button
plan={SelfHostedPlan.premium}
handleGetPayUrl={jest.fn()}
/>,
)
expect(screen.getByText('AwsMarketplaceDark')).toBeInTheDocument()
})
})

View File

@ -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<string, string[]> = {
'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<string, unknown>) => {
if (options?.returnObjects)
return featuresTranslations[key] || []
return key
},
}),
Trans: ({ i18nKey }: { i18nKey: string }) => <span>{i18nKey}</span>,
}))
jest.mock('../../../../base/toast', () => ({
__esModule: true,
default: {
notify: jest.fn(),
},
}))
jest.mock('@/context/app-context', () => ({
useAppContext: jest.fn(),
}))
jest.mock('../../assets', () => ({
Community: () => <div>Community Icon</div>,
Premium: () => <div>Premium Icon</div>,
Enterprise: () => <div>Enterprise Icon</div>,
PremiumNoise: () => <div>PremiumNoise</div>,
EnterpriseNoise: () => <div>EnterpriseNoise</div>,
}))
jest.mock('@/app/components/base/icons/src/public/billing', () => ({
Azure: () => <div>Azure</div>,
GoogleCloud: () => <div>Google Cloud</div>,
AwsMarketplaceDark: () => <div>AwsMarketplaceDark</div>,
AwsMarketplaceLight: () => <div>AwsMarketplaceLight</div>,
}))
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(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
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(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
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(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
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(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
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(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
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(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />)
fireEvent.click(screen.getByRole('button', { name: 'billing.plans.enterprise.btnText' }))
expect(assignedHref).toBe(contactSalesUrl)
})
})
})

View File

@ -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<string, unknown>) => {
if (options?.returnObjects)
return ['Feature A', 'Feature B']
return key
},
}),
Trans: ({ i18nKey }: { i18nKey: string }) => <span>{i18nKey}</span>,
}))
describe('SelfHostedPlanItem/List', () => {
test('should render plan info', () => {
render(<List plan={SelfHostedPlan.community} />)
expect(screen.getByText('billing.plans.community.includesTitle')).toBeInTheDocument()
expect(screen.getByText('Feature A')).toBeInTheDocument()
expect(screen.getByText('Feature B')).toBeInTheDocument()
})
})

View File

@ -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(<Item label="Dedicated support" />)
expect(screen.getByText('Dedicated support')).toBeInTheDocument()
expect(container.querySelector('svg')).not.toBeNull()
})
})

View File

@ -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: () => <div data-testid="custom-web-app-brand">CustomWebAppBrand</div>,
}))
// 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(<CustomPage />)
}
// 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()
})
})
})

View File

@ -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 }: {

View File

@ -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<string, unknown>) => {

View File

@ -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,

View File

@ -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 (
<div data-testid="modal" role="dialog">
{closable && (
<button data-testid="modal-close-button" onClick={onClose}>
Close
</button>
)}
{children}
</div>
)
}
})
// 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 (
<div data-testid="start-node-selection-panel">
<button data-testid="select-user-input" onClick={onSelectUserInput}>
Select User Input
</button>
<button
data-testid="select-trigger-schedule"
onClick={() => onSelectTrigger(BlockEnum.TriggerSchedule)}
>
Select Trigger Schedule
</button>
<button
data-testid="select-trigger-webhook"
onClick={() => onSelectTrigger(BlockEnum.TriggerWebhook, { config: 'test' })}
>
Select Trigger Webhook
</button>
</div>
)
}
})
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(<WorkflowOnboardingModal {...defaultProps} {...props} />)
}
// 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(<WorkflowOnboardingModal {...defaultProps} isShow={true} />)
// 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(<WorkflowOnboardingModal {...defaultProps} isShow={false} />)
// 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(<WorkflowOnboardingModal {...defaultProps} isShow={true} />)
// Assert
expect(screen.getByTestId('modal')).toBeInTheDocument()
// Act
rerender(<WorkflowOnboardingModal {...defaultProps} isShow={false} />)
// 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(<WorkflowOnboardingModal {...defaultProps} isShow={true} />)
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(
<WorkflowOnboardingModal
isShow={true}
onClose={newOnClose}
onSelectStartNode={newOnSelectStartNode}
/>,
)
// 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(<WorkflowOnboardingModal {...defaultProps} isShow={true} onClose={newOnClose} />)
// 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)
})
})
})

View File

@ -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: <div data-testid="test-icon">Icon</div>,
title: 'Test Title',
description: 'Test description for the option',
onClick: mockOnClick,
}
beforeEach(() => {
jest.clearAllMocks()
})
// Helper function to render component
const renderComponent = (props = {}) => {
return render(<StartNodeOption {...defaultProps} {...props} />)
}
// 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 = <svg data-testid="custom-svg">Custom</svg>
// 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()
})
})
})

View File

@ -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 (
<div data-testid="node-selector">
{triggerElement}
{open && (
<div data-testid="node-selector-content">
<button
data-testid="select-schedule"
onClick={() => onSelect(BlockEnum.TriggerSchedule)}
>
Select Schedule
</button>
<button
data-testid="select-webhook"
onClick={() => onSelect(BlockEnum.TriggerWebhook)}
>
Select Webhook
</button>
<button
data-testid="close-selector"
onClick={() => onOpenChange(false)}
>
Close
</button>
</div>
)}
</div>
)
}
})
// Mock icons
jest.mock('@/app/components/base/icons/src/vender/workflow', () => ({
Home: () => <div data-testid="home-icon">Home</div>,
TriggerAll: () => <div data-testid="trigger-all-icon">TriggerAll</div>,
}))
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(<StartNodeSelectionPanel {...defaultProps} {...props} />)
}
// 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()
})
})
})