From 5c06e285ecb7ed2e6ab7aa0284bf3c8b2c2a6c0a Mon Sep 17 00:00:00 2001 From: Gritty_dev <101377478+codomposer@users.noreply.github.com> Date: Wed, 12 Nov 2025 08:47:06 -0500 Subject: [PATCH 01/27] test: create some hooks and utils test script, modified clipboard test script (#27928) --- web/hooks/use-breakpoints.spec.ts | 44 +- web/hooks/use-document-title.spec.ts | 46 ++ web/hooks/use-format-time-from-now.spec.ts | 376 ++++++++++ web/hooks/use-tab-searchparams.spec.ts | 543 ++++++++++++++ web/service/utils.spec.ts | 170 +++++ web/utils/clipboard.spec.ts | 39 + web/utils/context.spec.ts | 253 +++++++ web/utils/model-config.spec.ts | 819 +++++++++++++++++++++ web/utils/model-config.ts | 2 +- 9 files changed, 2289 insertions(+), 3 deletions(-) create mode 100644 web/hooks/use-format-time-from-now.spec.ts create mode 100644 web/hooks/use-tab-searchparams.spec.ts create mode 100644 web/service/utils.spec.ts create mode 100644 web/utils/context.spec.ts create mode 100644 web/utils/model-config.spec.ts 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] From 1bbb9d664451180aed73b92b9f5def9565fa6f61 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 12 Nov 2025 22:50:13 +0900 Subject: [PATCH 02/27] convert to TypeBase (#27935) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- api/models/provider.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) 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): From fe6538b08d786299a5c13c99f8593f8eae83a522 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Wed, 12 Nov 2025 22:55:02 +0800 Subject: [PATCH 03/27] chore: disable workflow logs auto-cleanup by default (#28136) This PR changes the default value of `WORKFLOW_LOG_CLEANUP_ENABLED` from `true` to `false` across all configuration files. ## Motivation Setting the default to `false` provides safer default behavior by: - Preventing unintended data loss for new installations - Giving users explicit control over when to enable log cleanup - Following the opt-in principle for data deletion features Users who need automatic cleanup can enable it by setting `WORKFLOW_LOG_CLEANUP_ENABLED=true` in their configuration. --- api/.env.example | 2 +- api/configs/feature/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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" From 805a1479f974fdb7b84880a59bdfa375090da49c Mon Sep 17 00:00:00 2001 From: Maries Date: Thu, 13 Nov 2025 10:59:31 +0800 Subject: [PATCH 04/27] fix: simplify graph structure validation in WorkflowService (#28146) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- api/services/workflow_service.py | 52 +++++++++++--------------------- 1 file changed, 18 insertions(+), 34 deletions(-) diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index b6d64d95da..e8088e17c1 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -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: From 2799b79e8c5a002ed0861a84b4f8552d816ae701 Mon Sep 17 00:00:00 2001 From: mnasrautinno Date: Thu, 13 Nov 2025 06:44:04 +0300 Subject: [PATCH 05/27] fix: app's ai site text to speech api (#28091) --- api/controllers/web/audio.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/api/controllers/web/audio.py b/api/controllers/web/audio.py index 3103851088..b9fef48c4d 100644 --- a/api/controllers/web/audio.py +++ b/api/controllers/web/audio.py @@ -88,12 +88,6 @@ class AudioApi(WebApiResource): @web_ns.route("/text-to-audio") class TextApi(WebApiResource): - text_to_audio_response_fields = { - "audio_url": fields.String, - "duration": fields.Float, - } - - @marshal_with(text_to_audio_response_fields) @web_ns.doc("Text to Audio") @web_ns.doc(description="Convert text to audio using text-to-speech service.") @web_ns.doc( From b0e7e7752f183d0453d214fc27293a4530a12e98 Mon Sep 17 00:00:00 2001 From: yangzheli <43645580+yangzheli@users.noreply.github.com> Date: Thu, 13 Nov 2025 11:44:21 +0800 Subject: [PATCH 06/27] refactor(web): reuse the same edit-custom-collection-modal component, and fix the pop up error (#28003) --- .../edit-custom-collection-modal/index.tsx | 3 + .../edit-custom-collection-modal/modal.tsx | 361 ------------------ .../workflow/block-selector/tool-picker.tsx | 4 +- 3 files changed, 5 insertions(+), 363 deletions(-) delete mode 100644 web/app/components/tools/edit-custom-collection-modal/modal.tsx diff --git a/web/app/components/tools/edit-custom-collection-modal/index.tsx b/web/app/components/tools/edit-custom-collection-modal/index.tsx index 95a204c1ec..48801b018f 100644 --- a/web/app/components/tools/edit-custom-collection-modal/index.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/index.tsx @@ -24,6 +24,7 @@ import Toast from '@/app/components/base/toast' type Props = { positionLeft?: boolean + dialogClassName?: string payload: any onHide: () => void onAdd?: (payload: CustomCollectionBackend) => void @@ -33,6 +34,7 @@ type Props = { // Add and Edit const EditCustomCollectionModal: FC = ({ positionLeft, + dialogClassName = '', payload, onHide, onAdd, @@ -186,6 +188,7 @@ const EditCustomCollectionModal: FC = ({ positionCenter={isAdd && !positionLeft} onHide={onHide} title={t(`tools.createTool.${isAdd ? 'title' : 'editTitle'}`)!} + dialogClassName={dialogClassName} panelClassName='mt-2 !w-[640px]' maxWidthClassName='!max-w-[640px]' height='calc(100vh - 16px)' diff --git a/web/app/components/tools/edit-custom-collection-modal/modal.tsx b/web/app/components/tools/edit-custom-collection-modal/modal.tsx deleted file mode 100644 index 3e278f7b53..0000000000 --- a/web/app/components/tools/edit-custom-collection-modal/modal.tsx +++ /dev/null @@ -1,361 +0,0 @@ -'use client' -import type { FC } from 'react' -import React, { useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { useDebounce, useGetState } from 'ahooks' -import { produce } from 'immer' -import { LinkExternal02, Settings01 } from '../../base/icons/src/vender/line/general' -import type { Credential, CustomCollectionBackend, CustomParamSchema, Emoji } from '../types' -import { AuthHeaderPrefix, AuthType } from '../types' -import GetSchema from './get-schema' -import ConfigCredentials from './config-credentials' -import TestApi from './test-api' -import cn from '@/utils/classnames' -import Input from '@/app/components/base/input' -import Textarea from '@/app/components/base/textarea' -import EmojiPicker from '@/app/components/base/emoji-picker' -import AppIcon from '@/app/components/base/app-icon' -import { parseParamsSchema } from '@/service/tools' -import LabelSelector from '@/app/components/tools/labels/selector' -import Toast from '@/app/components/base/toast' -import Modal from '../../base/modal' -import Button from '@/app/components/base/button' - -type Props = { - positionLeft?: boolean - payload: any - onHide: () => void - onAdd?: (payload: CustomCollectionBackend) => void - onRemove?: () => void - onEdit?: (payload: CustomCollectionBackend) => void -} -// Add and Edit -const EditCustomCollectionModal: FC = ({ - payload, - onHide, - onAdd, - onEdit, - onRemove, -}) => { - const { t } = useTranslation() - const isAdd = !payload - const isEdit = !!payload - - const [editFirst, setEditFirst] = useState(!isAdd) - const [paramsSchemas, setParamsSchemas] = useState(payload?.tools || []) - const [customCollection, setCustomCollection, getCustomCollection] = useGetState(isAdd - ? { - provider: '', - credentials: { - auth_type: AuthType.none, - api_key_header: 'Authorization', - api_key_header_prefix: AuthHeaderPrefix.basic, - }, - icon: { - content: '🕵️', - background: '#FEF7C3', - }, - schema_type: '', - schema: '', - } - : payload) - - const originalProvider = isEdit ? payload.provider : '' - - const [showEmojiPicker, setShowEmojiPicker] = useState(false) - const emoji = customCollection.icon - const setEmoji = (emoji: Emoji) => { - const newCollection = produce(customCollection, (draft) => { - draft.icon = emoji - }) - setCustomCollection(newCollection) - } - const schema = customCollection.schema - const debouncedSchema = useDebounce(schema, { wait: 500 }) - const setSchema = (schema: any) => { - const newCollection = produce(customCollection, (draft) => { - draft.schema = schema - }) - setCustomCollection(newCollection) - } - - useEffect(() => { - if (!debouncedSchema) - return - if (isEdit && editFirst) { - setEditFirst(false) - return - } - (async () => { - try { - const { parameters_schema, schema_type } = await parseParamsSchema(debouncedSchema) - const customCollection = getCustomCollection() - const newCollection = produce(customCollection, (draft) => { - draft.schema_type = schema_type - }) - setCustomCollection(newCollection) - setParamsSchemas(parameters_schema) - } - catch { - const customCollection = getCustomCollection() - const newCollection = produce(customCollection, (draft) => { - draft.schema_type = '' - }) - setCustomCollection(newCollection) - setParamsSchemas([]) - } - })() - }, [debouncedSchema]) - - const [credentialsModalShow, setCredentialsModalShow] = useState(false) - const credential = customCollection.credentials - const setCredential = (credential: Credential) => { - const newCollection = produce(customCollection, (draft) => { - draft.credentials = credential - }) - setCustomCollection(newCollection) - } - - const [currTool, setCurrTool] = useState(null) - const [isShowTestApi, setIsShowTestApi] = useState(false) - - const [labels, setLabels] = useState(payload?.labels || []) - const handleLabelSelect = (value: string[]) => { - setLabels(value) - } - - const handleSave = () => { - // const postData = clone(customCollection) - const postData = produce(customCollection, (draft) => { - delete draft.tools - - if (draft.credentials.auth_type === AuthType.none) { - delete draft.credentials.api_key_header - delete draft.credentials.api_key_header_prefix - delete draft.credentials.api_key_value - } - - draft.labels = labels - }) - - let errorMessage = '' - if (!postData.provider) - errorMessage = t('common.errorMsg.fieldRequired', { field: t('tools.createTool.name') }) - - if (!postData.schema) - errorMessage = t('common.errorMsg.fieldRequired', { field: t('tools.createTool.schema') }) - - if (errorMessage) { - Toast.notify({ - type: 'error', - message: errorMessage, - }) - return - } - - if (isAdd) { - onAdd?.(postData) - return - } - - onEdit?.({ - ...postData, - original_provider: originalProvider, - }) - } - - const getPath = (url: string) => { - if (!url) - return '' - - try { - const path = decodeURI(new URL(url).pathname) - return path || '' - } - catch { - return url - } - } - - return ( - <> - -
-
- {t('tools.createTool.title')} -
-
-
-
{t('tools.createTool.name')} *
-
- { setShowEmojiPicker(true) }} className='cursor-pointer' icon={emoji.content} background={emoji.background} /> - { - const newCollection = produce(customCollection, (draft) => { - draft.provider = e.target.value - }) - setCustomCollection(newCollection) - }} - /> -
-
- - {/* Schema */} -
-
-
-
{t('tools.createTool.schema')}*
-
- -
{t('tools.createTool.viewSchemaSpec')}
- -
-
- - -
-