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()
+ })
+ })
+})