diff --git a/web/app/components/app/workflow-log/detail.spec.tsx b/web/app/components/app/workflow-log/detail.spec.tsx new file mode 100644 index 0000000000..48641307b9 --- /dev/null +++ b/web/app/components/app/workflow-log/detail.spec.tsx @@ -0,0 +1,325 @@ +/** + * DetailPanel Component Tests + * + * Tests the workflow run detail panel which displays: + * - Workflow run title + * - Replay button (when canReplay is true) + * - Close button + * - Run component with detail/tracing URLs + */ + +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import DetailPanel from './detail' +import { useStore as useAppStore } from '@/app/components/app/store' +import type { App, AppIconType, AppModeEnum } from '@/types/app' + +// ============================================================================ +// Mocks +// ============================================================================ + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +const mockRouterPush = jest.fn() +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockRouterPush, + }), +})) + +// Mock the Run component as it has complex dependencies +jest.mock('@/app/components/workflow/run', () => ({ + __esModule: true, + default: ({ runDetailUrl, tracingListUrl }: { runDetailUrl: string; tracingListUrl: string }) => ( +
+ {runDetailUrl} + {tracingListUrl} +
+ ), +})) + +// Mock WorkflowContextProvider +jest.mock('@/app/components/workflow/context', () => ({ + WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) + +// Mock ahooks for useBoolean (used by TooltipPlus) +jest.mock('ahooks', () => ({ + useBoolean: (initial: boolean) => { + const setters = { + setTrue: jest.fn(), + setFalse: jest.fn(), + toggle: jest.fn(), + } + return [initial, setters] as const + }, +})) + +// ============================================================================ +// Test Data Factories +// ============================================================================ + +const createMockApp = (overrides: Partial = {}): App => ({ + id: 'test-app-id', + name: 'Test App', + description: 'Test app description', + author_name: 'Test Author', + icon_type: 'emoji' as AppIconType, + icon: '🚀', + icon_background: '#FFEAD5', + icon_url: null, + use_icon_as_answer_icon: false, + mode: 'workflow' as AppModeEnum, + enable_site: true, + enable_api: true, + api_rpm: 60, + api_rph: 3600, + is_demo: false, + model_config: {} as App['model_config'], + app_model_config: {} as App['app_model_config'], + created_at: Date.now(), + updated_at: Date.now(), + site: { + access_token: 'token', + app_base_url: 'https://example.com', + } as App['site'], + api_base_url: 'https://api.example.com', + tags: [], + access_mode: 'public_access' as App['access_mode'], + ...overrides, +}) + +// ============================================================================ +// Tests +// ============================================================================ + +describe('DetailPanel', () => { + const defaultOnClose = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + useAppStore.setState({ appDetail: createMockApp() }) + }) + + // -------------------------------------------------------------------------- + // Rendering Tests (REQUIRED) + // -------------------------------------------------------------------------- + describe('Rendering', () => { + it('should render without crashing', () => { + render() + + expect(screen.getByText('appLog.runDetail.workflowTitle')).toBeInTheDocument() + }) + + it('should render workflow title', () => { + render() + + expect(screen.getByText('appLog.runDetail.workflowTitle')).toBeInTheDocument() + }) + + it('should render close button', () => { + const { container } = render() + + // Close button has RiCloseLine icon + const closeButton = container.querySelector('span.cursor-pointer') + expect(closeButton).toBeInTheDocument() + }) + + it('should render Run component with correct URLs', () => { + useAppStore.setState({ appDetail: createMockApp({ id: 'app-456' }) }) + + render() + + expect(screen.getByTestId('workflow-run')).toBeInTheDocument() + expect(screen.getByTestId('run-detail-url')).toHaveTextContent('/apps/app-456/workflow-runs/run-789') + expect(screen.getByTestId('tracing-list-url')).toHaveTextContent('/apps/app-456/workflow-runs/run-789/node-executions') + }) + + it('should render WorkflowContextProvider wrapper', () => { + render() + + expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Props Tests (REQUIRED) + // -------------------------------------------------------------------------- + describe('Props', () => { + it('should not render replay button when canReplay is false (default)', () => { + render() + + expect(screen.queryByRole('button', { name: 'appLog.runDetail.testWithParams' })).not.toBeInTheDocument() + }) + + it('should render replay button when canReplay is true', () => { + render() + + expect(screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' })).toBeInTheDocument() + }) + + it('should use empty URL when runID is empty', () => { + render() + + expect(screen.getByTestId('run-detail-url')).toHaveTextContent('') + expect(screen.getByTestId('tracing-list-url')).toHaveTextContent('') + }) + }) + + // -------------------------------------------------------------------------- + // User Interactions + // -------------------------------------------------------------------------- + describe('User Interactions', () => { + it('should call onClose when close button is clicked', async () => { + const user = userEvent.setup() + const onClose = jest.fn() + + const { container } = render() + + const closeButton = container.querySelector('span.cursor-pointer') + expect(closeButton).toBeInTheDocument() + + await user.click(closeButton!) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should navigate to workflow page with replayRunId when replay button is clicked', async () => { + const user = userEvent.setup() + useAppStore.setState({ appDetail: createMockApp({ id: 'app-replay-test' }) }) + + render() + + const replayButton = screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' }) + await user.click(replayButton) + + expect(mockRouterPush).toHaveBeenCalledWith('/app/app-replay-test/workflow?replayRunId=run-to-replay') + }) + + it('should not navigate when replay clicked but appDetail is missing', async () => { + const user = userEvent.setup() + useAppStore.setState({ appDetail: undefined }) + + render() + + const replayButton = screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' }) + await user.click(replayButton) + + expect(mockRouterPush).not.toHaveBeenCalled() + }) + }) + + // -------------------------------------------------------------------------- + // URL Generation Tests + // -------------------------------------------------------------------------- + describe('URL Generation', () => { + it('should generate correct run detail URL', () => { + useAppStore.setState({ appDetail: createMockApp({ id: 'my-app' }) }) + + render() + + expect(screen.getByTestId('run-detail-url')).toHaveTextContent('/apps/my-app/workflow-runs/my-run') + }) + + it('should generate correct tracing list URL', () => { + useAppStore.setState({ appDetail: createMockApp({ id: 'my-app' }) }) + + render() + + expect(screen.getByTestId('tracing-list-url')).toHaveTextContent('/apps/my-app/workflow-runs/my-run/node-executions') + }) + + it('should handle special characters in runID', () => { + useAppStore.setState({ appDetail: createMockApp({ id: 'app-id' }) }) + + render() + + expect(screen.getByTestId('run-detail-url')).toHaveTextContent('/apps/app-id/workflow-runs/run-with-special-123') + }) + }) + + // -------------------------------------------------------------------------- + // Store Integration Tests + // -------------------------------------------------------------------------- + describe('Store Integration', () => { + it('should read appDetail from store', () => { + useAppStore.setState({ appDetail: createMockApp({ id: 'store-app-id' }) }) + + render() + + expect(screen.getByTestId('run-detail-url')).toHaveTextContent('/apps/store-app-id/workflow-runs/run-123') + }) + + it('should handle undefined appDetail from store gracefully', () => { + useAppStore.setState({ appDetail: undefined }) + + render() + + // Run component should still render but with undefined in URL + expect(screen.getByTestId('workflow-run')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Edge Cases (REQUIRED) + // -------------------------------------------------------------------------- + describe('Edge Cases', () => { + it('should handle empty runID', () => { + render() + + expect(screen.getByTestId('run-detail-url')).toHaveTextContent('') + expect(screen.getByTestId('tracing-list-url')).toHaveTextContent('') + }) + + it('should handle very long runID', () => { + const longRunId = 'a'.repeat(100) + useAppStore.setState({ appDetail: createMockApp({ id: 'app-id' }) }) + + render() + + expect(screen.getByTestId('run-detail-url')).toHaveTextContent(`/apps/app-id/workflow-runs/${longRunId}`) + }) + + it('should render replay button with correct aria-label', () => { + render() + + const replayButton = screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' }) + expect(replayButton).toHaveAttribute('aria-label', 'appLog.runDetail.testWithParams') + }) + + it('should maintain proper component structure', () => { + const { container } = render() + + // Check for main container with flex layout + const mainContainer = container.querySelector('.flex.grow.flex-col') + expect(mainContainer).toBeInTheDocument() + + // Check for header section + const header = container.querySelector('.flex.items-center.bg-components-panel-bg') + expect(header).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Tooltip Tests + // -------------------------------------------------------------------------- + describe('Tooltip', () => { + it('should have tooltip on replay button', () => { + render() + + // The replay button should be wrapped in TooltipPlus + const replayButton = screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' }) + expect(replayButton).toBeInTheDocument() + + // TooltipPlus wraps the button with popupContent + // We verify the button exists with the correct aria-label + expect(replayButton).toHaveAttribute('type', 'button') + }) + }) +}) diff --git a/web/app/components/app/workflow-log/filter.spec.tsx b/web/app/components/app/workflow-log/filter.spec.tsx new file mode 100644 index 0000000000..416f0cd9d9 --- /dev/null +++ b/web/app/components/app/workflow-log/filter.spec.tsx @@ -0,0 +1,533 @@ +/** + * Filter Component Tests + * + * Tests the workflow log filter component which provides: + * - Status filtering (all, succeeded, failed, stopped, partial-succeeded) + * - Time period selection + * - Keyword search + */ + +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import Filter, { TIME_PERIOD_MAPPING } from './filter' +import type { QueryParam } from './index' + +// ============================================================================ +// Mocks +// ============================================================================ + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +const mockTrackEvent = jest.fn() +jest.mock('@/app/components/base/amplitude/utils', () => ({ + trackEvent: (...args: unknown[]) => mockTrackEvent(...args), +})) + +// ============================================================================ +// Test Data Factories +// ============================================================================ + +const createDefaultQueryParams = (overrides: Partial = {}): QueryParam => ({ + status: 'all', + period: '2', // default to last 7 days + ...overrides, +}) + +// ============================================================================ +// Tests +// ============================================================================ + +describe('Filter', () => { + const defaultSetQueryParams = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + }) + + // -------------------------------------------------------------------------- + // Rendering Tests (REQUIRED) + // -------------------------------------------------------------------------- + describe('Rendering', () => { + it('should render without crashing', () => { + render( + , + ) + + // Should render status chip, period chip, and search input + expect(screen.getByText('All')).toBeInTheDocument() + expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument() + }) + + it('should render all filter components', () => { + render( + , + ) + + // Status chip + expect(screen.getByText('All')).toBeInTheDocument() + // Period chip (shows translated key) + expect(screen.getByText('appLog.filter.period.last7days')).toBeInTheDocument() + // Search input + expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Status Filter Tests + // -------------------------------------------------------------------------- + describe('Status Filter', () => { + it('should display current status value', () => { + render( + , + ) + + // Chip should show Success for succeeded status + expect(screen.getByText('Success')).toBeInTheDocument() + }) + + it('should open status dropdown when clicked', async () => { + const user = userEvent.setup() + + render( + , + ) + + await user.click(screen.getByText('All')) + + // Should show all status options + await waitFor(() => { + expect(screen.getByText('Success')).toBeInTheDocument() + expect(screen.getByText('Fail')).toBeInTheDocument() + expect(screen.getByText('Stop')).toBeInTheDocument() + expect(screen.getByText('Partial Success')).toBeInTheDocument() + }) + }) + + it('should call setQueryParams when status is selected', async () => { + const user = userEvent.setup() + const setQueryParams = jest.fn() + + render( + , + ) + + await user.click(screen.getByText('All')) + await user.click(await screen.findByText('Success')) + + expect(setQueryParams).toHaveBeenCalledWith({ + status: 'succeeded', + period: '2', + }) + }) + + it('should track status selection event', async () => { + const user = userEvent.setup() + + render( + , + ) + + await user.click(screen.getByText('All')) + await user.click(await screen.findByText('Fail')) + + expect(mockTrackEvent).toHaveBeenCalledWith( + 'workflow_log_filter_status_selected', + { workflow_log_filter_status: 'failed' }, + ) + }) + + it('should reset to all when status is cleared', async () => { + const user = userEvent.setup() + const setQueryParams = jest.fn() + + const { container } = render( + , + ) + + // Find the clear icon (div with group/clear class) in the status chip + const clearIcon = container.querySelector('.group\\/clear') + + expect(clearIcon).toBeInTheDocument() + await user.click(clearIcon!) + + expect(setQueryParams).toHaveBeenCalledWith({ + status: 'all', + period: '2', + }) + }) + + test.each([ + ['all', 'All'], + ['succeeded', 'Success'], + ['failed', 'Fail'], + ['stopped', 'Stop'], + ['partial-succeeded', 'Partial Success'], + ])('should display correct label for %s status', (statusValue, expectedLabel) => { + render( + , + ) + + expect(screen.getByText(expectedLabel)).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Time Period Filter Tests + // -------------------------------------------------------------------------- + describe('Time Period Filter', () => { + it('should display current period value', () => { + render( + , + ) + + expect(screen.getByText('appLog.filter.period.today')).toBeInTheDocument() + }) + + it('should open period dropdown when clicked', async () => { + const user = userEvent.setup() + + render( + , + ) + + await user.click(screen.getByText('appLog.filter.period.last7days')) + + // Should show all period options + await waitFor(() => { + expect(screen.getByText('appLog.filter.period.today')).toBeInTheDocument() + expect(screen.getByText('appLog.filter.period.last4weeks')).toBeInTheDocument() + expect(screen.getByText('appLog.filter.period.last3months')).toBeInTheDocument() + expect(screen.getByText('appLog.filter.period.allTime')).toBeInTheDocument() + }) + }) + + it('should call setQueryParams when period is selected', async () => { + const user = userEvent.setup() + const setQueryParams = jest.fn() + + render( + , + ) + + await user.click(screen.getByText('appLog.filter.period.last7days')) + await user.click(await screen.findByText('appLog.filter.period.allTime')) + + expect(setQueryParams).toHaveBeenCalledWith({ + status: 'all', + period: '9', + }) + }) + + it('should reset period to allTime when cleared', async () => { + const user = userEvent.setup() + const setQueryParams = jest.fn() + + render( + , + ) + + // Find the period chip's clear button + const periodChip = screen.getByText('appLog.filter.period.last7days').closest('div') + const clearButton = periodChip?.querySelector('button[type="button"]') + + if (clearButton) { + await user.click(clearButton) + expect(setQueryParams).toHaveBeenCalledWith({ + status: 'all', + period: '9', + }) + } + }) + }) + + // -------------------------------------------------------------------------- + // Keyword Search Tests + // -------------------------------------------------------------------------- + describe('Keyword Search', () => { + it('should display current keyword value', () => { + render( + , + ) + + expect(screen.getByDisplayValue('test search')).toBeInTheDocument() + }) + + it('should call setQueryParams when typing in search', async () => { + const user = userEvent.setup() + const setQueryParams = jest.fn() + + render( + , + ) + + const input = screen.getByPlaceholderText('common.operation.search') + await user.type(input, 'workflow') + + // Should call setQueryParams for each character typed + expect(setQueryParams).toHaveBeenLastCalledWith( + expect.objectContaining({ keyword: 'workflow' }), + ) + }) + + it('should clear keyword when clear button is clicked', async () => { + const user = userEvent.setup() + const setQueryParams = jest.fn() + + const { container } = render( + , + ) + + // The Input component renders a clear icon div inside the input wrapper + // when showClearIcon is true and value exists + const inputWrapper = container.querySelector('.w-\\[200px\\]') + + // Find the clear icon div (has cursor-pointer class and contains RiCloseCircleFill) + const clearIconDiv = inputWrapper?.querySelector('div.cursor-pointer') + + expect(clearIconDiv).toBeInTheDocument() + await user.click(clearIconDiv!) + + expect(setQueryParams).toHaveBeenCalledWith({ + status: 'all', + period: '2', + keyword: '', + }) + }) + + it('should update on direct input change', () => { + const setQueryParams = jest.fn() + + render( + , + ) + + const input = screen.getByPlaceholderText('common.operation.search') + fireEvent.change(input, { target: { value: 'new search' } }) + + expect(setQueryParams).toHaveBeenCalledWith({ + status: 'all', + period: '2', + keyword: 'new search', + }) + }) + }) + + // -------------------------------------------------------------------------- + // TIME_PERIOD_MAPPING Tests + // -------------------------------------------------------------------------- + describe('TIME_PERIOD_MAPPING', () => { + it('should have correct mapping for today', () => { + expect(TIME_PERIOD_MAPPING['1']).toEqual({ value: 0, name: 'today' }) + }) + + it('should have correct mapping for last 7 days', () => { + expect(TIME_PERIOD_MAPPING['2']).toEqual({ value: 7, name: 'last7days' }) + }) + + it('should have correct mapping for last 4 weeks', () => { + expect(TIME_PERIOD_MAPPING['3']).toEqual({ value: 28, name: 'last4weeks' }) + }) + + it('should have correct mapping for all time', () => { + expect(TIME_PERIOD_MAPPING['9']).toEqual({ value: -1, name: 'allTime' }) + }) + + it('should have all 9 predefined time periods', () => { + expect(Object.keys(TIME_PERIOD_MAPPING)).toHaveLength(9) + }) + + test.each([ + ['1', 'today', 0], + ['2', 'last7days', 7], + ['3', 'last4weeks', 28], + ['9', 'allTime', -1], + ])('TIME_PERIOD_MAPPING[%s] should have name=%s and correct value', (key, name, expectedValue) => { + const mapping = TIME_PERIOD_MAPPING[key] + expect(mapping.name).toBe(name) + if (expectedValue >= 0) + expect(mapping.value).toBe(expectedValue) + else + expect(mapping.value).toBe(-1) + }) + }) + + // -------------------------------------------------------------------------- + // Edge Cases (REQUIRED) + // -------------------------------------------------------------------------- + describe('Edge Cases', () => { + it('should handle undefined keyword gracefully', () => { + render( + , + ) + + const input = screen.getByPlaceholderText('common.operation.search') + expect(input).toHaveValue('') + }) + + it('should handle empty string keyword', () => { + render( + , + ) + + const input = screen.getByPlaceholderText('common.operation.search') + expect(input).toHaveValue('') + }) + + it('should preserve other query params when updating status', async () => { + const user = userEvent.setup() + const setQueryParams = jest.fn() + + render( + , + ) + + await user.click(screen.getByText('All')) + await user.click(await screen.findByText('Success')) + + expect(setQueryParams).toHaveBeenCalledWith({ + status: 'succeeded', + period: '3', + keyword: 'test', + }) + }) + + it('should preserve other query params when updating period', async () => { + const user = userEvent.setup() + const setQueryParams = jest.fn() + + render( + , + ) + + await user.click(screen.getByText('appLog.filter.period.last7days')) + await user.click(await screen.findByText('appLog.filter.period.today')) + + expect(setQueryParams).toHaveBeenCalledWith({ + status: 'failed', + period: '1', + keyword: 'test', + }) + }) + + it('should preserve other query params when updating keyword', async () => { + const user = userEvent.setup() + const setQueryParams = jest.fn() + + render( + , + ) + + const input = screen.getByPlaceholderText('common.operation.search') + await user.type(input, 'a') + + expect(setQueryParams).toHaveBeenCalledWith({ + status: 'failed', + period: '3', + keyword: 'a', + }) + }) + }) + + // -------------------------------------------------------------------------- + // Integration Tests + // -------------------------------------------------------------------------- + describe('Integration', () => { + it('should render with all filters visible simultaneously', () => { + render( + , + ) + + expect(screen.getByText('Success')).toBeInTheDocument() + expect(screen.getByText('appLog.filter.period.today')).toBeInTheDocument() + expect(screen.getByDisplayValue('integration test')).toBeInTheDocument() + }) + + it('should have proper layout with flex and gap', () => { + const { container } = render( + , + ) + + const filterContainer = container.firstChild as HTMLElement + expect(filterContainer).toHaveClass('flex') + expect(filterContainer).toHaveClass('flex-row') + expect(filterContainer).toHaveClass('gap-2') + }) + }) +}) diff --git a/web/app/components/app/workflow-log/index.spec.tsx b/web/app/components/app/workflow-log/index.spec.tsx index 2ac9113a8e..b38c1e4d0f 100644 --- a/web/app/components/app/workflow-log/index.spec.tsx +++ b/web/app/components/app/workflow-log/index.spec.tsx @@ -1,105 +1,67 @@ -import React from 'react' +/** + * Logs Container Component Tests + * + * Tests the main Logs container component which: + * - Fetches workflow logs via useSWR + * - Manages query parameters (status, period, keyword) + * - Handles pagination + * - Renders Filter, List, and Empty states + * + * Note: Individual component tests are in their respective spec files: + * - filter.spec.tsx + * - list.spec.tsx + * - detail.spec.tsx + * - trigger-by-display.spec.tsx + */ + import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import useSWR from 'swr' - -// Import real components for integration testing -import Logs from './index' -import type { ILogsProps, QueryParam } from './index' -import Filter, { TIME_PERIOD_MAPPING } from './filter' -import WorkflowAppLogList from './list' -import TriggerByDisplay from './trigger-by-display' -import DetailPanel from './detail' - -// Import types from source +import Logs, { type ILogsProps } from './index' +import { TIME_PERIOD_MAPPING } from './filter' import type { App, AppIconType, AppModeEnum } from '@/types/app' -import type { TriggerMetadata, WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log' +import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log' import { WorkflowRunTriggeredFrom } from '@/models/log' import { APP_PAGE_LIMIT } from '@/config' -import { Theme } from '@/types/app' -// Mock external dependencies only +// ============================================================================ +// Mocks +// ============================================================================ + jest.mock('swr') + jest.mock('ahooks', () => ({ - useDebounce: (value: T): T => value, + useDebounce: (value: T) => value, + useDebounceFn: (fn: (value: string) => void) => ({ run: fn }), + useBoolean: (initial: boolean) => { + const setters = { + setTrue: jest.fn(), + setFalse: jest.fn(), + toggle: jest.fn(), + } + return [initial, setters] as const + }, })) -jest.mock('@/service/log', () => ({ - fetchWorkflowLogs: jest.fn(), + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + push: jest.fn(), + }), })) + jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, }), -})) -jest.mock('@/context/app-context', () => ({ - useAppContext: () => ({ - userProfile: { - timezone: 'UTC', - }, - }), + Trans: ({ children }: { children: React.ReactNode }) => <>{children}, })) -// Router mock with trackable push function -const mockRouterPush = jest.fn() -jest.mock('next/navigation', () => ({ - useRouter: () => ({ - push: mockRouterPush, - }), -})) - -jest.mock('@/hooks/use-theme', () => ({ +jest.mock('next/link', () => ({ __esModule: true, - default: () => ({ theme: Theme.light }), -})) -jest.mock('@/hooks/use-timestamp', () => ({ - __esModule: true, - default: () => ({ - formatTime: (timestamp: number, _format: string) => new Date(timestamp).toISOString(), - }), -})) -jest.mock('@/hooks/use-breakpoints', () => ({ - __esModule: true, - default: () => 'pc', - MediaType: { mobile: 'mobile', pc: 'pc' }, + default: ({ children, href }: { children: React.ReactNode; href: string }) => {children}, })) -// Store mock with configurable appDetail -let mockAppDetail: App | null = null -jest.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: { appDetail: App | null }) => App | null) => { - return selector({ appDetail: mockAppDetail }) - }, -})) - -// Mock portal-based components (they need DOM portal which is complex in tests) -let mockPortalOpen = false -jest.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children, open }: { children: React.ReactNode; open: boolean }) => { - mockPortalOpen = open - return
{children}
- }, - PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => ( -
{children}
- ), - PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => ( - mockPortalOpen ?
{children}
: null - ), -})) - -// Mock Drawer for List component (uses headlessui Dialog) -jest.mock('@/app/components/base/drawer', () => ({ - __esModule: true, - default: ({ isOpen, onClose, children }: { isOpen: boolean; onClose: () => void; children: React.ReactNode }) => ( - isOpen ? ( -
- - {children} -
- ) : null - ), -})) - -// Mock only the complex workflow Run component - DetailPanel itself is tested with real code +// Mock the Run component to avoid complex dependencies jest.mock('@/app/components/workflow/run', () => ({ __esModule: true, default: ({ runDetailUrl, tracingListUrl }: { runDetailUrl: string; tracingListUrl: string }) => ( @@ -110,105 +72,58 @@ jest.mock('@/app/components/workflow/run', () => ({ ), })) -// Mock WorkflowContextProvider - provides context for Run component -jest.mock('@/app/components/workflow/context', () => ({ - WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), -})) - -// Mock TooltipPlus - simple UI component -jest.mock('@/app/components/base/tooltip', () => ({ - __esModule: true, - default: ({ children, popupContent }: { children: React.ReactNode; popupContent: string }) => ( -
{children}
- ), -})) - -// Mock base components that are difficult to render -jest.mock('@/app/components/app/log/empty-element', () => ({ - __esModule: true, - default: ({ appDetail }: { appDetail: App }) => ( -
No logs for {appDetail.name}
- ), -})) - -jest.mock('@/app/components/base/pagination', () => ({ - __esModule: true, - default: ({ - current, - onChange, - total, - limit, - onLimitChange, - }: { - current: number - onChange: (page: number) => void - total: number - limit: number - onLimitChange: (limit: number) => void - }) => ( -
- {current} - {total} - {limit} - - - -
- ), -})) - -jest.mock('@/app/components/base/loading', () => ({ - __esModule: true, - default: ({ type }: { type?: string }) => ( -
Loading...
- ), -})) - -// Mock amplitude tracking - with trackable function const mockTrackEvent = jest.fn() jest.mock('@/app/components/base/amplitude/utils', () => ({ trackEvent: (...args: unknown[]) => mockTrackEvent(...args), })) -// Mock workflow icons -jest.mock('@/app/components/base/icons/src/vender/workflow', () => ({ - Code: () => Code, - KnowledgeRetrieval: () => Knowledge, - Schedule: () => Schedule, - WebhookLine: () => Webhook, - WindowCursor: () => Window, +jest.mock('@/service/log', () => ({ + fetchWorkflowLogs: jest.fn(), })) +jest.mock('@/hooks/use-theme', () => ({ + __esModule: true, + default: () => { + const { Theme } = require('@/types/app') + return { theme: Theme.light } + }, +})) + +jest.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + userProfile: { timezone: 'UTC' }, + }), +})) + +// Mock useTimestamp +jest.mock('@/hooks/use-timestamp', () => ({ + __esModule: true, + default: () => ({ + formatTime: (timestamp: number, _format: string) => `formatted-${timestamp}`, + }), +})) + +// Mock useBreakpoints +jest.mock('@/hooks/use-breakpoints', () => ({ + __esModule: true, + default: () => 'pc', + MediaType: { + mobile: 'mobile', + pc: 'pc', + }, +})) + +// Mock BlockIcon jest.mock('@/app/components/workflow/block-icon', () => ({ __esModule: true, - default: ({ type, toolIcon }: { type: string; size?: string; toolIcon?: string }) => ( - BlockIcon - ), + default: () =>
BlockIcon
, })) -// Mock workflow types - must include all exports used by config/index.ts -jest.mock('@/app/components/workflow/types', () => ({ - BlockEnum: { - TriggerPlugin: 'trigger-plugin', - }, - InputVarType: { - textInput: 'text-input', - paragraph: 'paragraph', - select: 'select', - number: 'number', - checkbox: 'checkbox', - url: 'url', - files: 'files', - json: 'json', - jsonObject: 'json_object', - contexts: 'contexts', - iterator: 'iterator', - singleFile: 'file', - multiFiles: 'file-list', - loop: 'loop', - }, +// Mock WorkflowContextProvider +jest.mock('@/app/components/workflow/context', () => ({ + WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), })) const mockedUseSWR = useSWR as jest.MockedFunction @@ -237,7 +152,10 @@ const createMockApp = (overrides: Partial = {}): App => ({ app_model_config: {} as App['app_model_config'], created_at: Date.now(), updated_at: Date.now(), - site: {} as App['site'], + site: { + access_token: 'token', + app_base_url: 'https://example.com', + } as App['site'], api_base_url: 'https://api.example.com', tags: [], access_mode: 'public_access' as App['access_mode'], @@ -274,7 +192,7 @@ const createMockWorkflowLog = (overrides: Partial = {}): W const createMockLogsResponse = ( data: WorkflowAppLogDetail[] = [], - total = 0, + total = data.length, ): WorkflowLogsResponse => ({ data, has_more: data.length < total, @@ -284,919 +202,23 @@ const createMockLogsResponse = ( }) // ============================================================================ -// Integration Tests for Logs (Main Component) +// Tests // ============================================================================ -describe('Workflow Log Module Integration Tests', () => { +describe('Logs Container', () => { const defaultProps: ILogsProps = { appDetail: createMockApp(), } beforeEach(() => { jest.clearAllMocks() - mockPortalOpen = false - mockAppDetail = createMockApp() - mockRouterPush.mockClear() - mockTrackEvent.mockClear() }) - // Tests for Logs container component - orchestrates Filter, List, Pagination, and Loading states - describe('Logs Container', () => { - describe('Rendering', () => { - it('should render title, subtitle, and filter component', () => { - // Arrange - mockedUseSWR.mockReturnValue({ - data: createMockLogsResponse([], 0), - mutate: jest.fn(), - isValidating: false, - isLoading: false, - error: undefined, - }) - - // Act - render() - - // Assert - expect(screen.getByText('appLog.workflowTitle')).toBeInTheDocument() - expect(screen.getByText('appLog.workflowSubtitle')).toBeInTheDocument() - // Filter should render (has Chip components for status/period and Input for keyword) - expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument() - }) - }) - - describe('Loading State', () => { - it('should show loading spinner when data is undefined', () => { - // Arrange - mockedUseSWR.mockReturnValue({ - data: undefined, - mutate: jest.fn(), - isValidating: true, - isLoading: true, - error: undefined, - }) - - // Act - render() - - // Assert - expect(screen.getByTestId('loading')).toBeInTheDocument() - expect(screen.queryByTestId('empty-element')).not.toBeInTheDocument() - }) - }) - - describe('Empty State', () => { - it('should show empty element when total is 0', () => { - // Arrange - mockedUseSWR.mockReturnValue({ - data: createMockLogsResponse([], 0), - mutate: jest.fn(), - isValidating: false, - isLoading: false, - error: undefined, - }) - - // Act - render() - - // Assert - expect(screen.getByTestId('empty-element')).toBeInTheDocument() - expect(screen.getByText(`No logs for ${defaultProps.appDetail.name}`)).toBeInTheDocument() - expect(screen.queryByTestId('pagination')).not.toBeInTheDocument() - }) - }) - - describe('List State with Data', () => { - it('should render log table when data exists', () => { - // Arrange - const mockLogs = [ - createMockWorkflowLog({ id: 'log-1' }), - createMockWorkflowLog({ id: 'log-2' }), - ] - mockedUseSWR.mockReturnValue({ - data: createMockLogsResponse(mockLogs, 2), - mutate: jest.fn(), - isValidating: false, - isLoading: false, - error: undefined, - }) - - // Act - render() - - // Assert - expect(screen.getByRole('table')).toBeInTheDocument() - // Check table headers - expect(screen.getByText('appLog.table.header.startTime')).toBeInTheDocument() - expect(screen.getByText('appLog.table.header.status')).toBeInTheDocument() - expect(screen.getByText('appLog.table.header.runtime')).toBeInTheDocument() - expect(screen.getByText('appLog.table.header.tokens')).toBeInTheDocument() - }) - - it('should show pagination when total exceeds APP_PAGE_LIMIT', () => { - // Arrange - const mockLogs = Array.from({ length: APP_PAGE_LIMIT }, (_, i) => - createMockWorkflowLog({ id: `log-${i}` }), - ) - mockedUseSWR.mockReturnValue({ - data: createMockLogsResponse(mockLogs, APP_PAGE_LIMIT + 10), - mutate: jest.fn(), - isValidating: false, - isLoading: false, - error: undefined, - }) - - // Act - render() - - // Assert - expect(screen.getByTestId('pagination')).toBeInTheDocument() - expect(screen.getByTestId('total-items')).toHaveTextContent(String(APP_PAGE_LIMIT + 10)) - }) - - it('should not show pagination when total is within limit', () => { - // Arrange - const mockLogs = [createMockWorkflowLog()] - mockedUseSWR.mockReturnValue({ - data: createMockLogsResponse(mockLogs, 1), - mutate: jest.fn(), - isValidating: false, - isLoading: false, - error: undefined, - }) - - // Act - render() - - // Assert - expect(screen.queryByTestId('pagination')).not.toBeInTheDocument() - }) - }) - - describe('API Query Parameters', () => { - it('should call useSWR with correct URL containing app ID', () => { - // Arrange - const customApp = createMockApp({ id: 'custom-app-123' }) - mockedUseSWR.mockReturnValue({ - data: createMockLogsResponse([], 0), - mutate: jest.fn(), - isValidating: false, - isLoading: false, - error: undefined, - }) - - // Act - render() - - // Assert - expect(mockedUseSWR).toHaveBeenCalledWith( - expect.objectContaining({ - url: '/apps/custom-app-123/workflow-app-logs', - }), - expect.any(Function), - ) - }) - - it('should include pagination parameters in query', () => { - // Arrange - mockedUseSWR.mockReturnValue({ - data: createMockLogsResponse([], 0), - mutate: jest.fn(), - isValidating: false, - isLoading: false, - error: undefined, - }) - - // Act - render() - - // Assert - expect(mockedUseSWR).toHaveBeenCalledWith( - expect.objectContaining({ - params: expect.objectContaining({ - page: 1, - detail: true, - limit: APP_PAGE_LIMIT, - }), - }), - expect.any(Function), - ) - }) - - it('should include date range when period is not all time', () => { - // Arrange - mockedUseSWR.mockReturnValue({ - data: createMockLogsResponse([], 0), - mutate: jest.fn(), - isValidating: false, - isLoading: false, - error: undefined, - }) - - // Act - render() - - // Assert - default period is '2' (last 7 days), should have date filters - const lastCall = mockedUseSWR.mock.calls[mockedUseSWR.mock.calls.length - 1] - const keyArg = lastCall?.[0] as { params?: Record } | undefined - expect(keyArg?.params).toHaveProperty('created_at__after') - expect(keyArg?.params).toHaveProperty('created_at__before') - }) - }) - - describe('Pagination Interactions', () => { - it('should update page when pagination changes', async () => { - // Arrange - const user = userEvent.setup() - const mockLogs = Array.from({ length: APP_PAGE_LIMIT }, (_, i) => - createMockWorkflowLog({ id: `log-${i}` }), - ) - mockedUseSWR.mockReturnValue({ - data: createMockLogsResponse(mockLogs, APP_PAGE_LIMIT + 10), - mutate: jest.fn(), - isValidating: false, - isLoading: false, - error: undefined, - }) - - // Act - render() - await user.click(screen.getByTestId('next-page-btn')) - - // Assert - await waitFor(() => { - expect(screen.getByTestId('current-page')).toHaveTextContent('1') - }) - }) - }) - - describe('State Transitions', () => { - it('should transition from loading to list state', async () => { - // Arrange - start with loading - mockedUseSWR.mockReturnValue({ - data: undefined, - mutate: jest.fn(), - isValidating: true, - isLoading: true, - error: undefined, - }) - - // Act - const { rerender } = render() - expect(screen.getByTestId('loading')).toBeInTheDocument() - - // Update to loaded state - const mockLogs = [createMockWorkflowLog()] - mockedUseSWR.mockReturnValue({ - data: createMockLogsResponse(mockLogs, 1), - mutate: jest.fn(), - isValidating: false, - isLoading: false, - error: undefined, - }) - rerender() - - // Assert - await waitFor(() => { - expect(screen.queryByTestId('loading')).not.toBeInTheDocument() - expect(screen.getByRole('table')).toBeInTheDocument() - }) - }) - }) - }) - - // ============================================================================ - // Tests for Filter Component - // ============================================================================ - - describe('Filter Component', () => { - const mockSetQueryParams = jest.fn() - const defaultFilterProps = { - queryParams: { status: 'all', period: '2' } as QueryParam, - setQueryParams: mockSetQueryParams, - } - - beforeEach(() => { - mockSetQueryParams.mockClear() - mockTrackEvent.mockClear() - }) - - describe('Rendering', () => { - it('should render status filter chip with correct value', () => { - // Arrange & Act - render() - - // Assert - should show "All" as default status - expect(screen.getByText('All')).toBeInTheDocument() - }) - - it('should render time period filter chip', () => { - // Arrange & Act - render() - - // Assert - should have calendar icon (period filter) - const calendarIcons = document.querySelectorAll('svg') - expect(calendarIcons.length).toBeGreaterThan(0) - }) - - it('should render keyword search input', () => { - // Arrange & Act - render() - - // Assert - const searchInput = screen.getByPlaceholderText('common.operation.search') - expect(searchInput).toBeInTheDocument() - }) - - it('should display different status values', () => { - // Arrange - const successStatusProps = { - queryParams: { status: 'succeeded', period: '2' } as QueryParam, - setQueryParams: mockSetQueryParams, - } - - // Act - render() - - // Assert - expect(screen.getByText('Success')).toBeInTheDocument() - }) - }) - - describe('Keyword Search', () => { - it('should call setQueryParams when keyword changes', async () => { - // Arrange - const user = userEvent.setup() - render() - - // Act - const searchInput = screen.getByPlaceholderText('common.operation.search') - await user.type(searchInput, 'test') - - // Assert - expect(mockSetQueryParams).toHaveBeenCalledWith( - expect.objectContaining({ keyword: expect.any(String) }), - ) - }) - - it('should render input with initial keyword value', () => { - // Arrange - const propsWithKeyword = { - queryParams: { status: 'all', period: '2', keyword: 'test' } as QueryParam, - setQueryParams: mockSetQueryParams, - } - - // Act - render() - - // Assert - const searchInput = screen.getByPlaceholderText('common.operation.search') - expect(searchInput).toHaveValue('test') - }) - }) - - describe('TIME_PERIOD_MAPPING Export', () => { - it('should export TIME_PERIOD_MAPPING with correct structure', () => { - // Assert - expect(TIME_PERIOD_MAPPING).toBeDefined() - expect(TIME_PERIOD_MAPPING['1']).toEqual({ value: 0, name: 'today' }) - expect(TIME_PERIOD_MAPPING['9']).toEqual({ value: -1, name: 'allTime' }) - }) - - it('should have all required time period options', () => { - // Assert - verify all periods are defined - expect(Object.keys(TIME_PERIOD_MAPPING)).toHaveLength(9) - expect(TIME_PERIOD_MAPPING['2']).toHaveProperty('name', 'last7days') - expect(TIME_PERIOD_MAPPING['3']).toHaveProperty('name', 'last4weeks') - expect(TIME_PERIOD_MAPPING['4']).toHaveProperty('name', 'last3months') - expect(TIME_PERIOD_MAPPING['5']).toHaveProperty('name', 'last12months') - expect(TIME_PERIOD_MAPPING['6']).toHaveProperty('name', 'monthToDate') - expect(TIME_PERIOD_MAPPING['7']).toHaveProperty('name', 'quarterToDate') - expect(TIME_PERIOD_MAPPING['8']).toHaveProperty('name', 'yearToDate') - }) - - it('should have correct value for allTime period', () => { - // Assert - allTime should have -1 value (special case) - expect(TIME_PERIOD_MAPPING['9'].value).toBe(-1) - }) - }) - }) - - // ============================================================================ - // Tests for WorkflowAppLogList Component - // ============================================================================ - - describe('WorkflowAppLogList Component', () => { - const mockOnRefresh = jest.fn() - - beforeEach(() => { - mockOnRefresh.mockClear() - }) - - it('should render loading when logs or appDetail is undefined', () => { - // Arrange & Act - render() - - // Assert - expect(screen.getByTestId('loading')).toBeInTheDocument() - }) - - it('should render table with correct headers for workflow app', () => { - // Arrange - const mockLogs = createMockLogsResponse([createMockWorkflowLog()], 1) - const workflowApp = createMockApp({ mode: 'workflow' as AppModeEnum }) - - // Act - render() - - // Assert - expect(screen.getByText('appLog.table.header.startTime')).toBeInTheDocument() - expect(screen.getByText('appLog.table.header.status')).toBeInTheDocument() - expect(screen.getByText('appLog.table.header.runtime')).toBeInTheDocument() - expect(screen.getByText('appLog.table.header.tokens')).toBeInTheDocument() - expect(screen.getByText('appLog.table.header.user')).toBeInTheDocument() - expect(screen.getByText('appLog.table.header.triggered_from')).toBeInTheDocument() - }) - - it('should not show triggered_from column for non-workflow apps', () => { - // Arrange - const mockLogs = createMockLogsResponse([createMockWorkflowLog()], 1) - const chatApp = createMockApp({ mode: 'advanced-chat' as AppModeEnum }) - - // Act - render() - - // Assert - expect(screen.queryByText('appLog.table.header.triggered_from')).not.toBeInTheDocument() - }) - - it('should render log rows with correct data', () => { - // Arrange - const mockLog = createMockWorkflowLog({ - id: 'test-log-1', - workflow_run: createMockWorkflowRun({ - status: 'succeeded', - elapsed_time: 1.5, - total_tokens: 150, - }), - created_by_account: { id: '1', name: 'John Doe', email: 'john@example.com' }, - }) - const mockLogs = createMockLogsResponse([mockLog], 1) - - // Act - render() - - // Assert - expect(screen.getByText('Success')).toBeInTheDocument() - expect(screen.getByText('1.500s')).toBeInTheDocument() - expect(screen.getByText('150')).toBeInTheDocument() - expect(screen.getByText('John Doe')).toBeInTheDocument() - }) - - describe('Status Display', () => { - it.each([ - ['succeeded', 'Success'], - ['failed', 'Failure'], - ['stopped', 'Stop'], - ['running', 'Running'], - ['partial-succeeded', 'Partial Success'], - ])('should display correct status for %s', (status, expectedText) => { - // Arrange - const mockLog = createMockWorkflowLog({ - workflow_run: createMockWorkflowRun({ status: status as WorkflowRunDetail['status'] }), - }) - const mockLogs = createMockLogsResponse([mockLog], 1) - - // Act - render() - - // Assert - expect(screen.getByText(expectedText)).toBeInTheDocument() - }) - }) - - describe('Sorting', () => { - it('should toggle sort order when clicking sort header', async () => { - // Arrange - const user = userEvent.setup() - const logs = [ - createMockWorkflowLog({ id: 'log-1', created_at: 1000 }), - createMockWorkflowLog({ id: 'log-2', created_at: 2000 }), - ] - const mockLogs = createMockLogsResponse(logs, 2) - - // Act - render() - - // Find and click the sort header - const sortHeader = screen.getByText('appLog.table.header.startTime') - await user.click(sortHeader) - - // Assert - sort icon should change (we can verify the click handler was called) - // The component should handle sorting internally - expect(sortHeader).toBeInTheDocument() - }) - }) - - describe('Row Click and Drawer', () => { - beforeEach(() => { - // Set app detail for DetailPanel's useStore - mockAppDetail = createMockApp({ id: 'test-app-id' }) - }) - - it('should open drawer with detail panel when clicking a log row', async () => { - // Arrange - const user = userEvent.setup() - const mockLog = createMockWorkflowLog({ - id: 'test-log-1', - workflow_run: createMockWorkflowRun({ id: 'run-123', triggered_from: WorkflowRunTriggeredFrom.APP_RUN }), - }) - const mockLogs = createMockLogsResponse([mockLog], 1) - - // Act - render() - - // Click on a table row - const rows = screen.getAllByRole('row') - // First row is header, second is data row - await user.click(rows[1]) - - // Assert - drawer opens and DetailPanel renders with real component - await waitFor(() => { - expect(screen.getByTestId('drawer')).toBeInTheDocument() - // Real DetailPanel renders workflow title - expect(screen.getByText('appLog.runDetail.workflowTitle')).toBeInTheDocument() - // Real DetailPanel renders Run component with correct URL - expect(screen.getByTestId('run-detail-url')).toHaveTextContent('run-123') - }) - }) - - it('should show replay button for APP_RUN triggered logs', async () => { - // Arrange - const user = userEvent.setup() - const mockLog = createMockWorkflowLog({ - workflow_run: createMockWorkflowRun({ id: 'run-abc', triggered_from: WorkflowRunTriggeredFrom.APP_RUN }), - }) - const mockLogs = createMockLogsResponse([mockLog], 1) - - // Act - render() - const rows = screen.getAllByRole('row') - await user.click(rows[1]) - - // Assert - replay button should be visible for APP_RUN - await waitFor(() => { - expect(screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' })).toBeInTheDocument() - }) - }) - - it('should not show replay button for WEBHOOK triggered logs', async () => { - // Arrange - const user = userEvent.setup() - const mockLog = createMockWorkflowLog({ - workflow_run: createMockWorkflowRun({ id: 'run-xyz', triggered_from: WorkflowRunTriggeredFrom.WEBHOOK }), - }) - const mockLogs = createMockLogsResponse([mockLog], 1) - - // Act - render() - const rows = screen.getAllByRole('row') - await user.click(rows[1]) - - // Assert - replay button should NOT be visible for WEBHOOK - await waitFor(() => { - expect(screen.getByTestId('drawer')).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'appLog.runDetail.testWithParams' })).not.toBeInTheDocument() - }) - }) - - it('should close drawer and call refresh when drawer closes', async () => { - // Arrange - const user = userEvent.setup() - const mockLog = createMockWorkflowLog() - const mockLogs = createMockLogsResponse([mockLog], 1) - - // Act - render() - - // Open drawer - const rows = screen.getAllByRole('row') - await user.click(rows[1]) - - // Wait for drawer to open - await waitFor(() => { - expect(screen.getByTestId('drawer')).toBeInTheDocument() - }) - - // Close drawer - await user.click(screen.getByTestId('drawer-close')) - - // Assert - await waitFor(() => { - expect(screen.queryByTestId('drawer')).not.toBeInTheDocument() - expect(mockOnRefresh).toHaveBeenCalled() - }) - }) - }) - - describe('User Display', () => { - it('should display end user session ID when available', () => { - // Arrange - const mockLog = createMockWorkflowLog({ - created_by_end_user: { id: 'end-user-1', session_id: 'session-abc', type: 'browser', is_anonymous: false }, - created_by_account: undefined, - }) - const mockLogs = createMockLogsResponse([mockLog], 1) - - // Act - render() - - // Assert - expect(screen.getByText('session-abc')).toBeInTheDocument() - }) - - it('should display N/A when no user info available', () => { - // Arrange - const mockLog = createMockWorkflowLog({ - created_by_end_user: undefined, - created_by_account: undefined, - }) - const mockLogs = createMockLogsResponse([mockLog], 1) - - // Act - render() - - // Assert - expect(screen.getByText('N/A')).toBeInTheDocument() - }) - }) - - describe('Unread Indicator', () => { - it('should show unread indicator when read_at is not set', () => { - // Arrange - const mockLog = createMockWorkflowLog({ read_at: undefined }) - const mockLogs = createMockLogsResponse([mockLog], 1) - - // Act - const { container } = render( - , - ) - - // Assert - look for the unread indicator dot - const unreadDot = container.querySelector('.bg-util-colors-blue-blue-500') - expect(unreadDot).toBeInTheDocument() - }) - }) - }) - - // ============================================================================ - // Tests for TriggerByDisplay Component - // ============================================================================ - - describe('TriggerByDisplay Component', () => { - it.each([ - [WorkflowRunTriggeredFrom.DEBUGGING, 'appLog.triggerBy.debugging', 'icon-code'], - [WorkflowRunTriggeredFrom.APP_RUN, 'appLog.triggerBy.appRun', 'icon-window'], - [WorkflowRunTriggeredFrom.WEBHOOK, 'appLog.triggerBy.webhook', 'icon-webhook'], - [WorkflowRunTriggeredFrom.SCHEDULE, 'appLog.triggerBy.schedule', 'icon-schedule'], - [WorkflowRunTriggeredFrom.RAG_PIPELINE_RUN, 'appLog.triggerBy.ragPipelineRun', 'icon-knowledge'], - [WorkflowRunTriggeredFrom.RAG_PIPELINE_DEBUGGING, 'appLog.triggerBy.ragPipelineDebugging', 'icon-knowledge'], - ])('should render correct display for %s trigger', (triggeredFrom, expectedText, expectedIcon) => { - // Act - render() - - // Assert - expect(screen.getByText(expectedText)).toBeInTheDocument() - expect(screen.getByTestId(expectedIcon)).toBeInTheDocument() - }) - - it('should render plugin trigger with custom event name from metadata', () => { - // Arrange - const metadata: TriggerMetadata = { - event_name: 'Custom Plugin Event', - icon: 'plugin-icon.png', - } - - // Act - render( - , - ) - - // Assert - expect(screen.getByText('Custom Plugin Event')).toBeInTheDocument() - }) - - it('should not show text when showText is false', () => { - // Act - render( - , - ) - - // Assert - expect(screen.queryByText('appLog.triggerBy.appRun')).not.toBeInTheDocument() - expect(screen.getByTestId('icon-window')).toBeInTheDocument() - }) - - it('should apply custom className', () => { - // Act - const { container } = render( - , - ) - - // Assert - const wrapper = container.firstChild as HTMLElement - expect(wrapper).toHaveClass('custom-class') - }) - - it('should render plugin with BlockIcon when metadata has icon', () => { - // Arrange - const metadata: TriggerMetadata = { - icon: 'custom-plugin-icon.png', - } - - // Act - render( - , - ) - - // Assert - const blockIcon = screen.getByTestId('block-icon') - expect(blockIcon).toHaveAttribute('data-tool-icon', 'custom-plugin-icon.png') - }) - - it('should fall back to default BlockIcon for plugin without metadata', () => { - // Act - render() - - // Assert - expect(screen.getByTestId('block-icon')).toBeInTheDocument() - }) - }) - - // ============================================================================ - // Tests for DetailPanel Component (Real Component Testing) - // ============================================================================ - - describe('DetailPanel Component', () => { - const mockOnClose = jest.fn() - - beforeEach(() => { - mockOnClose.mockClear() - mockRouterPush.mockClear() - // Set default app detail for store - mockAppDetail = createMockApp({ id: 'test-app-123', name: 'Test App' }) - }) - - describe('Rendering', () => { - it('should render title correctly', () => { - // Act - render() - - // Assert - expect(screen.getByText('appLog.runDetail.workflowTitle')).toBeInTheDocument() - }) - - it('should render close button', () => { - // Act - render() - - // Assert - close icon should be present - const closeIcon = document.querySelector('.cursor-pointer') - expect(closeIcon).toBeInTheDocument() - }) - - it('should render WorkflowContextProvider with Run component', () => { - // Act - render() - - // Assert - expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument() - expect(screen.getByTestId('workflow-run')).toBeInTheDocument() - }) - - it('should pass correct URLs to Run component', () => { - // Arrange - mockAppDetail = createMockApp({ id: 'app-456' }) - - // Act - render() - - // Assert - expect(screen.getByTestId('run-detail-url')).toHaveTextContent('/apps/app-456/workflow-runs/run-789') - expect(screen.getByTestId('tracing-list-url')).toHaveTextContent('/apps/app-456/workflow-runs/run-789/node-executions') - }) - - it('should pass empty URLs when runID is empty', () => { - // Act - render() - - // Assert - expect(screen.getByTestId('run-detail-url')).toHaveTextContent('') - expect(screen.getByTestId('tracing-list-url')).toHaveTextContent('') - }) - }) - - describe('Close Button Interaction', () => { - it('should call onClose when close icon is clicked', async () => { - // Arrange - const user = userEvent.setup() - render() - - // Act - click on the close icon - const closeIcon = document.querySelector('.cursor-pointer') as HTMLElement - await user.click(closeIcon) - - // Assert - expect(mockOnClose).toHaveBeenCalledTimes(1) - }) - }) - - describe('Replay Button (canReplay=true)', () => { - it('should render replay button when canReplay is true', () => { - // Act - render() - - // Assert - const replayButton = screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' }) - expect(replayButton).toBeInTheDocument() - }) - - it('should show tooltip with correct text', () => { - // Act - render() - - // Assert - const tooltip = screen.getByTestId('tooltip') - expect(tooltip).toHaveAttribute('title', 'appLog.runDetail.testWithParams') - }) - - it('should navigate to workflow page with replayRunId when replay is clicked', async () => { - // Arrange - const user = userEvent.setup() - mockAppDetail = createMockApp({ id: 'app-for-replay' }) - render() - - // Act - const replayButton = screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' }) - await user.click(replayButton) - - // Assert - expect(mockRouterPush).toHaveBeenCalledWith('/app/app-for-replay/workflow?replayRunId=run-to-replay') - }) - - it('should not navigate when appDetail.id is undefined', async () => { - // Arrange - const user = userEvent.setup() - mockAppDetail = null - render() - - // Act - const replayButton = screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' }) - await user.click(replayButton) - - // Assert - expect(mockRouterPush).not.toHaveBeenCalled() - }) - }) - - describe('Replay Button (canReplay=false)', () => { - it('should not render replay button when canReplay is false', () => { - // Act - render() - - // Assert - expect(screen.queryByRole('button', { name: 'appLog.runDetail.testWithParams' })).not.toBeInTheDocument() - }) - - it('should not render replay button when canReplay is not provided (defaults to false)', () => { - // Act - render() - - // Assert - expect(screen.queryByRole('button', { name: 'appLog.runDetail.testWithParams' })).not.toBeInTheDocument() - }) - }) - }) - - // ============================================================================ - // Edge Cases and Error Handling - // ============================================================================ - - describe('Edge Cases', () => { - it('should handle app with minimal required fields', () => { - // Arrange - const minimalApp = createMockApp({ id: 'minimal-id', name: 'Minimal App' }) + // -------------------------------------------------------------------------- + // Rendering Tests (REQUIRED) + // -------------------------------------------------------------------------- + describe('Rendering', () => { + it('should render without crashing', () => { mockedUseSWR.mockReturnValue({ data: createMockLogsResponse([], 0), mutate: jest.fn(), @@ -1205,63 +227,373 @@ describe('Workflow Log Module Integration Tests', () => { error: undefined, }) - // Act & Assert - expect(() => render()).not.toThrow() - }) - - it('should handle logs with zero elapsed time', () => { - // Arrange - const mockLog = createMockWorkflowLog({ - workflow_run: createMockWorkflowRun({ elapsed_time: 0 }), - }) - const mockLogs = createMockLogsResponse([mockLog], 1) - - // Act - render() - - // Assert - expect(screen.getByText('0.000s')).toBeInTheDocument() - }) - - it('should handle large number of logs', () => { - // Arrange - const largeLogs = Array.from({ length: 100 }, (_, i) => - createMockWorkflowLog({ id: `log-${i}`, created_at: Date.now() - i * 1000 }), - ) - mockedUseSWR.mockReturnValue({ - data: createMockLogsResponse(largeLogs, 1000), - mutate: jest.fn(), - isValidating: false, - isLoading: false, - error: undefined, - }) - - // Act render() - // Assert - expect(screen.getByRole('table')).toBeInTheDocument() - expect(screen.getByTestId('pagination')).toBeInTheDocument() - expect(screen.getByTestId('total-items')).toHaveTextContent('1000') + expect(screen.getByText('appLog.workflowTitle')).toBeInTheDocument() }) - it('should handle advanced-chat mode correctly', () => { - // Arrange - const advancedChatApp = createMockApp({ mode: 'advanced-chat' as AppModeEnum }) - const mockLogs = createMockLogsResponse([createMockWorkflowLog()], 1) + it('should render title and subtitle', () => { mockedUseSWR.mockReturnValue({ - data: mockLogs, + data: createMockLogsResponse([], 0), mutate: jest.fn(), isValidating: false, isLoading: false, error: undefined, }) - // Act - render() + render() - // Assert - should not show triggered_from column + expect(screen.getByText('appLog.workflowTitle')).toBeInTheDocument() + expect(screen.getByText('appLog.workflowSubtitle')).toBeInTheDocument() + }) + + it('should render Filter component', () => { + mockedUseSWR.mockReturnValue({ + data: createMockLogsResponse([], 0), + mutate: jest.fn(), + isValidating: false, + isLoading: false, + error: undefined, + }) + + render() + + expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Loading State Tests + // -------------------------------------------------------------------------- + describe('Loading State', () => { + it('should show loading spinner when data is undefined', () => { + mockedUseSWR.mockReturnValue({ + data: undefined, + mutate: jest.fn(), + isValidating: true, + isLoading: true, + error: undefined, + }) + + const { container } = render() + + expect(container.querySelector('.spin-animation')).toBeInTheDocument() + }) + + it('should not show loading spinner when data is available', () => { + mockedUseSWR.mockReturnValue({ + data: createMockLogsResponse([createMockWorkflowLog()], 1), + mutate: jest.fn(), + isValidating: false, + isLoading: false, + error: undefined, + }) + + const { container } = render() + + expect(container.querySelector('.spin-animation')).not.toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Empty State Tests + // -------------------------------------------------------------------------- + describe('Empty State', () => { + it('should render empty element when total is 0', () => { + mockedUseSWR.mockReturnValue({ + data: createMockLogsResponse([], 0), + mutate: jest.fn(), + isValidating: false, + isLoading: false, + error: undefined, + }) + + render() + + expect(screen.getByText('appLog.table.empty.element.title')).toBeInTheDocument() + expect(screen.queryByRole('table')).not.toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Data Fetching Tests + // -------------------------------------------------------------------------- + describe('Data Fetching', () => { + it('should call useSWR with correct URL and default params', () => { + mockedUseSWR.mockReturnValue({ + data: createMockLogsResponse([], 0), + mutate: jest.fn(), + isValidating: false, + isLoading: false, + error: undefined, + }) + + render() + + const keyArg = mockedUseSWR.mock.calls.at(-1)?.[0] as { url: string; params: Record } + expect(keyArg).toMatchObject({ + url: `/apps/${defaultProps.appDetail.id}/workflow-app-logs`, + params: expect.objectContaining({ + page: 1, + detail: true, + limit: APP_PAGE_LIMIT, + }), + }) + }) + + it('should include date filters for non-allTime periods', () => { + mockedUseSWR.mockReturnValue({ + data: createMockLogsResponse([], 0), + mutate: jest.fn(), + isValidating: false, + isLoading: false, + error: undefined, + }) + + render() + + const keyArg = mockedUseSWR.mock.calls.at(-1)?.[0] as { params?: Record } + expect(keyArg?.params).toHaveProperty('created_at__after') + expect(keyArg?.params).toHaveProperty('created_at__before') + }) + + it('should not include status param when status is all', () => { + mockedUseSWR.mockReturnValue({ + data: createMockLogsResponse([], 0), + mutate: jest.fn(), + isValidating: false, + isLoading: false, + error: undefined, + }) + + render() + + const keyArg = mockedUseSWR.mock.calls.at(-1)?.[0] as { params?: Record } + expect(keyArg?.params).not.toHaveProperty('status') + }) + }) + + // -------------------------------------------------------------------------- + // Filter Integration Tests + // -------------------------------------------------------------------------- + describe('Filter Integration', () => { + it('should update query when selecting status filter', async () => { + const user = userEvent.setup() + mockedUseSWR.mockReturnValue({ + data: createMockLogsResponse([], 0), + mutate: jest.fn(), + isValidating: false, + isLoading: false, + error: undefined, + }) + + render() + + // Click status filter + await user.click(screen.getByText('All')) + await user.click(await screen.findByText('Success')) + + // Check that useSWR was called with updated params + await waitFor(() => { + const lastCall = mockedUseSWR.mock.calls.at(-1)?.[0] as { params?: Record } + expect(lastCall?.params).toMatchObject({ + status: 'succeeded', + }) + }) + }) + + it('should update query when selecting period filter', async () => { + const user = userEvent.setup() + mockedUseSWR.mockReturnValue({ + data: createMockLogsResponse([], 0), + mutate: jest.fn(), + isValidating: false, + isLoading: false, + error: undefined, + }) + + render() + + // Click period filter + await user.click(screen.getByText('appLog.filter.period.last7days')) + await user.click(await screen.findByText('appLog.filter.period.allTime')) + + // When period is allTime (9), date filters should be removed + await waitFor(() => { + const lastCall = mockedUseSWR.mock.calls.at(-1)?.[0] as { params?: Record } + expect(lastCall?.params).not.toHaveProperty('created_at__after') + expect(lastCall?.params).not.toHaveProperty('created_at__before') + }) + }) + + it('should update query when typing keyword', async () => { + const user = userEvent.setup() + mockedUseSWR.mockReturnValue({ + data: createMockLogsResponse([], 0), + mutate: jest.fn(), + isValidating: false, + isLoading: false, + error: undefined, + }) + + render() + + const searchInput = screen.getByPlaceholderText('common.operation.search') + await user.type(searchInput, 'test-keyword') + + await waitFor(() => { + const lastCall = mockedUseSWR.mock.calls.at(-1)?.[0] as { params?: Record } + expect(lastCall?.params).toMatchObject({ + keyword: 'test-keyword', + }) + }) + }) + }) + + // -------------------------------------------------------------------------- + // Pagination Tests + // -------------------------------------------------------------------------- + describe('Pagination', () => { + it('should not render pagination when total is less than limit', () => { + mockedUseSWR.mockReturnValue({ + data: createMockLogsResponse([createMockWorkflowLog()], 1), + mutate: jest.fn(), + isValidating: false, + isLoading: false, + error: undefined, + }) + + render() + + // Pagination component should not be rendered + expect(screen.queryByRole('navigation')).not.toBeInTheDocument() + }) + + it('should render pagination when total exceeds limit', () => { + const logs = Array.from({ length: APP_PAGE_LIMIT }, (_, i) => + createMockWorkflowLog({ id: `log-${i}` }), + ) + + mockedUseSWR.mockReturnValue({ + data: createMockLogsResponse(logs, APP_PAGE_LIMIT + 10), + mutate: jest.fn(), + isValidating: false, + isLoading: false, + error: undefined, + }) + + render() + + // Should show pagination - checking for any pagination-related element + // The Pagination component renders page controls + expect(screen.getByRole('table')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // List Rendering Tests + // -------------------------------------------------------------------------- + describe('List Rendering', () => { + it('should render List component when data is available', () => { + mockedUseSWR.mockReturnValue({ + data: createMockLogsResponse([createMockWorkflowLog()], 1), + mutate: jest.fn(), + isValidating: false, + isLoading: false, + error: undefined, + }) + + render() + + expect(screen.getByRole('table')).toBeInTheDocument() + }) + + it('should display log data in table', () => { + mockedUseSWR.mockReturnValue({ + data: createMockLogsResponse([ + createMockWorkflowLog({ + workflow_run: createMockWorkflowRun({ + status: 'succeeded', + total_tokens: 500, + }), + }), + ], 1), + mutate: jest.fn(), + isValidating: false, + isLoading: false, + error: undefined, + }) + + render() + + expect(screen.getByText('Success')).toBeInTheDocument() + expect(screen.getByText('500')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // TIME_PERIOD_MAPPING Export Tests + // -------------------------------------------------------------------------- + describe('TIME_PERIOD_MAPPING', () => { + it('should export TIME_PERIOD_MAPPING with correct values', () => { + expect(TIME_PERIOD_MAPPING['1']).toEqual({ value: 0, name: 'today' }) + expect(TIME_PERIOD_MAPPING['2']).toEqual({ value: 7, name: 'last7days' }) + expect(TIME_PERIOD_MAPPING['9']).toEqual({ value: -1, name: 'allTime' }) + expect(Object.keys(TIME_PERIOD_MAPPING)).toHaveLength(9) + }) + }) + + // -------------------------------------------------------------------------- + // Edge Cases (REQUIRED) + // -------------------------------------------------------------------------- + describe('Edge Cases', () => { + it('should handle different app modes', () => { + mockedUseSWR.mockReturnValue({ + data: createMockLogsResponse([createMockWorkflowLog()], 1), + mutate: jest.fn(), + isValidating: false, + isLoading: false, + error: undefined, + }) + + const chatApp = createMockApp({ mode: 'advanced-chat' as AppModeEnum }) + + render() + + // Should render without trigger column expect(screen.queryByText('appLog.table.header.triggered_from')).not.toBeInTheDocument() }) + + it('should handle error state from useSWR', () => { + mockedUseSWR.mockReturnValue({ + data: undefined, + mutate: jest.fn(), + isValidating: false, + isLoading: false, + error: new Error('Failed to fetch'), + }) + + const { container } = render() + + // Should show loading state when data is undefined + expect(container.querySelector('.spin-animation')).toBeInTheDocument() + }) + + it('should handle app with different ID', () => { + mockedUseSWR.mockReturnValue({ + data: createMockLogsResponse([], 0), + mutate: jest.fn(), + isValidating: false, + isLoading: false, + error: undefined, + }) + + const customApp = createMockApp({ id: 'custom-app-123' }) + + render() + + const keyArg = mockedUseSWR.mock.calls.at(-1)?.[0] as { url: string } + expect(keyArg?.url).toBe('/apps/custom-app-123/workflow-app-logs') + }) }) }) diff --git a/web/app/components/app/workflow-log/list.spec.tsx b/web/app/components/app/workflow-log/list.spec.tsx new file mode 100644 index 0000000000..228b6ac465 --- /dev/null +++ b/web/app/components/app/workflow-log/list.spec.tsx @@ -0,0 +1,757 @@ +/** + * WorkflowAppLogList Component Tests + * + * Tests the workflow log list component which displays: + * - Table of workflow run logs with sortable columns + * - Status indicators (success, failed, stopped, running, partial-succeeded) + * - Trigger display for workflow apps + * - Drawer with run details + * - Loading states + */ + +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import WorkflowAppLogList from './list' +import { useStore as useAppStore } from '@/app/components/app/store' +import type { App, AppIconType, AppModeEnum } from '@/types/app' +import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log' +import { WorkflowRunTriggeredFrom } from '@/models/log' +import { APP_PAGE_LIMIT } from '@/config' + +// ============================================================================ +// Mocks +// ============================================================================ + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +const mockRouterPush = jest.fn() +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockRouterPush, + }), +})) + +// Mock useTimestamp hook +jest.mock('@/hooks/use-timestamp', () => ({ + __esModule: true, + default: () => ({ + formatTime: (timestamp: number, _format: string) => `formatted-${timestamp}`, + }), +})) + +// Mock useBreakpoints hook +jest.mock('@/hooks/use-breakpoints', () => ({ + __esModule: true, + default: () => 'pc', // Return desktop by default + MediaType: { + mobile: 'mobile', + pc: 'pc', + }, +})) + +// Mock the Run component +jest.mock('@/app/components/workflow/run', () => ({ + __esModule: true, + default: ({ runDetailUrl, tracingListUrl }: { runDetailUrl: string; tracingListUrl: string }) => ( +
+ {runDetailUrl} + {tracingListUrl} +
+ ), +})) + +// Mock WorkflowContextProvider +jest.mock('@/app/components/workflow/context', () => ({ + WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) + +// Mock BlockIcon +jest.mock('@/app/components/workflow/block-icon', () => ({ + __esModule: true, + default: () =>
BlockIcon
, +})) + +// Mock useTheme +jest.mock('@/hooks/use-theme', () => ({ + __esModule: true, + default: () => { + const { Theme } = require('@/types/app') + return { theme: Theme.light } + }, +})) + +// Mock ahooks +jest.mock('ahooks', () => ({ + useBoolean: (initial: boolean) => { + const setters = { + setTrue: jest.fn(), + setFalse: jest.fn(), + toggle: jest.fn(), + } + return [initial, setters] as const + }, +})) + +// ============================================================================ +// Test Data Factories +// ============================================================================ + +const createMockApp = (overrides: Partial = {}): App => ({ + id: 'test-app-id', + name: 'Test App', + description: 'Test app description', + author_name: 'Test Author', + icon_type: 'emoji' as AppIconType, + icon: '🚀', + icon_background: '#FFEAD5', + icon_url: null, + use_icon_as_answer_icon: false, + mode: 'workflow' as AppModeEnum, + enable_site: true, + enable_api: true, + api_rpm: 60, + api_rph: 3600, + is_demo: false, + model_config: {} as App['model_config'], + app_model_config: {} as App['app_model_config'], + created_at: Date.now(), + updated_at: Date.now(), + site: { + access_token: 'token', + app_base_url: 'https://example.com', + } as App['site'], + api_base_url: 'https://api.example.com', + tags: [], + access_mode: 'public_access' as App['access_mode'], + ...overrides, +}) + +const createMockWorkflowRun = (overrides: Partial = {}): WorkflowRunDetail => ({ + id: 'run-1', + version: '1.0.0', + status: 'succeeded', + elapsed_time: 1.234, + total_tokens: 100, + total_price: 0.001, + currency: 'USD', + total_steps: 5, + finished_at: Date.now(), + triggered_from: WorkflowRunTriggeredFrom.APP_RUN, + ...overrides, +}) + +const createMockWorkflowLog = (overrides: Partial = {}): WorkflowAppLogDetail => ({ + id: 'log-1', + workflow_run: createMockWorkflowRun(), + created_from: 'web-app', + created_by_role: 'account', + created_by_account: { + id: 'account-1', + name: 'Test User', + email: 'test@example.com', + }, + created_at: Date.now(), + ...overrides, +}) + +const createMockLogsResponse = ( + data: WorkflowAppLogDetail[] = [], + total = data.length, +): WorkflowLogsResponse => ({ + data, + has_more: data.length < total, + limit: APP_PAGE_LIMIT, + total, + page: 1, +}) + +// ============================================================================ +// Tests +// ============================================================================ + +describe('WorkflowAppLogList', () => { + const defaultOnRefresh = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + useAppStore.setState({ appDetail: createMockApp() }) + }) + + // -------------------------------------------------------------------------- + // Rendering Tests (REQUIRED) + // -------------------------------------------------------------------------- + describe('Rendering', () => { + it('should render loading state when logs are undefined', () => { + const { container } = render( + , + ) + + expect(container.querySelector('.spin-animation')).toBeInTheDocument() + }) + + it('should render loading state when appDetail is undefined', () => { + const logs = createMockLogsResponse([createMockWorkflowLog()]) + + const { container } = render( + , + ) + + expect(container.querySelector('.spin-animation')).toBeInTheDocument() + }) + + it('should render table when data is available', () => { + const logs = createMockLogsResponse([createMockWorkflowLog()]) + + render( + , + ) + + expect(screen.getByRole('table')).toBeInTheDocument() + }) + + it('should render all table headers', () => { + const logs = createMockLogsResponse([createMockWorkflowLog()]) + + render( + , + ) + + expect(screen.getByText('appLog.table.header.startTime')).toBeInTheDocument() + expect(screen.getByText('appLog.table.header.status')).toBeInTheDocument() + expect(screen.getByText('appLog.table.header.runtime')).toBeInTheDocument() + expect(screen.getByText('appLog.table.header.tokens')).toBeInTheDocument() + expect(screen.getByText('appLog.table.header.user')).toBeInTheDocument() + }) + + it('should render trigger column for workflow apps', () => { + const logs = createMockLogsResponse([createMockWorkflowLog()]) + const workflowApp = createMockApp({ mode: 'workflow' as AppModeEnum }) + + render( + , + ) + + expect(screen.getByText('appLog.table.header.triggered_from')).toBeInTheDocument() + }) + + it('should not render trigger column for non-workflow apps', () => { + const logs = createMockLogsResponse([createMockWorkflowLog()]) + const chatApp = createMockApp({ mode: 'advanced-chat' as AppModeEnum }) + + render( + , + ) + + expect(screen.queryByText('appLog.table.header.triggered_from')).not.toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Status Display Tests + // -------------------------------------------------------------------------- + describe('Status Display', () => { + it('should render success status correctly', () => { + const logs = createMockLogsResponse([ + createMockWorkflowLog({ + workflow_run: createMockWorkflowRun({ status: 'succeeded' }), + }), + ]) + + render( + , + ) + + expect(screen.getByText('Success')).toBeInTheDocument() + }) + + it('should render failure status correctly', () => { + const logs = createMockLogsResponse([ + createMockWorkflowLog({ + workflow_run: createMockWorkflowRun({ status: 'failed' }), + }), + ]) + + render( + , + ) + + expect(screen.getByText('Failure')).toBeInTheDocument() + }) + + it('should render stopped status correctly', () => { + const logs = createMockLogsResponse([ + createMockWorkflowLog({ + workflow_run: createMockWorkflowRun({ status: 'stopped' }), + }), + ]) + + render( + , + ) + + expect(screen.getByText('Stop')).toBeInTheDocument() + }) + + it('should render running status correctly', () => { + const logs = createMockLogsResponse([ + createMockWorkflowLog({ + workflow_run: createMockWorkflowRun({ status: 'running' }), + }), + ]) + + render( + , + ) + + expect(screen.getByText('Running')).toBeInTheDocument() + }) + + it('should render partial-succeeded status correctly', () => { + const logs = createMockLogsResponse([ + createMockWorkflowLog({ + workflow_run: createMockWorkflowRun({ status: 'partial-succeeded' as WorkflowRunDetail['status'] }), + }), + ]) + + render( + , + ) + + expect(screen.getByText('Partial Success')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // User Info Display Tests + // -------------------------------------------------------------------------- + describe('User Info Display', () => { + it('should display account name when created by account', () => { + const logs = createMockLogsResponse([ + createMockWorkflowLog({ + created_by_account: { id: 'acc-1', name: 'John Doe', email: 'john@example.com' }, + created_by_end_user: undefined, + }), + ]) + + render( + , + ) + + expect(screen.getByText('John Doe')).toBeInTheDocument() + }) + + it('should display end user session id when created by end user', () => { + const logs = createMockLogsResponse([ + createMockWorkflowLog({ + created_by_end_user: { id: 'user-1', type: 'browser', is_anonymous: false, session_id: 'session-abc-123' }, + created_by_account: undefined, + }), + ]) + + render( + , + ) + + expect(screen.getByText('session-abc-123')).toBeInTheDocument() + }) + + it('should display N/A when no user info', () => { + const logs = createMockLogsResponse([ + createMockWorkflowLog({ + created_by_account: undefined, + created_by_end_user: undefined, + }), + ]) + + render( + , + ) + + expect(screen.getByText('N/A')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Sorting Tests + // -------------------------------------------------------------------------- + describe('Sorting', () => { + it('should sort logs in descending order by default', () => { + const logs = createMockLogsResponse([ + createMockWorkflowLog({ id: 'log-1', created_at: 1000 }), + createMockWorkflowLog({ id: 'log-2', created_at: 2000 }), + createMockWorkflowLog({ id: 'log-3', created_at: 3000 }), + ]) + + render( + , + ) + + const rows = screen.getAllByRole('row') + // First row is header, data rows start from index 1 + // In descending order, newest (3000) should be first + expect(rows.length).toBe(4) // 1 header + 3 data rows + }) + + it('should toggle sort order when clicking on start time header', async () => { + const user = userEvent.setup() + const logs = createMockLogsResponse([ + createMockWorkflowLog({ id: 'log-1', created_at: 1000 }), + createMockWorkflowLog({ id: 'log-2', created_at: 2000 }), + ]) + + render( + , + ) + + // Click on the start time header to toggle sort + const startTimeHeader = screen.getByText('appLog.table.header.startTime') + await user.click(startTimeHeader) + + // Arrow should rotate (indicated by class change) + // The sort icon should have rotate-180 class for ascending + const sortIcon = startTimeHeader.closest('div')?.querySelector('svg') + expect(sortIcon).toBeInTheDocument() + }) + + it('should render sort arrow icon', () => { + const logs = createMockLogsResponse([createMockWorkflowLog()]) + + const { container } = render( + , + ) + + // Check for ArrowDownIcon presence + const sortArrow = container.querySelector('svg.ml-0\\.5') + expect(sortArrow).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Drawer Tests + // -------------------------------------------------------------------------- + describe('Drawer', () => { + it('should open drawer when clicking on a log row', async () => { + const user = userEvent.setup() + useAppStore.setState({ appDetail: createMockApp({ id: 'app-123' }) }) + const logs = createMockLogsResponse([ + createMockWorkflowLog({ + id: 'log-1', + workflow_run: createMockWorkflowRun({ id: 'run-456' }), + }), + ]) + + render( + , + ) + + const dataRows = screen.getAllByRole('row') + await user.click(dataRows[1]) // Click first data row + + const dialog = await screen.findByRole('dialog') + expect(dialog).toBeInTheDocument() + expect(screen.getByText('appLog.runDetail.workflowTitle')).toBeInTheDocument() + }) + + it('should close drawer and call onRefresh when closing', async () => { + const user = userEvent.setup() + const onRefresh = jest.fn() + useAppStore.setState({ appDetail: createMockApp() }) + const logs = createMockLogsResponse([createMockWorkflowLog()]) + + render( + , + ) + + // Open drawer + const dataRows = screen.getAllByRole('row') + await user.click(dataRows[1]) + await screen.findByRole('dialog') + + // Close drawer using Escape key + await user.keyboard('{Escape}') + + await waitFor(() => { + expect(onRefresh).toHaveBeenCalled() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + }) + + it('should highlight selected row', async () => { + const user = userEvent.setup() + const logs = createMockLogsResponse([createMockWorkflowLog()]) + + render( + , + ) + + const dataRows = screen.getAllByRole('row') + const dataRow = dataRows[1] + + // Before click - no highlight + expect(dataRow).not.toHaveClass('bg-background-default-hover') + + // After click - has highlight (via currentLog state) + await user.click(dataRow) + + // The row should have the selected class + expect(dataRow).toHaveClass('bg-background-default-hover') + }) + }) + + // -------------------------------------------------------------------------- + // Replay Functionality Tests + // -------------------------------------------------------------------------- + describe('Replay Functionality', () => { + it('should allow replay when triggered from app-run', async () => { + const user = userEvent.setup() + useAppStore.setState({ appDetail: createMockApp({ id: 'app-replay' }) }) + const logs = createMockLogsResponse([ + createMockWorkflowLog({ + workflow_run: createMockWorkflowRun({ + id: 'run-to-replay', + triggered_from: WorkflowRunTriggeredFrom.APP_RUN, + }), + }), + ]) + + render( + , + ) + + // Open drawer + const dataRows = screen.getAllByRole('row') + await user.click(dataRows[1]) + await screen.findByRole('dialog') + + // Replay button should be present for app-run triggers + const replayButton = screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' }) + await user.click(replayButton) + + expect(mockRouterPush).toHaveBeenCalledWith('/app/app-replay/workflow?replayRunId=run-to-replay') + }) + + it('should allow replay when triggered from debugging', async () => { + const user = userEvent.setup() + useAppStore.setState({ appDetail: createMockApp({ id: 'app-debug' }) }) + const logs = createMockLogsResponse([ + createMockWorkflowLog({ + workflow_run: createMockWorkflowRun({ + id: 'debug-run', + triggered_from: WorkflowRunTriggeredFrom.DEBUGGING, + }), + }), + ]) + + render( + , + ) + + // Open drawer + const dataRows = screen.getAllByRole('row') + await user.click(dataRows[1]) + await screen.findByRole('dialog') + + // Replay button should be present for debugging triggers + const replayButton = screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' }) + expect(replayButton).toBeInTheDocument() + }) + + it('should not show replay for webhook triggers', async () => { + const user = userEvent.setup() + useAppStore.setState({ appDetail: createMockApp({ id: 'app-webhook' }) }) + const logs = createMockLogsResponse([ + createMockWorkflowLog({ + workflow_run: createMockWorkflowRun({ + id: 'webhook-run', + triggered_from: WorkflowRunTriggeredFrom.WEBHOOK, + }), + }), + ]) + + render( + , + ) + + // Open drawer + const dataRows = screen.getAllByRole('row') + await user.click(dataRows[1]) + await screen.findByRole('dialog') + + // Replay button should not be present for webhook triggers + expect(screen.queryByRole('button', { name: 'appLog.runDetail.testWithParams' })).not.toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Unread Indicator Tests + // -------------------------------------------------------------------------- + describe('Unread Indicator', () => { + it('should show unread indicator for unread logs', () => { + const logs = createMockLogsResponse([ + createMockWorkflowLog({ + read_at: undefined, + }), + ]) + + const { container } = render( + , + ) + + // Unread indicator is a small blue dot + const unreadDot = container.querySelector('.bg-util-colors-blue-blue-500') + expect(unreadDot).toBeInTheDocument() + }) + + it('should not show unread indicator for read logs', () => { + const logs = createMockLogsResponse([ + createMockWorkflowLog({ + read_at: Date.now(), + }), + ]) + + const { container } = render( + , + ) + + // No unread indicator + const unreadDot = container.querySelector('.bg-util-colors-blue-blue-500') + expect(unreadDot).not.toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Runtime Display Tests + // -------------------------------------------------------------------------- + describe('Runtime Display', () => { + it('should display elapsed time with 3 decimal places', () => { + const logs = createMockLogsResponse([ + createMockWorkflowLog({ + workflow_run: createMockWorkflowRun({ elapsed_time: 1.23456 }), + }), + ]) + + render( + , + ) + + expect(screen.getByText('1.235s')).toBeInTheDocument() + }) + + it('should display 0 elapsed time with special styling', () => { + const logs = createMockLogsResponse([ + createMockWorkflowLog({ + workflow_run: createMockWorkflowRun({ elapsed_time: 0 }), + }), + ]) + + render( + , + ) + + const zeroTime = screen.getByText('0.000s') + expect(zeroTime).toBeInTheDocument() + expect(zeroTime).toHaveClass('text-text-quaternary') + }) + }) + + // -------------------------------------------------------------------------- + // Token Display Tests + // -------------------------------------------------------------------------- + describe('Token Display', () => { + it('should display total tokens', () => { + const logs = createMockLogsResponse([ + createMockWorkflowLog({ + workflow_run: createMockWorkflowRun({ total_tokens: 12345 }), + }), + ]) + + render( + , + ) + + expect(screen.getByText('12345')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Empty State Tests + // -------------------------------------------------------------------------- + describe('Empty State', () => { + it('should render empty table when logs data is empty', () => { + const logs = createMockLogsResponse([]) + + render( + , + ) + + const table = screen.getByRole('table') + expect(table).toBeInTheDocument() + + // Should only have header row + const rows = screen.getAllByRole('row') + expect(rows).toHaveLength(1) + }) + }) + + // -------------------------------------------------------------------------- + // Edge Cases (REQUIRED) + // -------------------------------------------------------------------------- + describe('Edge Cases', () => { + it('should handle multiple logs correctly', () => { + const logs = createMockLogsResponse([ + createMockWorkflowLog({ id: 'log-1', created_at: 1000 }), + createMockWorkflowLog({ id: 'log-2', created_at: 2000 }), + createMockWorkflowLog({ id: 'log-3', created_at: 3000 }), + ]) + + render( + , + ) + + const rows = screen.getAllByRole('row') + expect(rows).toHaveLength(4) // 1 header + 3 data rows + }) + + it('should handle logs with missing workflow_run data gracefully', () => { + const logs = createMockLogsResponse([ + createMockWorkflowLog({ + workflow_run: createMockWorkflowRun({ + elapsed_time: 0, + total_tokens: 0, + }), + }), + ]) + + render( + , + ) + + expect(screen.getByText('0.000s')).toBeInTheDocument() + expect(screen.getByText('0')).toBeInTheDocument() + }) + + it('should handle null workflow_run.triggered_from for non-workflow apps', () => { + const logs = createMockLogsResponse([ + createMockWorkflowLog({ + workflow_run: createMockWorkflowRun({ + triggered_from: undefined as any, + }), + }), + ]) + const chatApp = createMockApp({ mode: 'advanced-chat' as AppModeEnum }) + + render( + , + ) + + // Should render without trigger column + expect(screen.queryByText('appLog.table.header.triggered_from')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/app/workflow-log/trigger-by-display.spec.tsx b/web/app/components/app/workflow-log/trigger-by-display.spec.tsx new file mode 100644 index 0000000000..a2110f14eb --- /dev/null +++ b/web/app/components/app/workflow-log/trigger-by-display.spec.tsx @@ -0,0 +1,377 @@ +/** + * TriggerByDisplay Component Tests + * + * Tests the display of workflow trigger sources with appropriate icons and labels. + * Covers all trigger types: app-run, debugging, webhook, schedule, plugin, rag-pipeline. + */ + +import { render, screen } from '@testing-library/react' +import TriggerByDisplay from './trigger-by-display' +import { WorkflowRunTriggeredFrom } from '@/models/log' +import type { TriggerMetadata } from '@/models/log' +import { Theme } from '@/types/app' + +// ============================================================================ +// Mocks +// ============================================================================ + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +let mockTheme = Theme.light +jest.mock('@/hooks/use-theme', () => ({ + __esModule: true, + default: () => ({ theme: mockTheme }), +})) + +// Mock BlockIcon as it has complex dependencies +jest.mock('@/app/components/workflow/block-icon', () => ({ + __esModule: true, + default: ({ type, toolIcon }: { type: string; toolIcon?: string }) => ( +
+ BlockIcon +
+ ), +})) + +// ============================================================================ +// Test Data Factories +// ============================================================================ + +const createTriggerMetadata = (overrides: Partial = {}): TriggerMetadata => ({ + ...overrides, +}) + +// ============================================================================ +// Tests +// ============================================================================ + +describe('TriggerByDisplay', () => { + beforeEach(() => { + jest.clearAllMocks() + mockTheme = Theme.light + }) + + // -------------------------------------------------------------------------- + // Rendering Tests (REQUIRED) + // -------------------------------------------------------------------------- + describe('Rendering', () => { + it('should render without crashing', () => { + render() + + expect(screen.getByText('appLog.triggerBy.appRun')).toBeInTheDocument() + }) + + it('should render icon container', () => { + const { container } = render( + , + ) + + // Should have icon container with flex layout + const iconContainer = container.querySelector('.flex.items-center.justify-center') + expect(iconContainer).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Props Tests (REQUIRED) + // -------------------------------------------------------------------------- + describe('Props', () => { + it('should apply custom className', () => { + const { container } = render( + , + ) + + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('custom-class') + }) + + it('should show text by default (showText defaults to true)', () => { + render() + + expect(screen.getByText('appLog.triggerBy.appRun')).toBeInTheDocument() + }) + + it('should hide text when showText is false', () => { + render( + , + ) + + expect(screen.queryByText('appLog.triggerBy.appRun')).not.toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Trigger Type Display Tests + // -------------------------------------------------------------------------- + describe('Trigger Types', () => { + it('should display app-run trigger correctly', () => { + render() + + expect(screen.getByText('appLog.triggerBy.appRun')).toBeInTheDocument() + }) + + it('should display debugging trigger correctly', () => { + render() + + expect(screen.getByText('appLog.triggerBy.debugging')).toBeInTheDocument() + }) + + it('should display webhook trigger correctly', () => { + render() + + expect(screen.getByText('appLog.triggerBy.webhook')).toBeInTheDocument() + }) + + it('should display schedule trigger correctly', () => { + render() + + expect(screen.getByText('appLog.triggerBy.schedule')).toBeInTheDocument() + }) + + it('should display plugin trigger correctly', () => { + render() + + expect(screen.getByText('appLog.triggerBy.plugin')).toBeInTheDocument() + }) + + it('should display rag-pipeline-run trigger correctly', () => { + render() + + expect(screen.getByText('appLog.triggerBy.ragPipelineRun')).toBeInTheDocument() + }) + + it('should display rag-pipeline-debugging trigger correctly', () => { + render() + + expect(screen.getByText('appLog.triggerBy.ragPipelineDebugging')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Plugin Metadata Tests + // -------------------------------------------------------------------------- + describe('Plugin Metadata', () => { + it('should display custom event name from plugin metadata', () => { + const metadata = createTriggerMetadata({ event_name: 'Custom Plugin Event' }) + + render( + , + ) + + expect(screen.getByText('Custom Plugin Event')).toBeInTheDocument() + }) + + it('should fallback to default plugin text when no event_name', () => { + const metadata = createTriggerMetadata({}) + + render( + , + ) + + expect(screen.getByText('appLog.triggerBy.plugin')).toBeInTheDocument() + }) + + it('should use plugin icon from metadata in light theme', () => { + mockTheme = Theme.light + const metadata = createTriggerMetadata({ icon: 'light-icon.png', icon_dark: 'dark-icon.png' }) + + render( + , + ) + + const blockIcon = screen.getByTestId('block-icon') + expect(blockIcon).toHaveAttribute('data-tool-icon', 'light-icon.png') + }) + + it('should use dark plugin icon in dark theme', () => { + mockTheme = Theme.dark + const metadata = createTriggerMetadata({ icon: 'light-icon.png', icon_dark: 'dark-icon.png' }) + + render( + , + ) + + const blockIcon = screen.getByTestId('block-icon') + expect(blockIcon).toHaveAttribute('data-tool-icon', 'dark-icon.png') + }) + + it('should fallback to light icon when dark icon not available in dark theme', () => { + mockTheme = Theme.dark + const metadata = createTriggerMetadata({ icon: 'light-icon.png' }) + + render( + , + ) + + const blockIcon = screen.getByTestId('block-icon') + expect(blockIcon).toHaveAttribute('data-tool-icon', 'light-icon.png') + }) + + it('should use default BlockIcon when plugin has no icon metadata', () => { + const metadata = createTriggerMetadata({}) + + render( + , + ) + + const blockIcon = screen.getByTestId('block-icon') + expect(blockIcon).toHaveAttribute('data-tool-icon', '') + }) + }) + + // -------------------------------------------------------------------------- + // Icon Rendering Tests + // -------------------------------------------------------------------------- + describe('Icon Rendering', () => { + it('should render WindowCursor icon for app-run trigger', () => { + const { container } = render( + , + ) + + // Check for the blue brand background used for app-run icon + const iconWrapper = container.querySelector('.bg-util-colors-blue-brand-blue-brand-500') + expect(iconWrapper).toBeInTheDocument() + }) + + it('should render Code icon for debugging trigger', () => { + const { container } = render( + , + ) + + // Check for the blue background used for debugging icon + const iconWrapper = container.querySelector('.bg-util-colors-blue-blue-500') + expect(iconWrapper).toBeInTheDocument() + }) + + it('should render WebhookLine icon for webhook trigger', () => { + const { container } = render( + , + ) + + // Check for the blue background used for webhook icon + const iconWrapper = container.querySelector('.bg-util-colors-blue-blue-500') + expect(iconWrapper).toBeInTheDocument() + }) + + it('should render Schedule icon for schedule trigger', () => { + const { container } = render( + , + ) + + // Check for the violet background used for schedule icon + const iconWrapper = container.querySelector('.bg-util-colors-violet-violet-500') + expect(iconWrapper).toBeInTheDocument() + }) + + it('should render KnowledgeRetrieval icon for rag-pipeline triggers', () => { + const { container } = render( + , + ) + + // Check for the green background used for rag pipeline icon + const iconWrapper = container.querySelector('.bg-util-colors-green-green-500') + expect(iconWrapper).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Edge Cases (REQUIRED) + // -------------------------------------------------------------------------- + describe('Edge Cases', () => { + it('should handle unknown trigger type gracefully', () => { + // Test with a type cast to simulate unknown trigger type + render() + + // Should fallback to default (app-run) icon styling + expect(screen.getByText('unknown-type')).toBeInTheDocument() + }) + + it('should handle undefined triggerMetadata', () => { + render( + , + ) + + expect(screen.getByText('appLog.triggerBy.plugin')).toBeInTheDocument() + }) + + it('should handle empty className', () => { + const { container } = render( + , + ) + + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('flex', 'items-center', 'gap-1.5') + }) + + it('should render correctly when both showText is false and metadata is provided', () => { + const metadata = createTriggerMetadata({ event_name: 'Test Event' }) + + render( + , + ) + + // Text should not be visible even with metadata + expect(screen.queryByText('Test Event')).not.toBeInTheDocument() + expect(screen.queryByText('appLog.triggerBy.plugin')).not.toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Theme Switching Tests + // -------------------------------------------------------------------------- + describe('Theme Switching', () => { + it('should render correctly in light theme', () => { + mockTheme = Theme.light + + render() + + expect(screen.getByText('appLog.triggerBy.appRun')).toBeInTheDocument() + }) + + it('should render correctly in dark theme', () => { + mockTheme = Theme.dark + + render() + + expect(screen.getByText('appLog.triggerBy.appRun')).toBeInTheDocument() + }) + }) +})