mirror of https://github.com/langgenius/dify.git
Merge branch 'main' into feat-agent-mask
This commit is contained in:
commit
2cea4e733d
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
AGENTS.md
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -401,7 +401,6 @@ function AppCard({
|
|||
/>
|
||||
<CustomizeModal
|
||||
isShow={showCustomizeModal}
|
||||
linkUrl=""
|
||||
onClose={() => setShowCustomizeModal(false)}
|
||||
appId={appInfo.id}
|
||||
api_base_url={appInfo.api_base_url}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }: {
|
||||
|
|
|
|||
|
|
@ -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>) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue