diff --git a/api/.env.example b/api/.env.example index 64fe20aa27..b1ac15d25b 100644 --- a/api/.env.example +++ b/api/.env.example @@ -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) diff --git a/api/app.py b/api/app.py index 4ed743dcb4..99f70f32d5 100644 --- a/api/app.py +++ b/api/app.py @@ -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 diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 65f07d65c3..ff1f983f94 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -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" diff --git a/api/models/provider.py b/api/models/provider.py index e9365adb93..4de17a7fd5 100644 --- a/api/models/provider.py +++ b/api/models/provider.py @@ -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): diff --git a/api/services/workflow_draft_variable_service.py b/api/services/workflow_draft_variable_service.py index 2690b55dbc..c5d1f6ab13 100644 --- a/api/services/workflow_draft_variable_service.py +++ b/api/services/workflow_draft_variable_service.py @@ -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, diff --git a/dev/basedpyright-check b/dev/basedpyright-check index 1c87b27d6f..1b3d1df7ad 100755 --- a/dev/basedpyright-check +++ b/dev/basedpyright-check @@ -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 diff --git a/web/app/components/base/file-uploader/hooks.ts b/web/app/components/base/file-uploader/hooks.ts index 9675123fe7..521ecdbafd 100644 --- a/web/app/components/base/file-uploader/hooks.ts +++ b/web/app/components/base/file-uploader/hooks.ts @@ -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) => { diff --git a/web/hooks/use-breakpoints.spec.ts b/web/hooks/use-breakpoints.spec.ts index 315e514f0f..8b29fe486c 100644 --- a/web/hooks/use-breakpoints.spec.ts +++ b/web/hooks/use-breakpoints.spec.ts @@ -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') diff --git a/web/hooks/use-document-title.spec.ts b/web/hooks/use-document-title.spec.ts index a8d3d56cff..fbc82a0cdf 100644 --- a/web/hooks/use-document-title.spec.ts +++ b/web/hooks/use-document-title.spec.ts @@ -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') diff --git a/web/hooks/use-format-time-from-now.spec.ts b/web/hooks/use-format-time-from-now.spec.ts new file mode 100644 index 0000000000..92ed37515c --- /dev/null +++ b/web/hooks/use-format-time-from-now.spec.ts @@ -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) + }) + }) +}) diff --git a/web/hooks/use-tab-searchparams.spec.ts b/web/hooks/use-tab-searchparams.spec.ts new file mode 100644 index 0000000000..62adea529f --- /dev/null +++ b/web/hooks/use-tab-searchparams.spec.ts @@ -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() + }) + }) +}) diff --git a/web/service/utils.spec.ts b/web/service/utils.spec.ts new file mode 100644 index 0000000000..fc5385c309 --- /dev/null +++ b/web/service/utils.spec.ts @@ -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(/\/$/) + }) + }) +}) diff --git a/web/utils/clipboard.spec.ts b/web/utils/clipboard.spec.ts index ccdafe83f4..be64cbbe13 100644 --- a/web/utils/clipboard.spec.ts +++ b/web/utils/clipboard.spec.ts @@ -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', { diff --git a/web/utils/context.spec.ts b/web/utils/context.spec.ts new file mode 100644 index 0000000000..fb72e4f4de --- /dev/null +++ b/web/utils/context.spec.ts @@ -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({ + 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({ + 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({ + 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({ + 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({ 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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() + + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => { /* suppress error */ }) + + expect(() => { + renderHook(() => useTestContext()) + }).toThrow('No related context found.') + + consoleError.mockRestore() + }) + }) +}) diff --git a/web/utils/model-config.spec.ts b/web/utils/model-config.spec.ts new file mode 100644 index 0000000000..2cccaabc61 --- /dev/null +++ b/web/utils/model-config.spec.ts @@ -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']) + }) + }) +}) diff --git a/web/utils/model-config.ts b/web/utils/model-config.ts index 3f655ce036..707a3685b9 100644 --- a/web/utils/model-config.ts +++ b/web/utils/model-config.ts @@ -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]