Merge remote-tracking branch 'origin/main' into feat/trigger

This commit is contained in:
Harry
2025-11-13 10:59:57 +08:00
16 changed files with 2337 additions and 26 deletions

View File

@@ -527,7 +527,7 @@ API_WORKFLOW_NODE_EXECUTION_REPOSITORY=repositories.sqlalchemy_api_workflow_node
API_WORKFLOW_RUN_REPOSITORY=repositories.sqlalchemy_api_workflow_run_repository.DifyAPISQLAlchemyWorkflowRunRepository
# Workflow log cleanup configuration
# Enable automatic cleanup of workflow run logs to manage database size
WORKFLOW_LOG_CLEANUP_ENABLED=true
WORKFLOW_LOG_CLEANUP_ENABLED=false
# Number of days to retain workflow run logs (default: 30 days)
WORKFLOW_LOG_RETENTION_DAYS=30
# Batch size for workflow log cleanup operations (default: 100)

View File

@@ -1,7 +1,7 @@
import sys
def is_db_command():
def is_db_command() -> bool:
if len(sys.argv) > 1 and sys.argv[0].endswith("flask") and sys.argv[1] == "db":
return True
return False

View File

@@ -1190,7 +1190,7 @@ class AccountConfig(BaseSettings):
class WorkflowLogConfig(BaseSettings):
WORKFLOW_LOG_CLEANUP_ENABLED: bool = Field(default=True, description="Enable workflow run log cleanup")
WORKFLOW_LOG_CLEANUP_ENABLED: bool = Field(default=False, description="Enable workflow run log cleanup")
WORKFLOW_LOG_RETENTION_DAYS: int = Field(default=30, description="Retention days for workflow run logs")
WORKFLOW_LOG_CLEANUP_BATCH_SIZE: int = Field(
default=100, description="Batch size for workflow run log cleanup operations"

View File

@@ -6,7 +6,7 @@ import sqlalchemy as sa
from sqlalchemy import DateTime, String, func, text
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
from .base import Base, TypeBase
from .engine import db
from .types import StringUUID
@@ -41,7 +41,7 @@ class ProviderQuotaType(StrEnum):
raise ValueError(f"No matching enum found for value '{value}'")
class Provider(Base):
class Provider(TypeBase):
"""
Provider model representing the API providers and their configurations.
"""
@@ -55,25 +55,27 @@ class Provider(Base):
),
)
id: Mapped[str] = mapped_column(StringUUID, server_default=text("uuid_generate_v4()"))
id: Mapped[str] = mapped_column(StringUUID, primary_key=True, server_default=text("uuidv7()"), init=False)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
provider_name: Mapped[str] = mapped_column(String(255), nullable=False)
provider_type: Mapped[str] = mapped_column(
String(40), nullable=False, server_default=text("'custom'::character varying")
String(40), nullable=False, server_default=text("'custom'::character varying"), default="custom"
)
is_valid: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=text("false"))
last_used: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
credential_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
is_valid: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=text("false"), default=False)
last_used: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, init=False)
credential_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None)
quota_type: Mapped[str | None] = mapped_column(
String(40), nullable=True, server_default=text("''::character varying")
String(40), nullable=True, server_default=text("''::character varying"), default=""
)
quota_limit: Mapped[int | None] = mapped_column(sa.BigInteger, nullable=True)
quota_used: Mapped[int | None] = mapped_column(sa.BigInteger, default=0)
quota_limit: Mapped[int | None] = mapped_column(sa.BigInteger, nullable=True, default=None)
quota_used: Mapped[int] = mapped_column(sa.BigInteger, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp())
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.current_timestamp(), init=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()
DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp(), init=False
)
def __repr__(self):

View File

