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

This commit is contained in:
lyzno1 2025-11-13 11:37:48 +08:00
commit 87954a8226
No known key found for this signature in database
17 changed files with 2355 additions and 60 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

@ -10,20 +10,17 @@ from sqlalchemy.orm import Session, sessionmaker
from core.app.app_config.entities import VariableEntityType
from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager
from core.app.entities.app_invoke_entities import InvokeFrom
from core.file import File
from core.repositories import DifyCoreRepositoryFactory
from core.variables import Variable
from core.variables.variables import VariableUnion
from core.workflow.entities import GraphInitParams, GraphRuntimeState, VariablePool, WorkflowNodeExecution
from core.workflow.entities import VariablePool, WorkflowNodeExecution
from core.workflow.enums import ErrorStrategy, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
from core.workflow.errors import WorkflowNodeRunFailedError
from core.workflow.graph.graph import Graph
from core.workflow.graph_events import GraphNodeEventBase, NodeRunFailedEvent, NodeRunSucceededEvent
from core.workflow.node_events import NodeRunResult
from core.workflow.nodes import NodeType
from core.workflow.nodes.base.node import Node
from core.workflow.nodes.node_factory import DifyNodeFactory
from core.workflow.nodes.node_mapping import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING
from core.workflow.nodes.start.entities import StartNodeData
from core.workflow.system_variable import SystemVariable
@ -34,7 +31,6 @@ from extensions.ext_storage import storage
from factories.file_factory import build_from_mapping, build_from_mappings
from libs.datetime_utils import naive_utc_now
from models import Account
from models.enums import UserFrom
from models.model import App, AppMode
from models.tools import WorkflowToolProvider
from models.workflow import Workflow, WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom, WorkflowType
@ -215,7 +211,7 @@ class WorkflowService:
self.validate_features_structure(app_model=app_model, features=features)
# validate graph structure
self.validate_graph_structure(user_id=account.id, app_model=app_model, graph=graph)
self.validate_graph_structure(graph=graph)
# create draft workflow if not found
if not workflow:
@ -274,7 +270,7 @@ class WorkflowService:
self._validate_workflow_credentials(draft_workflow)
# validate graph structure
self.validate_graph_structure(user_id=account.id, app_model=app_model, graph=draft_workflow.graph_dict)
self.validate_graph_structure(graph=draft_workflow.graph_dict)
# create new workflow
workflow = Workflow.new(
@ -905,42 +901,30 @@ class WorkflowService:
return new_app
def validate_graph_structure(self, user_id: str, app_model: App, graph: Mapping[str, Any]):
def validate_graph_structure(self, graph: Mapping[str, Any]):
"""
Validate workflow graph structure by instantiating the Graph object.
Validate workflow graph structure.
This leverages the built-in graph validators (including trigger/UserInput exclusivity)
and raises any structural errors before persisting the workflow.
This performs a lightweight validation on the graph, checking for structural
inconsistencies such as the coexistence of start and trigger nodes.
"""
node_configs = graph.get("nodes", [])
node_configs = cast(list[dict[str, object]], node_configs)
node_configs = cast(list[dict[str, Any]], node_configs)
# is empty graph
if not node_configs:
return
workflow_id = app_model.workflow_id or "UNKNOWN"
Graph.init(
graph_config=graph,
# TODO(Mairuis): Add root node id
root_node_id=None,
node_factory=DifyNodeFactory(
graph_init_params=GraphInitParams(
tenant_id=app_model.tenant_id,
app_id=app_model.id,
workflow_id=workflow_id,
graph_config=graph,
user_id=user_id,
user_from=UserFrom.ACCOUNT,
invoke_from=InvokeFrom.VALIDATION,
call_depth=0,
),
graph_runtime_state=GraphRuntimeState(
variable_pool=VariablePool(),
start_at=time.perf_counter(),
),
),
)
node_types: set[NodeType] = set()
for node in node_configs:
node_type = node.get("data", {}).get("type")
if node_type:
node_types.add(NodeType(node_type))
# start node and trigger node cannot coexist
if NodeType.START in node_types:
if any(nt.is_trigger_node for nt in node_types):
raise ValueError("Start node and trigger nodes cannot coexist in the same workflow")
def validate_features_structure(self, app_model: App, features: dict):
if app_model.mode == AppMode.ADVANCED_CHAT:

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]