@@ -808,7 +808,11 @@ class DraftVariableSaver:
# We only save conversation variable here.
if selector[0] != CONVERSATION_VARIABLE_NODE_ID:
continue
segment = WorkflowDraftVariable.build_segment_with_type(segment_type=item.value_type, value=item.new_value)
# Conversation variables are exposed as NUMBER in the UI even if their
# persisted type is INTEGER. Allow float updates by loosening the type
# to NUMBER here so downstream storage infers the precise subtype.
segment_type = SegmentType.NUMBER if item.value_type == SegmentType.INTEGER else item.value_type
segment = WorkflowDraftVariable.build_segment_with_type(segment_type=segment_type, value=item.new_value)
draft_vars.append(
WorkflowDraftVariable.new_conversation_variable(
app_id=self._app_id,

View File

@@ -8,9 +8,14 @@ cd "$SCRIPT_DIR/.."
# Get the path argument if provided
PATH_TO_CHECK="$1"
# run basedpyright checks
if [ -n "$PATH_TO_CHECK" ]; then
uv run --directory api --dev -- basedpyright --threads $(nproc) "$PATH_TO_CHECK"
else
uv run --directory api --dev -- basedpyright --threads $(nproc)
fi
# Determine CPU core count based on OS
CPU_CORES=$(
if [[ "$(uname -s)" == "Darwin" ]]; then
sysctl -n hw.ncpu 2>/dev/null
else
nproc
fi
)
# Run basedpyright checks
uv run --directory api --dev -- basedpyright --threads "$CPU_CORES" $PATH_TO_CHECK

View File

@@ -305,9 +305,23 @@ export const useFile = (fileConfig: FileUpload) => {
const text = e.clipboardData?.getData('text/plain')
if (file && !text) {
e.preventDefault()
const allowedFileTypes = fileConfig.allowed_file_types || []
const fileType = getSupportFileType(file.name, file.type, allowedFileTypes?.includes(SupportUploadFileTypes.custom))
const isFileTypeAllowed = allowedFileTypes.includes(fileType)
// Check if file type is in allowed list
if (!isFileTypeAllowed || !fileConfig.enabled) {
notify({
type: 'error',
message: t('common.fileUploader.fileExtensionNotSupport'),
})
return
}
handleLocalFileUpload(file)
}
}, [handleLocalFileUpload])
}, [handleLocalFileUpload, fileConfig, notify, t])
const [isDragActive, setIsDragActive] = useState(false)
const handleDragFileEnter = useCallback((e: React.DragEvent<HTMLElement>) => {

View File

@@ -1,10 +1,27 @@
/**
* Test suite for useBreakpoints hook
*
* This hook provides responsive breakpoint detection based on window width.
* It listens to window resize events and returns the current media type.
*
* Breakpoint definitions:
* - mobile: width <= 640px
* - tablet: 640px < width <= 768px
* - pc: width > 768px
*
* The hook automatically updates when the window is resized and cleans up
* event listeners on unmount to prevent memory leaks.
*/
import { act, renderHook } from '@testing-library/react'
import useBreakpoints, { MediaType } from './use-breakpoints'
describe('useBreakpoints', () => {
const originalInnerWidth = window.innerWidth
// Mock the window resize event
/**
* Helper function to simulate window resize events
* Updates window.innerWidth and dispatches a resize event
*/
const fireResize = (width: number) => {
window.innerWidth = width
act(() => {
@@ -12,11 +29,18 @@ describe('useBreakpoints', () => {
})
}
// Restore the original innerWidth after tests
/**
* Restore the original innerWidth after all tests
* Ensures tests don't affect each other or the test environment
*/
afterAll(() => {
window.innerWidth = originalInnerWidth
})
/**
* Test mobile breakpoint detection
* Mobile devices have width <= 640px
*/
it('should return mobile for width <= 640px', () => {
// Mock window.innerWidth for mobile
Object.defineProperty(window, 'innerWidth', {
@@ -29,6 +53,10 @@ describe('useBreakpoints', () => {
expect(result.current).toBe(MediaType.mobile)
})
/**
* Test tablet breakpoint detection
* Tablet devices have width between 640px and 768px
*/
it('should return tablet for width > 640px and <= 768px', () => {
// Mock window.innerWidth for tablet
Object.defineProperty(window, 'innerWidth', {
@@ -41,6 +69,10 @@ describe('useBreakpoints', () => {
expect(result.current).toBe(MediaType.tablet)
})
/**
* Test desktop/PC breakpoint detection
* Desktop devices have width > 768px
*/
it('should return pc for width > 768px', () => {
// Mock window.innerWidth for pc
Object.defineProperty(window, 'innerWidth', {
@@ -53,6 +85,10 @@ describe('useBreakpoints', () => {
expect(result.current).toBe(MediaType.pc)
})
/**
* Test dynamic breakpoint updates on window resize
* The hook should react to window resize events and update the media type
*/
it('should update media type when window resizes', () => {
// Start with desktop
Object.defineProperty(window, 'innerWidth', {
@@ -73,6 +109,10 @@ describe('useBreakpoints', () => {
expect(result.current).toBe(MediaType.mobile)
})
/**
* Test proper cleanup of event listeners
* Ensures no memory leaks by removing resize listeners on unmount
*/
it('should clean up event listeners on unmount', () => {
// Spy on addEventListener and removeEventListener
const addEventListenerSpy = jest.spyOn(window, 'addEventListener')

View File

@@ -1,3 +1,15 @@
/**
* Test suite for useDocumentTitle hook
*
* This hook manages the browser document title with support for:
* - Custom branding (when enabled in system features)
* - Default "Dify" branding
* - Pending state handling (prevents title flicker during loading)
* - Page-specific titles with automatic suffix
*
* Title format: "[Page Title] - [Brand Name]"
* If no page title: "[Brand Name]"
*/
import { defaultSystemFeatures } from '@/types/feature'
import { act, renderHook } from '@testing-library/react'
import useDocumentTitle from './use-document-title'
@@ -7,6 +19,10 @@ jest.mock('@/service/common', () => ({
getSystemFeatures: jest.fn(() => ({ ...defaultSystemFeatures })),
}))
/**
* Test behavior when system features are still loading
* Title should remain empty to prevent flicker
*/
describe('title should be empty if systemFeatures is pending', () => {
act(() => {
useGlobalPublicStore.setState({
@@ -14,16 +30,26 @@ describe('title should be empty if systemFeatures is pending', () => {
isGlobalPending: true,
})
})
/**
* Test that title stays empty during loading even when a title is provided
*/
it('document title should be empty if set title', () => {
renderHook(() => useDocumentTitle('test'))
expect(document.title).toBe('')
})
/**
* Test that title stays empty during loading when no title is provided
*/
it('document title should be empty if not set title', () => {
renderHook(() => useDocumentTitle(''))
expect(document.title).toBe('')
})
})
/**
* Test default Dify branding behavior
* When custom branding is disabled, should use "Dify" as the brand name
*/
describe('use default branding', () => {
beforeEach(() => {
act(() => {
@@ -33,17 +59,29 @@ describe('use default branding', () => {
})
})
})
/**
* Test title format with page title and default branding
* Format: "[page] - Dify"
*/
it('document title should be test-Dify if set title', () => {
renderHook(() => useDocumentTitle('test'))
expect(document.title).toBe('test - Dify')
})
/**
* Test title with only default branding (no page title)
* Format: "Dify"
*/
it('document title should be Dify if not set title', () => {
renderHook(() => useDocumentTitle(''))
expect(document.title).toBe('Dify')
})
})
/**
* Test custom branding behavior
* When custom branding is enabled, should use the configured application_title
*/
describe('use specific branding', () => {
beforeEach(() => {
act(() => {
@@ -53,11 +91,19 @@ describe('use specific branding', () => {
})
})
})
/**
* Test title format with page title and custom branding
* Format: "[page] - [Custom Brand]"
*/
it('document title should be test-Test if set title', () => {
renderHook(() => useDocumentTitle('test'))
expect(document.title).toBe('test - Test')
})
/**
* Test title with only custom branding (no page title)
* Format: "[Custom Brand]"
*/
it('document title should be Test if not set title', () => {
renderHook(() => useDocumentTitle(''))
expect(document.title).toBe('Test')

View File

@@ -0,0 +1,376 @@
/**
* Test suite for useFormatTimeFromNow hook
*
* This hook provides internationalized relative time formatting (e.g., "2 hours ago", "3 days ago")
* using dayjs with the relativeTime plugin. It automatically uses the correct locale based on
* the user's i18n settings.
*
* Key features:
* - Supports 20+ locales with proper translations
* - Automatically syncs with user's interface language
* - Uses dayjs for consistent time calculations
* - Returns human-readable relative time strings
*/
import { renderHook } from '@testing-library/react'
import { useFormatTimeFromNow } from './use-format-time-from-now'
// Mock the i18n context
jest.mock('@/context/i18n', () => ({
useI18N: jest.fn(() => ({
locale: 'en-US',
})),
}))
// Import after mock to get the mocked version
import { useI18N } from '@/context/i18n'
describe('useFormatTimeFromNow', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('Basic functionality', () => {
/**
* Test that the hook returns a formatTimeFromNow function
* This is the primary interface of the hook
*/
it('should return formatTimeFromNow function', () => {
const { result } = renderHook(() => useFormatTimeFromNow())
expect(result.current).toHaveProperty('formatTimeFromNow')
expect(typeof result.current.formatTimeFromNow).toBe('function')
})
/**
* Test basic relative time formatting with English locale
* Should return human-readable relative time strings
*/
it('should format time from now in English', () => {
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
const { result } = renderHook(() => useFormatTimeFromNow())
const now = Date.now()
const oneHourAgo = now - (60 * 60 * 1000)
const formatted = result.current.formatTimeFromNow(oneHourAgo)
// Should contain "hour" or "hours" and "ago"
expect(formatted).toMatch(/hour|hours/)
expect(formatted).toMatch(/ago/)
})
/**
* Test that recent times are formatted as "a few seconds ago"
* Very recent timestamps should show seconds
*/
it('should format very recent times', () => {
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
const { result } = renderHook(() => useFormatTimeFromNow())
const now = Date.now()
const fiveSecondsAgo = now - (5 * 1000)
const formatted = result.current.formatTimeFromNow(fiveSecondsAgo)
expect(formatted).toMatch(/second|seconds|few seconds/)
})
/**
* Test formatting of times in the past (days ago)
* Should handle day-level granularity
*/
it('should format times from days ago', () => {
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
const { result } = renderHook(() => useFormatTimeFromNow())
const now = Date.now()
const threeDaysAgo = now - (3 * 24 * 60 * 60 * 1000)
const formatted = result.current.formatTimeFromNow(threeDaysAgo)
expect(formatted).toMatch(/day|days/)
expect(formatted).toMatch(/ago/)
})
/**
* Test formatting of future times
* dayjs fromNow also supports future times (e.g., "in 2 hours")
*/
it('should format future times', () => {
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
const { result } = renderHook(() => useFormatTimeFromNow())
const now = Date.now()
const twoHoursFromNow = now + (2 * 60 * 60 * 1000)
const formatted = result.current.formatTimeFromNow(twoHoursFromNow)
expect(formatted).toMatch(/in/)
expect(formatted).toMatch(/hour|hours/)
})
})
describe('Locale support', () => {
/**
* Test Chinese (Simplified) locale formatting
* Should use Chinese characters for time units
*/
it('should format time in Chinese (Simplified)', () => {
;(useI18N as jest.Mock).mockReturnValue({ locale: 'zh-Hans' })
const { result } = renderHook(() => useFormatTimeFromNow())
const now = Date.now()
const oneHourAgo = now - (60 * 60 * 1000)
const formatted = result.current.formatTimeFromNow(oneHourAgo)
// Chinese should contain Chinese characters
expect(formatted).toMatch(/[\u4E00-\u9FA5]/)
})
/**
* Test Spanish locale formatting
* Should use Spanish words for relative time
*/
it('should format time in Spanish', () => {
;(useI18N as jest.Mock).mockReturnValue({ locale: 'es-ES' })
const { result } = renderHook(() => useFormatTimeFromNow())
const now = Date.now()
const oneHourAgo = now - (60 * 60 * 1000)
const formatted = result.current.formatTimeFromNow(oneHourAgo)
// Spanish should contain "hace" (ago)
expect(formatted).toMatch(/hace/)
})
/**
* Test French locale formatting
* Should use French words for relative time
*/
it('should format time in French', () => {
;(useI18N as jest.Mock).mockReturnValue({ locale: 'fr-FR' })
const { result } = renderHook(() => useFormatTimeFromNow())
const now = Date.now()
const oneHourAgo = now - (60 * 60 * 1000)
const formatted = result.current.formatTimeFromNow(oneHourAgo)
// French should contain "il y a" (ago)
expect(formatted).toMatch(/il y a/)
})
/**
* Test Japanese locale formatting
* Should use Japanese characters
*/
it('should format time in Japanese', () => {
;(useI18N as jest.Mock).mockReturnValue({ locale: 'ja-JP' })
const { result } = renderHook(() => useFormatTimeFromNow())
const now = Date.now()
const oneHourAgo = now - (60 * 60 * 1000)
const formatted = result.current.formatTimeFromNow(oneHourAgo)
// Japanese should contain Japanese characters
expect(formatted).toMatch(/[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/)
})
/**
* Test Portuguese (Brazil) locale formatting
* Should use pt-br locale mapping
*/
it('should format time in Portuguese (Brazil)', () => {
;(useI18N as jest.Mock).mockReturnValue({ locale: 'pt-BR' })
const { result } = renderHook(() => useFormatTimeFromNow())
const now = Date.now()
const oneHourAgo = now - (60 * 60 * 1000)
const formatted = result.current.formatTimeFromNow(oneHourAgo)
// Portuguese should contain "há" (ago)
expect(formatted).toMatch(/há/)
})
/**
* Test fallback to English for unsupported locales
* Unknown locales should default to English
*/
it('should fallback to English for unsupported locale', () => {
;(useI18N as jest.Mock).mockReturnValue({ locale: 'xx-XX' as any })
const { result } = renderHook(() => useFormatTimeFromNow())
const now = Date.now()
const oneHourAgo = now - (60 * 60 * 1000)
const formatted = result.current.formatTimeFromNow(oneHourAgo)
// Should still return a valid string (in English)
expect(typeof formatted).toBe('string')
expect(formatted.length).toBeGreaterThan(0)
})
})
describe('Edge cases', () => {
/**
* Test handling of timestamp 0 (Unix epoch)
* Should format as a very old date
*/
it('should handle timestamp 0', () => {
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
const { result } = renderHook(() => useFormatTimeFromNow())
const formatted = result.current.formatTimeFromNow(0)
expect(typeof formatted).toBe('string')
expect(formatted.length).toBeGreaterThan(0)
expect(formatted).toMatch(/year|years/)
})
/**
* Test handling of very large timestamps
* Should handle dates far in the future
*/
it('should handle very large timestamps', () => {
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
const { result } = renderHook(() => useFormatTimeFromNow())
const farFuture = Date.now() + (365 * 24 * 60 * 60 * 1000) // 1 year from now
const formatted = result.current.formatTimeFromNow(farFuture)
expect(typeof formatted).toBe('string')
expect(formatted).toMatch(/in/)
})
/**
* Test that the function is memoized based on locale
* Changing locale should update the function
*/
it('should update when locale changes', () => {
const { result, rerender } = renderHook(() => useFormatTimeFromNow())
const now = Date.now()
const oneHourAgo = now - (60 * 60 * 1000)
// First render with English
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
rerender()
const englishResult = result.current.formatTimeFromNow(oneHourAgo)
// Second render with Spanish
;(useI18N as jest.Mock).mockReturnValue({ locale: 'es-ES' })
rerender()
const spanishResult = result.current.formatTimeFromNow(oneHourAgo)
// Results should be different
expect(englishResult).not.toBe(spanishResult)
})
})
describe('Time granularity', () => {
/**
* Test different time granularities (seconds, minutes, hours, days, months, years)
* dayjs should automatically choose the appropriate unit
*/
it('should use appropriate time units for different durations', () => {
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
const { result } = renderHook(() => useFormatTimeFromNow())
const now = Date.now()
// Seconds
const seconds = result.current.formatTimeFromNow(now - 30 * 1000)
expect(seconds).toMatch(/second/)
// Minutes
const minutes = result.current.formatTimeFromNow(now - 5 * 60 * 1000)
expect(minutes).toMatch(/minute/)
// Hours
const hours = result.current.formatTimeFromNow(now - 3 * 60 * 60 * 1000)
expect(hours).toMatch(/hour/)
// Days
const days = result.current.formatTimeFromNow(now - 5 * 24 * 60 * 60 * 1000)
expect(days).toMatch(/day/)
// Months
const months = result.current.formatTimeFromNow(now - 60 * 24 * 60 * 60 * 1000)
expect(months).toMatch(/month/)
})
})
describe('Locale mapping', () => {
/**
* Test that all supported locales in the localeMap are handled correctly
* This ensures the mapping from app locales to dayjs locales works
*/
it('should handle all mapped locales', () => {
const locales = [
'en-US', 'zh-Hans', 'zh-Hant', 'pt-BR', 'es-ES', 'fr-FR',
'de-DE', 'ja-JP', 'ko-KR', 'ru-RU', 'it-IT', 'th-TH',
'id-ID', 'uk-UA', 'vi-VN', 'ro-RO', 'pl-PL', 'hi-IN',
'tr-TR', 'fa-IR', 'sl-SI',
]
const now = Date.now()
const oneHourAgo = now - (60 * 60 * 1000)
locales.forEach((locale) => {
;(useI18N as jest.Mock).mockReturnValue({ locale })
const { result } = renderHook(() => useFormatTimeFromNow())
const formatted = result.current.formatTimeFromNow(oneHourAgo)
// Should return a non-empty string for each locale
expect(typeof formatted).toBe('string')
expect(formatted.length).toBeGreaterThan(0)
})
})
})
describe('Performance', () => {
/**
* Test that the hook doesn't create new functions on every render
* The formatTimeFromNow function should be memoized with useCallback
*/
it('should memoize formatTimeFromNow function', () => {
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
const { result, rerender } = renderHook(() => useFormatTimeFromNow())
const firstFunction = result.current.formatTimeFromNow
rerender()
const secondFunction = result.current.formatTimeFromNow
// Same locale should return the same function reference
expect(firstFunction).toBe(secondFunction)
})
/**
* Test that changing locale creates a new function
* This ensures the memoization dependency on locale works correctly
*/
it('should create new function when locale changes', () => {
const { result, rerender } = renderHook(() => useFormatTimeFromNow())
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
rerender()
const englishFunction = result.current.formatTimeFromNow
;(useI18N as jest.Mock).mockReturnValue({ locale: 'es-ES' })
rerender()
const spanishFunction = result.current.formatTimeFromNow
// Different locale should return different function reference
expect(englishFunction).not.toBe(spanishFunction)
})
})
})

View File

@@ -0,0 +1,543 @@
/**
* Test suite for useTabSearchParams hook
*
* This hook manages tab state through URL search parameters, enabling:
* - Bookmarkable tab states (users can share URLs with specific tabs active)
* - Browser history integration (back/forward buttons work with tabs)
* - Configurable routing behavior (push vs replace)
* - Optional search parameter syncing (can disable URL updates)
*
* The hook syncs a local tab state with URL search parameters, making tab
* navigation persistent and shareable across sessions.
*/
import { act, renderHook } from '@testing-library/react'
import { useTabSearchParams } from './use-tab-searchparams'
// Mock Next.js navigation hooks
const mockPush = jest.fn()
const mockReplace = jest.fn()
const mockPathname = '/test-path'
const mockSearchParams = new URLSearchParams()
jest.mock('next/navigation', () => ({
usePathname: jest.fn(() => mockPathname),
useRouter: jest.fn(() => ({
push: mockPush,
replace: mockReplace,
})),
useSearchParams: jest.fn(() => mockSearchParams),
}))
// Import after mocks
import { usePathname } from 'next/navigation'
describe('useTabSearchParams', () => {
beforeEach(() => {
jest.clearAllMocks()
mockSearchParams.delete('category')
mockSearchParams.delete('tab')
})
describe('Basic functionality', () => {
/**
* Test that the hook returns a tuple with activeTab and setActiveTab
* This is the primary interface matching React's useState pattern
*/
it('should return activeTab and setActiveTab function', () => {
const { result } = renderHook(() =>
useTabSearchParams({ defaultTab: 'overview' }),
)
const [activeTab, setActiveTab] = result.current
expect(typeof activeTab).toBe('string')
expect(typeof setActiveTab).toBe('function')
})
/**
* Test that the hook initializes with the default tab
* When no search param is present, should use defaultTab
*/
it('should initialize with default tab when no search param exists', () => {
const { result } = renderHook(() =>
useTabSearchParams({ defaultTab: 'overview' }),
)
const [activeTab] = result.current
expect(activeTab).toBe('overview')
})
/**
* Test that the hook reads from URL search parameters
* When a search param exists, it should take precedence over defaultTab
*/
it('should initialize with search param value when present', () => {
mockSearchParams.set('category', 'settings')
const { result } = renderHook(() =>
useTabSearchParams({ defaultTab: 'overview' }),
)
const [activeTab] = result.current
expect(activeTab).toBe('settings')
})
/**
* Test that setActiveTab updates the local state
* The active tab should change when setActiveTab is called
*/
it('should update active tab when setActiveTab is called', () => {
const { result } = renderHook(() =>
useTabSearchParams({ defaultTab: 'overview' }),
)
act(() => {
const [, setActiveTab] = result.current
setActiveTab('settings')
})
const [activeTab] = result.current
expect(activeTab).toBe('settings')
})
})
describe('Routing behavior', () => {
/**
* Test default push routing behavior
* By default, tab changes should use router.push (adds to history)
*/
it('should use push routing by default', () => {
const { result } = renderHook(() =>
useTabSearchParams({ defaultTab: 'overview' }),
)
act(() => {
const [, setActiveTab] = result.current
setActiveTab('settings')
})
expect(mockPush).toHaveBeenCalledWith('/test-path?category=settings')
expect(mockReplace).not.toHaveBeenCalled()
})
/**
* Test replace routing behavior
* When routingBehavior is 'replace', should use router.replace (no history)
*/
it('should use replace routing when specified', () => {
const { result } = renderHook(() =>
useTabSearchParams({
defaultTab: 'overview',
routingBehavior: 'replace',
}),
)
act(() => {
const [, setActiveTab] = result.current
setActiveTab('settings')
})
expect(mockReplace).toHaveBeenCalledWith('/test-path?category=settings')
expect(mockPush).not.toHaveBeenCalled()
})
/**
* Test that URL encoding is applied to tab values
* Special characters in tab names should be properly encoded
*/
it('should encode special characters in tab values', () => {
const { result } = renderHook(() =>
useTabSearchParams({ defaultTab: 'overview' }),
)
act(() => {
const [, setActiveTab] = result.current
setActiveTab('settings & config')
})
expect(mockPush).toHaveBeenCalledWith(
'/test-path?category=settings%20%26%20config',
)
})
/**
* Test that URL decoding is applied when reading from search params
* Encoded values in the URL should be properly decoded
*/
it('should decode encoded values from search params', () => {
mockSearchParams.set('category', 'settings%20%26%20config')
const { result } = renderHook(() =>
useTabSearchParams({ defaultTab: 'overview' }),
)
const [activeTab] = result.current
expect(activeTab).toBe('settings & config')
})
})
describe('Custom search parameter name', () => {
/**
* Test using a custom search parameter name
* Should support different param names instead of default 'category'
*/
it('should use custom search param name', () => {
mockSearchParams.set('tab', 'profile')
const { result } = renderHook(() =>
useTabSearchParams({
defaultTab: 'overview',
searchParamName: 'tab',
}),
)
const [activeTab] = result.current
expect(activeTab).toBe('profile')
})
/**
* Test that setActiveTab uses the custom param name in the URL
*/
it('should update URL with custom param name', () => {
const { result } = renderHook(() =>
useTabSearchParams({
defaultTab: 'overview',
searchParamName: 'tab',
}),
)
act(() => {
const [, setActiveTab] = result.current
setActiveTab('profile')
})
expect(mockPush).toHaveBeenCalledWith('/test-path?tab=profile')
})
})
describe('Disabled search params mode', () => {
/**
* Test that disableSearchParams prevents URL updates
* When disabled, tab state should be local only
*/
it('should not update URL when disableSearchParams is true', () => {
const { result } = renderHook(() =>
useTabSearchParams({
defaultTab: 'overview',
disableSearchParams: true,
}),
)
act(() => {
const [, setActiveTab] = result.current
setActiveTab('settings')
})
expect(mockPush).not.toHaveBeenCalled()
expect(mockReplace).not.toHaveBeenCalled()
})
/**
* Test that local state still updates when search params are disabled
* The tab state should work even without URL syncing
*/
it('should still update local state when search params disabled', () => {
const { result } = renderHook(() =>
useTabSearchParams({
defaultTab: 'overview',
disableSearchParams: true,
}),
)
act(() => {
const [, setActiveTab] = result.current
setActiveTab('settings')
})
const [activeTab] = result.current
expect(activeTab).toBe('settings')
})
/**
* Test that disabled mode always uses defaultTab
* Search params should be ignored when disabled
*/
it('should use defaultTab when search params disabled even if URL has value', () => {
mockSearchParams.set('category', 'settings')
const { result } = renderHook(() =>
useTabSearchParams({
defaultTab: 'overview',
disableSearchParams: true,
}),
)
const [activeTab] = result.current
expect(activeTab).toBe('overview')
})
})
describe('Edge cases', () => {
/**
* Test handling of empty string tab values
* Empty strings should be handled gracefully
*/
it('should handle empty string tab values', () => {
const { result } = renderHook(() =>
useTabSearchParams({ defaultTab: 'overview' }),
)
act(() => {
const [, setActiveTab] = result.current
setActiveTab('')
})
const [activeTab] = result.current
expect(activeTab).toBe('')
expect(mockPush).toHaveBeenCalledWith('/test-path?category=')
})
/**
* Test that special characters in tab names are properly encoded
* This ensures URLs remain valid even with unusual tab names
*/
it('should handle tabs with various special characters', () => {
const { result } = renderHook(() =>
useTabSearchParams({ defaultTab: 'overview' }),
)
// Test tab with slashes
act(() => result.current[1]('tab/with/slashes'))
expect(result.current[0]).toBe('tab/with/slashes')
// Test tab with question marks
act(() => result.current[1]('tab?with?questions'))
expect(result.current[0]).toBe('tab?with?questions')
// Test tab with hash symbols
act(() => result.current[1]('tab#with#hash'))
expect(result.current[0]).toBe('tab#with#hash')
// Test tab with equals signs
act(() => result.current[1]('tab=with=equals'))
expect(result.current[0]).toBe('tab=with=equals')
})
/**
* Test fallback when pathname is not available
* Should use window.location.pathname as fallback
*/
it('should fallback to window.location.pathname when hook pathname is null', () => {
;(usePathname as jest.Mock).mockReturnValue(null)
// Mock window.location.pathname
Object.defineProperty(window, 'location', {
value: { pathname: '/fallback-path' },
writable: true,
})
const { result } = renderHook(() =>
useTabSearchParams({ defaultTab: 'overview' }),
)
act(() => {
const [, setActiveTab] = result.current
setActiveTab('settings')
})
expect(mockPush).toHaveBeenCalledWith('/fallback-path?category=settings')
// Restore mock
;(usePathname as jest.Mock).mockReturnValue(mockPathname)
})
})
describe('Multiple instances', () => {
/**
* Test that multiple instances with different param names work independently
* Different hooks should not interfere with each other
*/
it('should support multiple independent tab states', () => {
mockSearchParams.set('category', 'overview')
mockSearchParams.set('subtab', 'details')
const { result: result1 } = renderHook(() =>
useTabSearchParams({
defaultTab: 'home',
searchParamName: 'category',
}),
)
const { result: result2 } = renderHook(() =>
useTabSearchParams({
defaultTab: 'info',
searchParamName: 'subtab',
}),
)
const [activeTab1] = result1.current
const [activeTab2] = result2.current
expect(activeTab1).toBe('overview')
expect(activeTab2).toBe('details')
})
})
describe('Integration scenarios', () => {
/**
* Test typical usage in a tabbed interface
* Simulates real-world tab switching behavior
*/
it('should handle sequential tab changes', () => {
const { result } = renderHook(() =>
useTabSearchParams({ defaultTab: 'overview' }),
)
// Change to settings tab
act(() => {
const [, setActiveTab] = result.current
setActiveTab('settings')
})
expect(result.current[0]).toBe('settings')
expect(mockPush).toHaveBeenCalledWith('/test-path?category=settings')
// Change to profile tab
act(() => {
const [, setActiveTab] = result.current
setActiveTab('profile')
})
expect(result.current[0]).toBe('profile')
expect(mockPush).toHaveBeenCalledWith('/test-path?category=profile')
// Verify push was called twice
expect(mockPush).toHaveBeenCalledTimes(2)
})
/**
* Test that the hook works with complex pathnames
* Should handle nested routes and existing query params
*/
it('should work with complex pathnames', () => {
;(usePathname as jest.Mock).mockReturnValue('/app/123/settings')
const { result } = renderHook(() =>
useTabSearchParams({ defaultTab: 'overview' }),
)
act(() => {
const [, setActiveTab] = result.current
setActiveTab('advanced')
})
expect(mockPush).toHaveBeenCalledWith('/app/123/settings?category=advanced')
// Restore mock
;(usePathname as jest.Mock).mockReturnValue(mockPathname)
})
})
describe('Type safety', () => {
/**
* Test that the return type is a const tuple
* TypeScript should infer [string, (tab: string) => void] as const
*/
it('should return a const tuple type', () => {
const { result } = renderHook(() =>
useTabSearchParams({ defaultTab: 'overview' }),
)
// The result should be a tuple with exactly 2 elements
expect(result.current).toHaveLength(2)
expect(typeof result.current[0]).toBe('string')
expect(typeof result.current[1]).toBe('function')
})
})
describe('Performance', () => {
/**
* Test that the hook creates a new function on each render
* Note: The current implementation doesn't use useCallback,
* so setActiveTab is recreated on each render. This could lead to
* unnecessary re-renders in child components that depend on this function.
* TODO: Consider memoizing setActiveTab with useCallback for better performance.
*/
it('should create new setActiveTab function on each render', () => {
const { result, rerender } = renderHook(() =>
useTabSearchParams({ defaultTab: 'overview' }),
)
const [, firstSetActiveTab] = result.current
rerender()
const [, secondSetActiveTab] = result.current
// Function reference changes on re-render (not memoized)
expect(firstSetActiveTab).not.toBe(secondSetActiveTab)
// But both functions should work correctly
expect(typeof firstSetActiveTab).toBe('function')
expect(typeof secondSetActiveTab).toBe('function')
})
})
describe('Browser history integration', () => {
/**
* Test that push behavior adds to browser history
* This enables back/forward navigation through tabs
*/
it('should add to history with push behavior', () => {
const { result } = renderHook(() =>
useTabSearchParams({
defaultTab: 'overview',
routingBehavior: 'push',
}),
)
act(() => {
const [, setActiveTab] = result.current
setActiveTab('tab1')
})
act(() => {
const [, setActiveTab] = result.current
setActiveTab('tab2')
})
act(() => {
const [, setActiveTab] = result.current
setActiveTab('tab3')
})
// Each tab change should create a history entry
expect(mockPush).toHaveBeenCalledTimes(3)
})
/**
* Test that replace behavior doesn't add to history
* This prevents cluttering browser history with tab changes
*/
it('should not add to history with replace behavior', () => {
const { result } = renderHook(() =>
useTabSearchParams({
defaultTab: 'overview',
routingBehavior: 'replace',
}),
)
act(() => {
const [, setActiveTab] = result.current
setActiveTab('tab1')
})
act(() => {
const [, setActiveTab] = result.current
setActiveTab('tab2')
})
// Should use replace instead of push
expect(mockReplace).toHaveBeenCalledTimes(2)
expect(mockPush).not.toHaveBeenCalled()
})
})
})

170
web/service/utils.spec.ts Normal file
View File

@@ -0,0 +1,170 @@
/**
* Test suite for service utility functions
*
* This module provides utilities for working with different flow types in the application.
* Flow types determine the API endpoint prefix used for various operations.
*
* Key concepts:
* - FlowType.appFlow: Standard application workflows (prefix: 'apps')
* - FlowType.ragPipeline: RAG (Retrieval-Augmented Generation) pipelines (prefix: 'rag/pipelines')
*
* The getFlowPrefix function maps flow types to their corresponding API path prefixes,
* with a fallback to 'apps' for undefined or unknown flow types.
*/
import { flowPrefixMap, getFlowPrefix } from './utils'
import { FlowType } from '@/types/common'
describe('Service Utils', () => {
describe('flowPrefixMap', () => {
/**
* Test that the flowPrefixMap object contains the expected mappings
* This ensures the mapping configuration is correct
*/
it('should have correct flow type to prefix mappings', () => {
expect(flowPrefixMap[FlowType.appFlow]).toBe('apps')
expect(flowPrefixMap[FlowType.ragPipeline]).toBe('rag/pipelines')
})
/**
* Test that the map only contains the expected flow types
* This helps catch unintended additions to the mapping
*/
it('should contain exactly two flow type mappings', () => {
const keys = Object.keys(flowPrefixMap)
expect(keys).toHaveLength(2)
})
})
describe('getFlowPrefix', () => {
/**
* Test that appFlow type returns the correct prefix
* This is the most common flow type for standard application workflows
*/
it('should return "apps" for appFlow type', () => {
const result = getFlowPrefix(FlowType.appFlow)
expect(result).toBe('apps')
})
/**
* Test that ragPipeline type returns the correct prefix
* RAG pipelines have a different API structure with nested paths
*/
it('should return "rag/pipelines" for ragPipeline type', () => {
const result = getFlowPrefix(FlowType.ragPipeline)
expect(result).toBe('rag/pipelines')
})
/**
* Test fallback behavior when no flow type is provided
* Should default to 'apps' prefix for backward compatibility
*/
it('should return "apps" when flow type is undefined', () => {
const result = getFlowPrefix(undefined)
expect(result).toBe('apps')
})
/**
* Test fallback behavior for unknown flow types
* Any unrecognized flow type should default to 'apps'
*/
it('should return "apps" for unknown flow type', () => {
// Cast to FlowType to test the fallback behavior
const unknownType = 'unknown' as FlowType
const result = getFlowPrefix(unknownType)
expect(result).toBe('apps')
})
/**
* Test that the function handles null gracefully
* Null should be treated the same as undefined
*/
it('should return "apps" when flow type is null', () => {
const result = getFlowPrefix(null as any)
expect(result).toBe('apps')
})
/**
* Test consistency with flowPrefixMap
* The function should return the same values as direct map access
*/
it('should return values consistent with flowPrefixMap', () => {
expect(getFlowPrefix(FlowType.appFlow)).toBe(flowPrefixMap[FlowType.appFlow])
expect(getFlowPrefix(FlowType.ragPipeline)).toBe(flowPrefixMap[FlowType.ragPipeline])
})
})
describe('Integration scenarios', () => {
/**
* Test typical usage pattern in API path construction
* This demonstrates how the function is used in real application code
*/
it('should construct correct API paths for different flow types', () => {
const appId = '123'
// App flow path construction
const appFlowPath = `/${getFlowPrefix(FlowType.appFlow)}/${appId}`
expect(appFlowPath).toBe('/apps/123')
// RAG pipeline path construction
const ragPipelinePath = `/${getFlowPrefix(FlowType.ragPipeline)}/${appId}`
expect(ragPipelinePath).toBe('/rag/pipelines/123')
})
/**
* Test that the function can be used in conditional logic
* Common pattern for determining which API endpoint to use
*/
it('should support conditional API routing logic', () => {
const determineEndpoint = (flowType?: FlowType, resourceId?: string) => {
const prefix = getFlowPrefix(flowType)
return `/${prefix}/${resourceId || 'default'}`
}
expect(determineEndpoint(FlowType.appFlow, 'app-1')).toBe('/apps/app-1')
expect(determineEndpoint(FlowType.ragPipeline, 'pipeline-1')).toBe('/rag/pipelines/pipeline-1')
expect(determineEndpoint(undefined, 'fallback')).toBe('/apps/fallback')
})
/**
* Test behavior with empty string flow type
* Empty strings should fall back to default
*/
it('should handle empty string as flow type', () => {
const result = getFlowPrefix('' as any)
expect(result).toBe('apps')
})
})
describe('Type safety', () => {
/**
* Test that all FlowType enum values are handled
* This ensures we don't miss any flow types in the mapping
*/
it('should handle all FlowType enum values', () => {
// Get all enum values
const flowTypes = Object.values(FlowType)
// Each flow type should return a valid prefix
flowTypes.forEach((flowType) => {
const prefix = getFlowPrefix(flowType)
expect(prefix).toBeTruthy()
expect(typeof prefix).toBe('string')
expect(prefix.length).toBeGreaterThan(0)
})
})
/**
* Test that returned prefixes are valid path segments
* Prefixes should not contain leading/trailing slashes or invalid characters
*/
it('should return valid path segments without leading/trailing slashes', () => {
const appFlowPrefix = getFlowPrefix(FlowType.appFlow)
const ragPipelinePrefix = getFlowPrefix(FlowType.ragPipeline)
expect(appFlowPrefix).not.toMatch(/^\//)
expect(appFlowPrefix).not.toMatch(/\/$/)
expect(ragPipelinePrefix).not.toMatch(/^\//)
expect(ragPipelinePrefix).not.toMatch(/\/$/)
})
})
})

View File

@@ -1,3 +1,13 @@
/**
* Test suite for clipboard utilities
*
* This module provides cross-browser clipboard functionality with automatic fallback:
* 1. Modern Clipboard API (navigator.clipboard.writeText) - preferred method
* 2. Legacy execCommand('copy') - fallback for older browsers
*
* The implementation ensures clipboard operations work across all supported browsers
* while gracefully handling permissions and API availability.
*/
import { writeTextToClipboard } from './clipboard'
describe('Clipboard Utilities', () => {
@@ -6,6 +16,10 @@ describe('Clipboard Utilities', () => {
jest.restoreAllMocks()
})
/**
* Test modern Clipboard API usage
* When navigator.clipboard is available, should use the modern API
*/
it('should use navigator.clipboard.writeText when available', async () => {
const mockWriteText = jest.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
@@ -18,6 +32,11 @@ describe('Clipboard Utilities', () => {
expect(mockWriteText).toHaveBeenCalledWith('test text')
})
/**
* Test fallback to legacy execCommand method
* When Clipboard API is unavailable, should use document.execCommand('copy')
* This involves creating a temporary textarea element
*/
it('should fallback to execCommand when clipboard API not available', async () => {
Object.defineProperty(navigator, 'clipboard', {
value: undefined,
@@ -38,6 +57,10 @@ describe('Clipboard Utilities', () => {
expect(removeChildSpy).toHaveBeenCalled()
})
/**
* Test error handling when execCommand returns false
* execCommand returns false when the operation fails
*/
it('should handle execCommand failure', async () => {
Object.defineProperty(navigator, 'clipboard', {
value: undefined,
@@ -51,6 +74,10 @@ describe('Clipboard Utilities', () => {
await expect(writeTextToClipboard('fail text')).rejects.toThrow()
})
/**
* Test error handling when execCommand throws an exception
* Should propagate the error to the caller
*/
it('should handle execCommand exception', async () => {
Object.defineProperty(navigator, 'clipboard', {
value: undefined,
@@ -66,6 +93,10 @@ describe('Clipboard Utilities', () => {
await expect(writeTextToClipboard('error text')).rejects.toThrow('execCommand error')
})
/**
* Test proper cleanup of temporary DOM elements
* The temporary textarea should be removed after copying
*/
it('should clean up textarea after fallback', async () => {
Object.defineProperty(navigator, 'clipboard', {
value: undefined,
@@ -81,6 +112,10 @@ describe('Clipboard Utilities', () => {
expect(removeChildSpy).toHaveBeenCalled()
})
/**
* Test copying empty strings
* Should handle edge case of empty clipboard content
*/
it('should handle empty string', async () => {
const mockWriteText = jest.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
@@ -93,6 +128,10 @@ describe('Clipboard Utilities', () => {
expect(mockWriteText).toHaveBeenCalledWith('')
})
/**
* Test copying text with special characters
* Should preserve newlines, tabs, quotes, unicode, and emojis
*/
it('should handle special characters', async () => {
const mockWriteText = jest.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {

253
web/utils/context.spec.ts Normal file
View File

@@ -0,0 +1,253 @@
/**
* Test suite for React context creation utilities
*
* This module provides helper functions to create React contexts with better type safety
* and automatic error handling when context is used outside of its provider.
*
* Two variants are provided:
* - createCtx: Standard React context using useContext/createContext
* - createSelectorCtx: Context with selector support using use-context-selector library
*/
import React from 'react'
import { renderHook } from '@testing-library/react'
import { createCtx, createSelectorCtx } from './context'
describe('Context Utilities', () => {
describe('createCtx', () => {
/**
* Test that createCtx creates a valid context with provider and hook
* The function should return a tuple with [Provider, useContextValue, Context]
* plus named properties for easier access
*/
it('should create context with provider and hook', () => {
type TestContextValue = { value: string }
const [Provider, useTestContext, Context] = createCtx<TestContextValue>({
name: 'Test',
})
expect(Provider).toBeDefined()
expect(useTestContext).toBeDefined()
expect(Context).toBeDefined()
})
/**
* Test that the context hook returns the provided value correctly
* when used within the context provider
*/
it('should provide and consume context value', () => {
type TestContextValue = { value: string }
const [Provider, useTestContext] = createCtx<TestContextValue>({
name: 'Test',
})
const testValue = { value: 'test-value' }
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(Provider, { value: testValue }, children)
const { result } = renderHook(() => useTestContext(), { wrapper })
expect(result.current).toEqual(testValue)
})
/**
* Test that accessing context outside of provider throws an error
* This ensures developers are notified when they forget to wrap components
*/
it('should throw error when used outside provider', () => {
type TestContextValue = { value: string }
const [, useTestContext] = createCtx<TestContextValue>({
name: 'Test',
})
// Suppress console.error for this test
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => { /* suppress error */ })
expect(() => {
renderHook(() => useTestContext())
}).toThrow('No Test context found.')
consoleError.mockRestore()
})
/**
* Test that context works with default values
* When a default value is provided, it should be accessible without a provider
*/
it('should use default value when provided', () => {
type TestContextValue = { value: string }
const defaultValue = { value: 'default' }
const [, useTestContext] = createCtx<TestContextValue>({
name: 'Test',
defaultValue,
})
const { result } = renderHook(() => useTestContext())
expect(result.current).toEqual(defaultValue)
})
/**
* Test that the returned tuple has named properties for convenience
* This allows destructuring or property access based on preference
*/
it('should expose named properties', () => {
type TestContextValue = { value: string }
const result = createCtx<TestContextValue>({ name: 'Test' })
expect(result.provider).toBe(result[0])
expect(result.useContextValue).toBe(result[1])
expect(result.context).toBe(result[2])
})
/**
* Test context with complex data types
* Ensures type safety is maintained with nested objects and arrays
*/
it('should handle complex context values', () => {
type ComplexContext = {
user: { id: string; name: string }
settings: { theme: string; locale: string }
actions: Array<() => void>
}
const [Provider, useComplexContext] = createCtx<ComplexContext>({
name: 'Complex',
})
const complexValue: ComplexContext = {
user: { id: '123', name: 'Test User' },
settings: { theme: 'dark', locale: 'en-US' },
actions: [
() => { /* empty action 1 */ },
() => { /* empty action 2 */ },
],
}
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(Provider, { value: complexValue }, children)
const { result } = renderHook(() => useComplexContext(), { wrapper })
expect(result.current).toEqual(complexValue)
expect(result.current.user.id).toBe('123')
expect(result.current.settings.theme).toBe('dark')
expect(result.current.actions).toHaveLength(2)
})
/**
* Test that context updates propagate to consumers
* When provider value changes, hooks should receive the new value
*/
it('should update when context value changes', () => {
type TestContextValue = { count: number }
const [Provider, useTestContext] = createCtx<TestContextValue>({
name: 'Test',
})
let value = { count: 0 }
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(Provider, { value }, children)
const { result, rerender } = renderHook(() => useTestContext(), { wrapper })
expect(result.current.count).toBe(0)
value = { count: 5 }
rerender()
expect(result.current.count).toBe(5)
})
})
describe('createSelectorCtx', () => {
/**
* Test that createSelectorCtx creates a valid context with selector support
* This variant uses use-context-selector for optimized re-renders
*/
it('should create selector context with provider and hook', () => {
type TestContextValue = { value: string }
const [Provider, useTestContext, Context] = createSelectorCtx<TestContextValue>({
name: 'SelectorTest',
})
expect(Provider).toBeDefined()
expect(useTestContext).toBeDefined()
expect(Context).toBeDefined()
})
/**
* Test that selector context provides and consumes values correctly
* The API should be identical to createCtx for basic usage
*/
it('should provide and consume context value with selector', () => {
type TestContextValue = { value: string }
const [Provider, useTestContext] = createSelectorCtx<TestContextValue>({
name: 'SelectorTest',
})
const testValue = { value: 'selector-test' }
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(Provider, { value: testValue }, children)
const { result } = renderHook(() => useTestContext(), { wrapper })
expect(result.current).toEqual(testValue)
})
/**
* Test error handling for selector context
* Should throw error when used outside provider, same as createCtx
*/
it('should throw error when used outside provider', () => {
type TestContextValue = { value: string }
const [, useTestContext] = createSelectorCtx<TestContextValue>({
name: 'SelectorTest',
})
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => { /* suppress error */ })
expect(() => {
renderHook(() => useTestContext())
}).toThrow('No SelectorTest context found.')
consoleError.mockRestore()
})
/**
* Test that selector context works with default values
*/
it('should use default value when provided', () => {
type TestContextValue = { value: string }
const defaultValue = { value: 'selector-default' }
const [, useTestContext] = createSelectorCtx<TestContextValue>({
name: 'SelectorTest',
defaultValue,
})
const { result } = renderHook(() => useTestContext())
expect(result.current).toEqual(defaultValue)
})
})
describe('Context without name', () => {
/**
* Test that contexts can be created without a name
* The error message should use a generic fallback
*/
it('should create context without name and show generic error', () => {
type TestContextValue = { value: string }
const [, useTestContext] = createCtx<TestContextValue>()
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => { /* suppress error */ })
expect(() => {
renderHook(() => useTestContext())
}).toThrow('No related context found.')
consoleError.mockRestore()
})
})
})

View File

@@ -0,0 +1,819 @@
/**
* Test suite for model configuration transformation utilities
*
* This module handles the conversion between two different representations of user input forms:
* 1. UserInputFormItem: The form structure used in the UI
* 2. PromptVariable: The variable structure used in prompts and model configuration
*
* Key functions:
* - userInputsFormToPromptVariables: Converts UI form items to prompt variables
* - promptVariablesToUserInputsForm: Converts prompt variables back to form items
* - formatBooleanInputs: Ensures boolean inputs are properly typed
*/
import {
formatBooleanInputs,
promptVariablesToUserInputsForm,
userInputsFormToPromptVariables,
} from './model-config'
import type { UserInputFormItem } from '@/types/app'
import type { PromptVariable } from '@/models/debug'
describe('Model Config Utilities', () => {
describe('userInputsFormToPromptVariables', () => {
/**
* Test handling of null or undefined input
* Should return empty array when no inputs provided
*/
it('should return empty array for null input', () => {
const result = userInputsFormToPromptVariables(null)
expect(result).toEqual([])
})
/**
* Test conversion of text-input (string) type
* Text inputs are the most common form field type
*/
it('should convert text-input to string prompt variable', () => {
const userInputs: UserInputFormItem[] = [
{
'text-input': {
label: 'User Name',
variable: 'user_name',
required: true,
max_length: 100,
default: '',
hide: false,
},
},
]
const result = userInputsFormToPromptVariables(userInputs)
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
key: 'user_name',
name: 'User Name',
required: true,
type: 'string',
max_length: 100,
options: [],
is_context_var: false,
hide: false,
default: '',
})
})
/**
* Test conversion of paragraph type
* Paragraphs are multi-line text inputs
*/
it('should convert paragraph to paragraph prompt variable', () => {
const userInputs: UserInputFormItem[] = [
{
paragraph: {
label: 'Description',
variable: 'description',
required: false,
max_length: 500,
default: '',
hide: false,
},
},
]
const result = userInputsFormToPromptVariables(userInputs)
expect(result[0]).toEqual({
key: 'description',
name: 'Description',
required: false,
type: 'paragraph',
max_length: 500,
options: [],
is_context_var: false,
hide: false,
default: '',
})
})
/**
* Test conversion of number type
* Number inputs should preserve numeric constraints
*/
it('should convert number input to number prompt variable', () => {
const userInputs: UserInputFormItem[] = [
{
number: {
label: 'Age',
variable: 'age',
required: true,
default: '',
hide: false,
},
} as any,
]
const result = userInputsFormToPromptVariables(userInputs)
expect(result[0]).toEqual({
key: 'age',
name: 'Age',
required: true,
type: 'number',
options: [],
hide: false,
default: '',
})
})
/**
* Test conversion of checkbox (boolean) type
* Checkboxes are converted to 'checkbox' type in prompt variables
*/
it('should convert checkbox to checkbox prompt variable', () => {
const userInputs: UserInputFormItem[] = [
{
checkbox: {
label: 'Accept Terms',
variable: 'accept_terms',
required: true,
default: '',
hide: false,
},
} as any,
]
const result = userInputsFormToPromptVariables(userInputs)
expect(result[0]).toEqual({
key: 'accept_terms',
name: 'Accept Terms',
required: true,
type: 'checkbox',
options: [],
hide: false,
default: '',
})
})
/**
* Test conversion of select (dropdown) type
* Select inputs include options array
*/
it('should convert select input to select prompt variable', () => {
const userInputs: UserInputFormItem[] = [
{
select: {
label: 'Country',
variable: 'country',
required: true,
options: ['USA', 'Canada', 'Mexico'],
default: 'USA',
hide: false,
},
},
]
const result = userInputsFormToPromptVariables(userInputs)
expect(result[0]).toEqual({
key: 'country',
name: 'Country',
required: true,
type: 'select',
options: ['USA', 'Canada', 'Mexico'],
is_context_var: false,
hide: false,
default: 'USA',
})
})
/**
* Test conversion of file upload type
* File inputs include configuration for allowed types and upload methods
*/
it('should convert file input to file prompt variable', () => {
const userInputs: UserInputFormItem[] = [
{
file: {
label: 'Profile Picture',
variable: 'profile_pic',
required: false,
allowed_file_types: ['image'],
allowed_file_extensions: ['.jpg', '.png'],
allowed_file_upload_methods: ['local_file', 'remote_url'],
default: '',
hide: false,
},
} as any,
]
const result = userInputsFormToPromptVariables(userInputs)
expect(result[0]).toEqual({
key: 'profile_pic',
name: 'Profile Picture',
required: false,
type: 'file',
config: {
allowed_file_types: ['image'],
allowed_file_extensions: ['.jpg', '.png'],
allowed_file_upload_methods: ['local_file', 'remote_url'],
number_limits: 1,
},
hide: false,
default: '',
})
})
/**
* Test conversion of file-list type
* File lists allow multiple file uploads with a max_length constraint
*/
it('should convert file-list input to file-list prompt variable', () => {
const userInputs: UserInputFormItem[] = [
{
'file-list': {
label: 'Documents',
variable: 'documents',
required: true,
allowed_file_types: ['document'],
allowed_file_extensions: ['.pdf', '.docx'],
allowed_file_upload_methods: ['local_file'],
max_length: 5,
default: '',
hide: false,
},
} as any,
]
const result = userInputsFormToPromptVariables(userInputs)
expect(result[0]).toEqual({
key: 'documents',
name: 'Documents',
required: true,
type: 'file-list',
config: {
allowed_file_types: ['document'],
allowed_file_extensions: ['.pdf', '.docx'],
allowed_file_upload_methods: ['local_file'],
number_limits: 5,
},
hide: false,
default: '',
})
})
/**
* Test conversion of external_data_tool type
* External data tools have custom configuration and icons
*/
it('should convert external_data_tool to prompt variable', () => {
const userInputs: UserInputFormItem[] = [
{
external_data_tool: {
label: 'API Data',
variable: 'api_data',
type: 'api',
enabled: true,
required: false,
config: { endpoint: 'https://api.example.com' },
icon: 'api-icon',
icon_background: '#FF5733',
hide: false,
},
} as any,
]
const result = userInputsFormToPromptVariables(userInputs)
expect(result[0]).toEqual({
key: 'api_data',
name: 'API Data',
required: false,
type: 'api',
enabled: true,
config: { endpoint: 'https://api.example.com' },
icon: 'api-icon',
icon_background: '#FF5733',
is_context_var: false,
hide: false,
})
})
/**
* Test handling of dataset_query_variable
* When a variable matches the dataset_query_variable, is_context_var should be true
*/
it('should mark variable as context var when matching dataset_query_variable', () => {
const userInputs: UserInputFormItem[] = [
{
'text-input': {
label: 'Query',
variable: 'query',
required: true,
max_length: 200,
default: '',
hide: false,
},
},
]
const result = userInputsFormToPromptVariables(userInputs, 'query')
expect(result[0].is_context_var).toBe(true)
})
/**
* Test conversion of multiple mixed input types
* Should handle an array with different input types correctly
*/
it('should convert multiple mixed input types', () => {
const userInputs: UserInputFormItem[] = [
{
'text-input': {
label: 'Name',
variable: 'name',
required: true,
max_length: 50,
default: '',
hide: false,
},
},
{
number: {
label: 'Age',
variable: 'age',
required: false,
default: '',
hide: false,
},
} as any,
{
select: {
label: 'Gender',
variable: 'gender',
required: true,
options: ['Male', 'Female', 'Other'],
default: '',
hide: false,
},
},
]
const result = userInputsFormToPromptVariables(userInputs)
expect(result).toHaveLength(3)
expect(result[0].type).toBe('string')
expect(result[1].type).toBe('number')
expect(result[2].type).toBe('select')
})
})
describe('promptVariablesToUserInputsForm', () => {
/**
* Test conversion of string prompt variable back to text-input
*/
it('should convert string prompt variable to text-input', () => {
const promptVariables: PromptVariable[] = [
{
key: 'user_name',
name: 'User Name',
required: true,
type: 'string',
max_length: 100,
options: [],
},
]
const result = promptVariablesToUserInputsForm(promptVariables)
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
'text-input': {
label: 'User Name',
variable: 'user_name',
required: true,
max_length: 100,
default: '',
hide: undefined,
},
})
})
/**
* Test conversion of paragraph prompt variable
*/
it('should convert paragraph prompt variable to paragraph input', () => {
const promptVariables: PromptVariable[] = [
{
key: 'description',
name: 'Description',
required: false,
type: 'paragraph',
max_length: 500,
options: [],
},
]
const result = promptVariablesToUserInputsForm(promptVariables)
expect(result[0]).toEqual({
paragraph: {
label: 'Description',
variable: 'description',
required: false,
max_length: 500,
default: '',
hide: undefined,
},
})
})
/**
* Test conversion of number prompt variable
*/
it('should convert number prompt variable to number input', () => {
const promptVariables: PromptVariable[] = [
{
key: 'age',
name: 'Age',
required: true,
type: 'number',
options: [],
},
]
const result = promptVariablesToUserInputsForm(promptVariables)
expect(result[0]).toEqual({
number: {
label: 'Age',
variable: 'age',
required: true,
default: '',
hide: undefined,
},
})
})
/**
* Test conversion of checkbox prompt variable
*/
it('should convert checkbox prompt variable to checkbox input', () => {
const promptVariables: PromptVariable[] = [
{
key: 'accept_terms',
name: 'Accept Terms',
required: true,
type: 'checkbox',
options: [],
},
]
const result = promptVariablesToUserInputsForm(promptVariables)
expect(result[0]).toEqual({
checkbox: {
label: 'Accept Terms',
variable: 'accept_terms',
required: true,
default: '',
hide: undefined,
},
})
})
/**
* Test conversion of select prompt variable
*/
it('should convert select prompt variable to select input', () => {
const promptVariables: PromptVariable[] = [
{
key: 'country',
name: 'Country',
required: true,
type: 'select',
options: ['USA', 'Canada', 'Mexico'],
default: 'USA',
},
]
const result = promptVariablesToUserInputsForm(promptVariables)
expect(result[0]).toEqual({
select: {
label: 'Country',
variable: 'country',
required: true,
options: ['USA', 'Canada', 'Mexico'],
default: 'USA',
hide: undefined,
},
})
})
/**
* Test filtering of invalid prompt variables
* Variables without key or name should be filtered out
*/
it('should filter out variables with empty key or name', () => {
const promptVariables: PromptVariable[] = [
{
key: '',
name: 'Empty Key',
required: true,
type: 'string',
options: [],
},
{
key: 'valid',
name: '',
required: true,
type: 'string',
options: [],
},
{
key: ' ',
name: 'Whitespace Key',
required: true,
type: 'string',
options: [],
},
{
key: 'valid_key',
name: 'Valid Name',
required: true,
type: 'string',
options: [],
},
]
const result = promptVariablesToUserInputsForm(promptVariables)
expect(result).toHaveLength(1)
expect((result[0] as any)['text-input']?.variable).toBe('valid_key')
})
/**
* Test conversion of external data tool prompt variable
*/
it('should convert external data tool prompt variable', () => {
const promptVariables: PromptVariable[] = [
{
key: 'api_data',
name: 'API Data',
required: false,
type: 'api',
enabled: true,
config: { endpoint: 'https://api.example.com' },
icon: 'api-icon',
icon_background: '#FF5733',
},
]
const result = promptVariablesToUserInputsForm(promptVariables)
expect(result[0]).toEqual({
external_data_tool: {
label: 'API Data',
variable: 'api_data',
enabled: true,
type: 'api',
config: { endpoint: 'https://api.example.com' },
required: false,
icon: 'api-icon',
icon_background: '#FF5733',
hide: undefined,
},
})
})
/**
* Test that required defaults to true when not explicitly set to false
*/
it('should default required to true when not false', () => {
const promptVariables: PromptVariable[] = [
{
key: 'test1',
name: 'Test 1',
required: undefined,
type: 'string',
options: [],
},
{
key: 'test2',
name: 'Test 2',
required: false,
type: 'string',
options: [],
},
]
const result = promptVariablesToUserInputsForm(promptVariables)
expect((result[0] as any)['text-input']?.required).toBe(true)
expect((result[1] as any)['text-input']?.required).toBe(false)
})
})
describe('formatBooleanInputs', () => {
/**
* Test that null or undefined inputs are handled gracefully
*/
it('should return inputs unchanged when useInputs is null', () => {
const inputs = { key1: 'value1', key2: 'value2' }
const result = formatBooleanInputs(null, inputs)
expect(result).toEqual(inputs)
})
it('should return inputs unchanged when useInputs is undefined', () => {
const inputs = { key1: 'value1', key2: 'value2' }
const result = formatBooleanInputs(undefined, inputs)
expect(result).toEqual(inputs)
})
/**
* Test conversion of boolean input values to actual boolean type
* This is important for proper type handling in the backend
* Note: checkbox inputs are converted to type 'checkbox' by userInputsFormToPromptVariables
*/
it('should convert boolean inputs to boolean type', () => {
const useInputs: PromptVariable[] = [
{
key: 'accept_terms',
name: 'Accept Terms',
required: true,
type: 'checkbox',
options: [],
},
{
key: 'subscribe',
name: 'Subscribe',
required: false,
type: 'checkbox',
options: [],
},
]
const inputs = {
accept_terms: 'true',
subscribe: '',
other_field: 'value',
}
const result = formatBooleanInputs(useInputs, inputs)
expect(result).toEqual({
accept_terms: true,
subscribe: false,
other_field: 'value',
})
})
/**
* Test that non-boolean inputs are not affected
*/
it('should not modify non-boolean inputs', () => {
const useInputs: PromptVariable[] = [
{
key: 'name',
name: 'Name',
required: true,
type: 'string',
options: [],
},
{
key: 'age',
name: 'Age',
required: true,
type: 'number',
options: [],
},
]
const inputs = {
name: 'John Doe',
age: 30,
}
const result = formatBooleanInputs(useInputs, inputs)
expect(result).toEqual(inputs)
})
/**
* Test handling of truthy and falsy values for boolean conversion
* Note: checkbox inputs are converted to type 'checkbox' by userInputsFormToPromptVariables
*/
it('should handle various truthy and falsy values', () => {
const useInputs: PromptVariable[] = [
{
key: 'bool1',
name: 'Bool 1',
required: true,
type: 'checkbox',
options: [],
},
{
key: 'bool2',
name: 'Bool 2',
required: true,
type: 'checkbox',
options: [],
},
{
key: 'bool3',
name: 'Bool 3',
required: true,
type: 'checkbox',
options: [],
},
{
key: 'bool4',
name: 'Bool 4',
required: true,
type: 'checkbox',
options: [],
},
]
const inputs = {
bool1: 1,
bool2: 0,
bool3: 'yes',
bool4: null as any,
}
const result = formatBooleanInputs(useInputs, inputs)
expect(result?.bool1).toBe(true)
expect(result?.bool2).toBe(false)
expect(result?.bool3).toBe(true)
expect(result?.bool4).toBe(false)
})
/**
* Test that the function creates a new object and doesn't mutate the original
* Note: checkbox inputs are converted to type 'checkbox' by userInputsFormToPromptVariables
*/
it('should not mutate original inputs object', () => {
const useInputs: PromptVariable[] = [
{
key: 'flag',
name: 'Flag',
required: true,
type: 'checkbox',
options: [],
},
]
const inputs = { flag: 'true', other: 'value' }
const originalInputs = { ...inputs }
formatBooleanInputs(useInputs, inputs)
expect(inputs).toEqual(originalInputs)
})
})
describe('Round-trip conversion', () => {
/**
* Test that converting from UserInputForm to PromptVariable and back
* preserves the essential data (though some fields may have defaults applied)
*/
it('should preserve data through round-trip conversion', () => {
const originalUserInputs: UserInputFormItem[] = [
{
'text-input': {
label: 'Name',
variable: 'name',
required: true,
max_length: 50,
default: '',
hide: false,
},
},
{
select: {
label: 'Type',
variable: 'type',
required: false,
options: ['A', 'B', 'C'],
default: 'A',
hide: false,
},
},
]
const promptVars = userInputsFormToPromptVariables(originalUserInputs)
const backToUserInputs = promptVariablesToUserInputsForm(promptVars)
expect(backToUserInputs).toHaveLength(2)
expect((backToUserInputs[0] as any)['text-input']?.variable).toBe('name')
expect((backToUserInputs[1] as any).select?.variable).toBe('type')
expect((backToUserInputs[1] as any).select?.options).toEqual(['A', 'B', 'C'])
})
})
})

View File

@@ -200,7 +200,7 @@ export const formatBooleanInputs = (useInputs?: PromptVariable[] | null, inputs?
return inputs
const res = { ...inputs }
useInputs.forEach((item) => {
const isBooleanInput = item.type === 'boolean'
const isBooleanInput = item.type === 'checkbox'
if (isBooleanInput) {
// Convert boolean inputs to boolean type
res[item.key] = !!res[item.key]