refactor(web): migrate log service to TanStack Query (#30065)

This commit is contained in:
yyh 2025-12-24 15:25:28 +08:00 committed by GitHub
parent dcde854c5e
commit b2b7e82e28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 741 additions and 417 deletions

View File

@ -49,10 +49,10 @@ pnpm test
pnpm test:watch pnpm test:watch
# Run specific file # Run specific file
pnpm test -- path/to/file.spec.tsx pnpm test path/to/file.spec.tsx
# Generate coverage report # Generate coverage report
pnpm test -- --coverage pnpm test:coverage
# Analyze component complexity # Analyze component complexity
pnpm analyze-component <path> pnpm analyze-component <path>
@ -155,7 +155,7 @@ describe('ComponentName', () => {
For each file: For each file:
┌────────────────────────────────────────┐ ┌────────────────────────────────────────┐
│ 1. Write test │ │ 1. Write test │
│ 2. Run: pnpm test -- <file>.spec.tsx │ │ 2. Run: pnpm test <file>.spec.tsx
│ 3. PASS? → Mark complete, next file │ │ 3. PASS? → Mark complete, next file │
│ FAIL? → Fix first, then continue │ │ FAIL? → Fix first, then continue │
└────────────────────────────────────────┘ └────────────────────────────────────────┘

View File

@ -198,7 +198,7 @@ describe('ComponentName', () => {
}) })
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
// Async Operations (if component fetches data - useSWR, useQuery, fetch) // Async Operations (if component fetches data - useQuery, fetch)
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
// WHY: Async operations have 3 states users experience: loading, success, error // WHY: Async operations have 3 states users experience: loading, success, error
describe('Async Operations', () => { describe('Async Operations', () => {

View File

@ -114,15 +114,15 @@ For the current file being tested:
**Run these checks after EACH test file, not just at the end:** **Run these checks after EACH test file, not just at the end:**
- [ ] Run `pnpm test -- path/to/file.spec.tsx` - **MUST PASS before next file** - [ ] Run `pnpm test path/to/file.spec.tsx` - **MUST PASS before next file**
- [ ] Fix any failures immediately - [ ] Fix any failures immediately
- [ ] Mark file as complete in todo list - [ ] Mark file as complete in todo list
- [ ] Only then proceed to next file - [ ] Only then proceed to next file
### After All Files Complete ### After All Files Complete
- [ ] Run full directory test: `pnpm test -- path/to/directory/` - [ ] Run full directory test: `pnpm test path/to/directory/`
- [ ] Check coverage report: `pnpm test -- --coverage` - [ ] Check coverage report: `pnpm test:coverage`
- [ ] Run `pnpm lint:fix` on all test files - [ ] Run `pnpm lint:fix` on all test files
- [ ] Run `pnpm type-check:tsgo` - [ ] Run `pnpm type-check:tsgo`
@ -186,16 +186,16 @@ Always test these scenarios:
```bash ```bash
# Run specific test # Run specific test
pnpm test -- path/to/file.spec.tsx pnpm test path/to/file.spec.tsx
# Run with coverage # Run with coverage
pnpm test -- --coverage path/to/file.spec.tsx pnpm test:coverage path/to/file.spec.tsx
# Watch mode # Watch mode
pnpm test:watch -- path/to/file.spec.tsx pnpm test:watch path/to/file.spec.tsx
# Update snapshots (use sparingly) # Update snapshots (use sparingly)
pnpm test -- -u path/to/file.spec.tsx pnpm test -u path/to/file.spec.tsx
# Analyze component # Analyze component
pnpm analyze-component path/to/component.tsx pnpm analyze-component path/to/component.tsx

View File

@ -242,32 +242,9 @@ describe('Component with Context', () => {
}) })
``` ```
### 7. SWR / React Query ### 7. React Query
```typescript ```typescript
// SWR
vi.mock('swr', () => ({
__esModule: true,
default: vi.fn(),
}))
import useSWR from 'swr'
const mockedUseSWR = vi.mocked(useSWR)
describe('Component with SWR', () => {
it('should show loading state', () => {
mockedUseSWR.mockReturnValue({
data: undefined,
error: undefined,
isLoading: true,
})
render(<Component />)
expect(screen.getByText(/loading/i)).toBeInTheDocument()
})
})
// React Query
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const createTestQueryClient = () => new QueryClient({ const createTestQueryClient = () => new QueryClient({

View File

@ -35,7 +35,7 @@ When testing a **single component, hook, or utility**:
2. Run `pnpm analyze-component <path>` (if available) 2. Run `pnpm analyze-component <path>` (if available)
3. Check complexity score and features detected 3. Check complexity score and features detected
4. Write the test file 4. Write the test file
5. Run test: `pnpm test -- <file>.spec.tsx` 5. Run test: `pnpm test <file>.spec.tsx`
6. Fix any failures 6. Fix any failures
7. Verify coverage meets goals (100% function, >95% branch) 7. Verify coverage meets goals (100% function, >95% branch)
``` ```
@ -80,7 +80,7 @@ Process files in this recommended order:
``` ```
┌─────────────────────────────────────────────┐ ┌─────────────────────────────────────────────┐
│ 1. Write test file │ │ 1. Write test file │
│ 2. Run: pnpm test -- <file>.spec.tsx │ │ 2. Run: pnpm test <file>.spec.tsx
│ 3. If FAIL → Fix immediately, re-run │ │ 3. If FAIL → Fix immediately, re-run │
│ 4. If PASS → Mark complete in todo list │ │ 4. If PASS → Mark complete in todo list │
│ 5. ONLY THEN proceed to next file │ │ 5. ONLY THEN proceed to next file │
@ -95,10 +95,10 @@ After all individual tests pass:
```bash ```bash
# Run all tests in the directory together # Run all tests in the directory together
pnpm test -- path/to/directory/ pnpm test path/to/directory/
# Check coverage # Check coverage
pnpm test -- --coverage path/to/directory/ pnpm test:coverage path/to/directory/
``` ```
## Component Complexity Guidelines ## Component Complexity Guidelines
@ -201,9 +201,9 @@ Run pnpm test ← Multiple failures, hard to debug
``` ```
# GOOD: Incremental with verification # GOOD: Incremental with verification
Write component-a.spec.tsx Write component-a.spec.tsx
Run pnpm test -- component-a.spec.tsx ✅ Run pnpm test component-a.spec.tsx ✅
Write component-b.spec.tsx Write component-b.spec.tsx
Run pnpm test -- component-b.spec.tsx ✅ Run pnpm test component-b.spec.tsx ✅
...continue... ...continue...
``` ```

View File

@ -42,7 +42,7 @@ jobs:
run: pnpm run check:i18n-types run: pnpm run check:i18n-types
- name: Run tests - name: Run tests
run: pnpm test --coverage run: pnpm test:coverage
- name: Coverage Summary - name: Coverage Summary
if: always() if: always()

View File

@ -1,55 +1,209 @@
import type { UseQueryResult } from '@tanstack/react-query'
import type { Mock } from 'vitest' import type { Mock } from 'vitest'
import type { QueryParam } from './filter' import type { QueryParam } from './filter'
import type { AnnotationsCountResponse } from '@/models/log'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react' import * as React from 'react'
import useSWR from 'swr' import * as useLogModule from '@/service/use-log'
import Filter from './filter' import Filter from './filter'
vi.mock('swr', () => ({ vi.mock('@/service/use-log')
__esModule: true,
default: vi.fn(),
}))
vi.mock('@/service/log', () => ({ const mockUseAnnotationsCount = useLogModule.useAnnotationsCount as Mock
fetchAnnotationsCount: vi.fn(),
}))
const mockUseSWR = useSWR as unknown as Mock // ============================================================================
// Test Utilities
// ============================================================================
const createQueryClient = () => new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const renderWithQueryClient = (ui: React.ReactElement) => {
const queryClient = createQueryClient()
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
)
}
// ============================================================================
// Mock Return Value Factory
// ============================================================================
type MockQueryResult<T> = Pick<UseQueryResult<T>, 'data' | 'isLoading' | 'error' | 'refetch'>
const createMockQueryResult = <T,>(
overrides: Partial<MockQueryResult<T>> = {},
): MockQueryResult<T> => ({
data: undefined,
isLoading: false,
error: null,
refetch: vi.fn(),
...overrides,
})
// ============================================================================
// Tests
// ============================================================================
describe('Filter', () => { describe('Filter', () => {
const appId = 'app-1' const appId = 'app-1'
const childContent = 'child-content' const childContent = 'child-content'
const defaultQueryParams: QueryParam = { keyword: '' }
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
}) })
it('should render nothing until annotation count is fetched', () => { // --------------------------------------------------------------------------
mockUseSWR.mockReturnValue({ data: undefined }) // Rendering Tests (REQUIRED)
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render nothing when data is loading', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
createMockQueryResult<AnnotationsCountResponse>({ isLoading: true }),
)
const { container } = render( // Act
const { container } = renderWithQueryClient(
<Filter <Filter
appId={appId} appId={appId}
queryParams={{ keyword: '' }} queryParams={defaultQueryParams}
setQueryParams={vi.fn()} setQueryParams={vi.fn()}
> >
<div>{childContent}</div> <div>{childContent}</div>
</Filter>, </Filter>,
) )
// Assert
expect(container.firstChild).toBeNull() expect(container.firstChild).toBeNull()
expect(mockUseSWR).toHaveBeenCalledWith(
{ url: `/apps/${appId}/annotations/count` },
expect.any(Function),
)
}) })
it('should propagate keyword changes and clearing behavior', () => { it('should render nothing when data is undefined', () => {
mockUseSWR.mockReturnValue({ data: { total: 20 } }) // Arrange
const queryParams: QueryParam = { keyword: 'prefill' } mockUseAnnotationsCount.mockReturnValue(
createMockQueryResult<AnnotationsCountResponse>({ data: undefined, isLoading: false }),
)
// Act
const { container } = renderWithQueryClient(
<Filter
appId={appId}
queryParams={defaultQueryParams}
setQueryParams={vi.fn()}
>
<div>{childContent}</div>
</Filter>,
)
// Assert
expect(container.firstChild).toBeNull()
})
it('should render filter and children when data is available', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
createMockQueryResult<AnnotationsCountResponse>({
data: { count: 20 },
isLoading: false,
}),
)
// Act
renderWithQueryClient(
<Filter
appId={appId}
queryParams={defaultQueryParams}
setQueryParams={vi.fn()}
>
<div>{childContent}</div>
</Filter>,
)
// Assert
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
expect(screen.getByText(childContent)).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Props Tests (REQUIRED)
// --------------------------------------------------------------------------
describe('Props', () => {
it('should call useAnnotationsCount with appId', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
createMockQueryResult<AnnotationsCountResponse>({
data: { count: 10 },
isLoading: false,
}),
)
// Act
renderWithQueryClient(
<Filter
appId={appId}
queryParams={defaultQueryParams}
setQueryParams={vi.fn()}
>
<div>{childContent}</div>
</Filter>,
)
// Assert
expect(mockUseAnnotationsCount).toHaveBeenCalledWith(appId)
})
it('should display keyword value in input', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
createMockQueryResult<AnnotationsCountResponse>({
data: { count: 10 },
isLoading: false,
}),
)
const queryParams: QueryParam = { keyword: 'test-keyword' }
// Act
renderWithQueryClient(
<Filter
appId={appId}
queryParams={queryParams}
setQueryParams={vi.fn()}
>
<div>{childContent}</div>
</Filter>,
)
// Assert
expect(screen.getByPlaceholderText('common.operation.search')).toHaveValue('test-keyword')
})
})
// --------------------------------------------------------------------------
// User Interactions
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call setQueryParams when typing in search input', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
createMockQueryResult<AnnotationsCountResponse>({
data: { count: 20 },
isLoading: false,
}),
)
const queryParams: QueryParam = { keyword: '' }
const setQueryParams = vi.fn() const setQueryParams = vi.fn()
const { container } = render( renderWithQueryClient(
<Filter <Filter
appId={appId} appId={appId}
queryParams={queryParams} queryParams={queryParams}
@ -59,14 +213,120 @@ describe('Filter', () => {
</Filter>, </Filter>,
) )
const input = screen.getByPlaceholderText('common.operation.search') as HTMLInputElement // Act
const input = screen.getByPlaceholderText('common.operation.search')
fireEvent.change(input, { target: { value: 'updated' } }) fireEvent.change(input, { target: { value: 'updated' } })
// Assert
expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: 'updated' }) expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: 'updated' })
})
const clearButton = input.parentElement?.querySelector('div.cursor-pointer') as HTMLElement it('should call setQueryParams with empty keyword when clearing input', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
createMockQueryResult<AnnotationsCountResponse>({
data: { count: 20 },
isLoading: false,
}),
)
const queryParams: QueryParam = { keyword: 'prefill' }
const setQueryParams = vi.fn()
renderWithQueryClient(
<Filter
appId={appId}
queryParams={queryParams}
setQueryParams={setQueryParams}
>
<div>{childContent}</div>
</Filter>,
)
// Act
const input = screen.getByPlaceholderText('common.operation.search')
const clearButton = input.parentElement?.querySelector('div.cursor-pointer')
if (clearButton)
fireEvent.click(clearButton) fireEvent.click(clearButton)
expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: '' })
expect(container).toHaveTextContent(childContent) // Assert
expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: '' })
})
})
// --------------------------------------------------------------------------
// Edge Cases (REQUIRED)
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle empty keyword in queryParams', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
createMockQueryResult<AnnotationsCountResponse>({
data: { count: 5 },
isLoading: false,
}),
)
// Act
renderWithQueryClient(
<Filter
appId={appId}
queryParams={{ keyword: '' }}
setQueryParams={vi.fn()}
>
<div>{childContent}</div>
</Filter>,
)
// Assert
expect(screen.getByPlaceholderText('common.operation.search')).toHaveValue('')
})
it('should handle undefined keyword in queryParams', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
createMockQueryResult<AnnotationsCountResponse>({
data: { count: 5 },
isLoading: false,
}),
)
// Act
renderWithQueryClient(
<Filter
appId={appId}
queryParams={{ keyword: undefined }}
setQueryParams={vi.fn()}
>
<div>{childContent}</div>
</Filter>,
)
// Assert
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
})
it('should handle zero count', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
createMockQueryResult<AnnotationsCountResponse>({
data: { count: 0 },
isLoading: false,
}),
)
// Act
renderWithQueryClient(
<Filter
appId={appId}
queryParams={defaultQueryParams}
setQueryParams={vi.fn()}
>
<div>{childContent}</div>
</Filter>,
)
// Assert - should still render when count is 0
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
})
}) })
}) })

View File

@ -2,9 +2,8 @@
import type { FC } from 'react' import type { FC } from 'react'
import * as React from 'react' import * as React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import { fetchAnnotationsCount } from '@/service/log' import { useAnnotationsCount } from '@/service/use-log'
export type QueryParam = { export type QueryParam = {
keyword?: string keyword?: string
@ -23,10 +22,9 @@ const Filter: FC<IFilterProps> = ({
setQueryParams, setQueryParams,
children, children,
}) => { }) => {
// TODO: change fetch list api const { data, isLoading } = useAnnotationsCount(appId)
const { data } = useSWR({ url: `/apps/${appId}/annotations/count` }, fetchAnnotationsCount)
const { t } = useTranslation() const { t } = useTranslation()
if (!data) if (isLoading || !data)
return null return null
return ( return (
<div className="mb-2 flex flex-row flex-wrap items-center justify-between gap-2"> <div className="mb-2 flex flex-row flex-wrap items-center justify-between gap-2">

View File

@ -6,11 +6,10 @@ import dayjs from 'dayjs'
import quarterOfYear from 'dayjs/plugin/quarterOfYear' import quarterOfYear from 'dayjs/plugin/quarterOfYear'
import * as React from 'react' import * as React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import Chip from '@/app/components/base/chip' import Chip from '@/app/components/base/chip'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import Sort from '@/app/components/base/sort' import Sort from '@/app/components/base/sort'
import { fetchAnnotationsCount } from '@/service/log' import { useAnnotationsCount } from '@/service/use-log'
dayjs.extend(quarterOfYear) dayjs.extend(quarterOfYear)
@ -36,9 +35,9 @@ type IFilterProps = {
} }
const Filter: FC<IFilterProps> = ({ isChatMode, appId, queryParams, setQueryParams }: IFilterProps) => { const Filter: FC<IFilterProps> = ({ isChatMode, appId, queryParams, setQueryParams }: IFilterProps) => {
const { data } = useSWR({ url: `/apps/${appId}/annotations/count` }, fetchAnnotationsCount) const { data, isLoading } = useAnnotationsCount(appId)
const { t } = useTranslation() const { t } = useTranslation()
if (!data) if (isLoading || !data)
return null return null
return ( return (
<div className="mb-2 flex flex-row flex-wrap items-center gap-2"> <div className="mb-2 flex flex-row flex-wrap items-center gap-2">

View File

@ -8,11 +8,10 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import * as React from 'react' import * as React from 'react'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import Pagination from '@/app/components/base/pagination' import Pagination from '@/app/components/base/pagination'
import { APP_PAGE_LIMIT } from '@/config' import { APP_PAGE_LIMIT } from '@/config'
import { fetchChatConversations, fetchCompletionConversations } from '@/service/log' import { useChatConversations, useCompletionConversations } from '@/service/use-log'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import EmptyElement from './empty-element' import EmptyElement from './empty-element'
import Filter, { TIME_PERIOD_MAPPING } from './filter' import Filter, { TIME_PERIOD_MAPPING } from './filter'
@ -88,19 +87,15 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
} }
// When the details are obtained, proceed to the next request // When the details are obtained, proceed to the next request
const { data: chatConversations, mutate: mutateChatList } = useSWR(() => isChatMode const { data: chatConversations, refetch: mutateChatList } = useChatConversations({
? { appId: isChatMode ? appDetail.id : '',
url: `/apps/${appDetail.id}/chat-conversations`,
params: query, params: query,
} })
: null, fetchChatConversations)
const { data: completionConversations, mutate: mutateCompletionList } = useSWR(() => !isChatMode const { data: completionConversations, refetch: mutateCompletionList } = useCompletionConversations({
? { appId: !isChatMode ? appDetail.id : '',
url: `/apps/${appDetail.id}/completion-conversations`,
params: query, params: query,
} })
: null, fetchCompletionConversations)
const total = isChatMode ? chatConversations?.total : completionConversations?.total const total = isChatMode ? chatConversations?.total : completionConversations?.total

View File

@ -17,7 +17,6 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import * as React from 'react' import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { createContext, useContext } from 'use-context-selector' import { createContext, useContext } from 'use-context-selector'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import ModelInfo from '@/app/components/app/log/model-info' import ModelInfo from '@/app/components/app/log/model-info'
@ -38,7 +37,8 @@ import { WorkflowContextProvider } from '@/app/components/workflow/context'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useTimestamp from '@/hooks/use-timestamp' import useTimestamp from '@/hooks/use-timestamp'
import { fetchChatConversationDetail, fetchChatMessages, fetchCompletionConversationDetail, updateLogMessageAnnotations, updateLogMessageFeedbacks } from '@/service/log' import { fetchChatMessages, updateLogMessageAnnotations, updateLogMessageFeedbacks } from '@/service/log'
import { useChatConversationDetail, useCompletionConversationDetail } from '@/service/use-log'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import PromptLogModal from '../../base/prompt-log-modal' import PromptLogModal from '../../base/prompt-log-modal'
@ -825,8 +825,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
*/ */
const CompletionConversationDetailComp: FC<{ appId?: string, conversationId?: string }> = ({ appId, conversationId }) => { const CompletionConversationDetailComp: FC<{ appId?: string, conversationId?: string }> = ({ appId, conversationId }) => {
// Text Generator App Session Details Including Message List // Text Generator App Session Details Including Message List
const detailParams = ({ url: `/apps/${appId}/completion-conversations/${conversationId}` }) const { data: conversationDetail, refetch: conversationDetailMutate } = useCompletionConversationDetail(appId, conversationId)
const { data: conversationDetail, mutate: conversationDetailMutate } = useSWR(() => (appId && conversationId) ? detailParams : null, fetchCompletionConversationDetail)
const { notify } = useContext(ToastContext) const { notify } = useContext(ToastContext)
const { t } = useTranslation() const { t } = useTranslation()
@ -875,8 +874,7 @@ const CompletionConversationDetailComp: FC<{ appId?: string, conversationId?: st
* Chat App Conversation Detail Component * Chat App Conversation Detail Component
*/ */
const ChatConversationDetailComp: FC<{ appId?: string, conversationId?: string }> = ({ appId, conversationId }) => { const ChatConversationDetailComp: FC<{ appId?: string, conversationId?: string }> = ({ appId, conversationId }) => {
const detailParams = { url: `/apps/${appId}/chat-conversations/${conversationId}` } const { data: conversationDetail } = useChatConversationDetail(appId, conversationId)
const { data: conversationDetail } = useSWR(() => (appId && conversationId) ? detailParams : null, fetchChatConversationDetail)
const { notify } = useContext(ToastContext) const { notify } = useContext(ToastContext)
const { t } = useTranslation() const { t } = useTranslation()

View File

@ -1,9 +1,9 @@
import type { MockedFunction } from 'vitest' import type { UseQueryResult } from '@tanstack/react-query'
/** /**
* Logs Container Component Tests * Logs Container Component Tests
* *
* Tests the main Logs container component which: * Tests the main Logs container component which:
* - Fetches workflow logs via useSWR * - Fetches workflow logs via TanStack Query
* - Manages query parameters (status, period, keyword) * - Manages query parameters (status, period, keyword)
* - Handles pagination * - Handles pagination
* - Renders Filter, List, and Empty states * - Renders Filter, List, and Empty states
@ -15,14 +15,16 @@ import type { MockedFunction } from 'vitest'
* - trigger-by-display.spec.tsx * - trigger-by-display.spec.tsx
*/ */
import type { MockedFunction } from 'vitest'
import type { ILogsProps } from './index' import type { ILogsProps } from './index'
import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log' import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log'
import type { App, AppIconType, AppModeEnum } from '@/types/app' import type { App, AppIconType, AppModeEnum } from '@/types/app'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react' import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import useSWR from 'swr'
import { APP_PAGE_LIMIT } from '@/config' import { APP_PAGE_LIMIT } from '@/config'
import { WorkflowRunTriggeredFrom } from '@/models/log' import { WorkflowRunTriggeredFrom } from '@/models/log'
import * as useLogModule from '@/service/use-log'
import { TIME_PERIOD_MAPPING } from './filter' import { TIME_PERIOD_MAPPING } from './filter'
import Logs from './index' import Logs from './index'
@ -30,7 +32,7 @@ import Logs from './index'
// Mocks // Mocks
// ============================================================================ // ============================================================================
vi.mock('swr') vi.mock('@/service/use-log')
vi.mock('ahooks', () => ({ vi.mock('ahooks', () => ({
useDebounce: <T,>(value: T) => value, useDebounce: <T,>(value: T) => value,
@ -72,10 +74,6 @@ vi.mock('@/app/components/base/amplitude/utils', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args), trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
})) }))
vi.mock('@/service/log', () => ({
fetchWorkflowLogs: vi.fn(),
}))
vi.mock('@/hooks/use-theme', () => ({ vi.mock('@/hooks/use-theme', () => ({
__esModule: true, __esModule: true,
default: () => { default: () => {
@ -89,38 +87,76 @@ vi.mock('@/context/app-context', () => ({
}), }),
})) }))
// Mock useTimestamp
vi.mock('@/hooks/use-timestamp', () => ({
__esModule: true,
default: () => ({
formatTime: (timestamp: number, _format: string) => `formatted-${timestamp}`,
}),
}))
// Mock useBreakpoints
vi.mock('@/hooks/use-breakpoints', () => ({
__esModule: true,
default: () => 'pc',
MediaType: {
mobile: 'mobile',
pc: 'pc',
},
}))
// Mock BlockIcon
vi.mock('@/app/components/workflow/block-icon', () => ({
__esModule: true,
default: () => <div data-testid="block-icon">BlockIcon</div>,
}))
// Mock WorkflowContextProvider // Mock WorkflowContextProvider
vi.mock('@/app/components/workflow/context', () => ({ vi.mock('@/app/components/workflow/context', () => ({
WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => ( WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="workflow-context-provider">{children}</div> <>{children}</>
), ),
})) }))
const mockedUseSWR = useSWR as unknown as MockedFunction<typeof useSWR> const mockedUseWorkflowLogs = useLogModule.useWorkflowLogs as MockedFunction<typeof useLogModule.useWorkflowLogs>
// ============================================================================
// Test Utilities
// ============================================================================
const createQueryClient = () => new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const renderWithQueryClient = (ui: React.ReactElement) => {
const queryClient = createQueryClient()
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
)
}
// ============================================================================
// Mock Return Value Factory
// ============================================================================
const createMockQueryResult = <T,>(
overrides: { data?: T, isLoading?: boolean, error?: Error | null } = {},
): UseQueryResult<T, Error> => {
const isLoading = overrides.isLoading ?? false
const error = overrides.error ?? null
const data = overrides.data
return {
data,
isLoading,
error,
refetch: vi.fn(),
isError: !!error,
isPending: isLoading,
isSuccess: !isLoading && !error && data !== undefined,
isFetching: isLoading,
isRefetching: false,
isLoadingError: false,
isRefetchError: false,
isInitialLoading: isLoading,
isPaused: false,
isEnabled: true,
status: isLoading ? 'pending' : error ? 'error' : 'success',
fetchStatus: isLoading ? 'fetching' : 'idle',
dataUpdatedAt: Date.now(),
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
errorUpdateCount: 0,
isFetched: !isLoading,
isFetchedAfterMount: !isLoading,
isPlaceholderData: false,
isStale: false,
promise: Promise.resolve(data as T),
} as UseQueryResult<T, Error>
}
// ============================================================================ // ============================================================================
// Test Data Factories // Test Data Factories
@ -195,6 +231,20 @@ const createMockLogsResponse = (
page: 1, page: 1,
}) })
// ============================================================================
// Type-safe Mock Helper
// ============================================================================
type WorkflowLogsParams = {
appId: string
params?: Record<string, string | number | boolean | undefined>
}
const getMockCallParams = (): WorkflowLogsParams | undefined => {
const lastCall = mockedUseWorkflowLogs.mock.calls.at(-1)
return lastCall?.[0]
}
// ============================================================================ // ============================================================================
// Tests // Tests
// ============================================================================ // ============================================================================
@ -213,45 +263,48 @@ describe('Logs Container', () => {
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
describe('Rendering', () => { describe('Rendering', () => {
it('should render without crashing', () => { it('should render without crashing', () => {
mockedUseSWR.mockReturnValue({ // Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([], 0), data: createMockLogsResponse([], 0),
mutate: vi.fn(), }),
isValidating: false, )
isLoading: false,
error: undefined,
})
render(<Logs {...defaultProps} />) // Act
renderWithQueryClient(<Logs {...defaultProps} />)
// Assert
expect(screen.getByText('appLog.workflowTitle')).toBeInTheDocument() expect(screen.getByText('appLog.workflowTitle')).toBeInTheDocument()
}) })
it('should render title and subtitle', () => { it('should render title and subtitle', () => {
mockedUseSWR.mockReturnValue({ // Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([], 0), data: createMockLogsResponse([], 0),
mutate: vi.fn(), }),
isValidating: false, )
isLoading: false,
error: undefined,
})
render(<Logs {...defaultProps} />) // Act
renderWithQueryClient(<Logs {...defaultProps} />)
// Assert
expect(screen.getByText('appLog.workflowTitle')).toBeInTheDocument() expect(screen.getByText('appLog.workflowTitle')).toBeInTheDocument()
expect(screen.getByText('appLog.workflowSubtitle')).toBeInTheDocument() expect(screen.getByText('appLog.workflowSubtitle')).toBeInTheDocument()
}) })
it('should render Filter component', () => { it('should render Filter component', () => {
mockedUseSWR.mockReturnValue({ // Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([], 0), data: createMockLogsResponse([], 0),
mutate: vi.fn(), }),
isValidating: false, )
isLoading: false,
error: undefined,
})
render(<Logs {...defaultProps} />) // Act
renderWithQueryClient(<Logs {...defaultProps} />)
// Assert
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument() expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
}) })
}) })
@ -261,30 +314,33 @@ describe('Logs Container', () => {
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
describe('Loading State', () => { describe('Loading State', () => {
it('should show loading spinner when data is undefined', () => { it('should show loading spinner when data is undefined', () => {
mockedUseSWR.mockReturnValue({ // Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: undefined, data: undefined,
mutate: vi.fn(),
isValidating: true,
isLoading: true, isLoading: true,
error: undefined, }),
}) )
const { container } = render(<Logs {...defaultProps} />) // Act
const { container } = renderWithQueryClient(<Logs {...defaultProps} />)
// Assert
expect(container.querySelector('.spin-animation')).toBeInTheDocument() expect(container.querySelector('.spin-animation')).toBeInTheDocument()
}) })
it('should not show loading spinner when data is available', () => { it('should not show loading spinner when data is available', () => {
mockedUseSWR.mockReturnValue({ // Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([createMockWorkflowLog()], 1), data: createMockLogsResponse([createMockWorkflowLog()], 1),
mutate: vi.fn(), }),
isValidating: false, )
isLoading: false,
error: undefined,
})
const { container } = render(<Logs {...defaultProps} />) // Act
const { container } = renderWithQueryClient(<Logs {...defaultProps} />)
// Assert
expect(container.querySelector('.spin-animation')).not.toBeInTheDocument() expect(container.querySelector('.spin-animation')).not.toBeInTheDocument()
}) })
}) })
@ -294,16 +350,17 @@ describe('Logs Container', () => {
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
describe('Empty State', () => { describe('Empty State', () => {
it('should render empty element when total is 0', () => { it('should render empty element when total is 0', () => {
mockedUseSWR.mockReturnValue({ // Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([], 0), data: createMockLogsResponse([], 0),
mutate: vi.fn(), }),
isValidating: false, )
isLoading: false,
error: undefined,
})
render(<Logs {...defaultProps} />) // Act
renderWithQueryClient(<Logs {...defaultProps} />)
// Assert
expect(screen.getByText('appLog.table.empty.element.title')).toBeInTheDocument() expect(screen.getByText('appLog.table.empty.element.title')).toBeInTheDocument()
expect(screen.queryByRole('table')).not.toBeInTheDocument() expect(screen.queryByRole('table')).not.toBeInTheDocument()
}) })
@ -313,20 +370,21 @@ describe('Logs Container', () => {
// Data Fetching Tests // Data Fetching Tests
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
describe('Data Fetching', () => { describe('Data Fetching', () => {
it('should call useSWR with correct URL and default params', () => { it('should call useWorkflowLogs with correct appId and default params', () => {
mockedUseSWR.mockReturnValue({ // Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([], 0), data: createMockLogsResponse([], 0),
mutate: vi.fn(), }),
isValidating: false, )
isLoading: false,
error: undefined,
})
render(<Logs {...defaultProps} />) // Act
renderWithQueryClient(<Logs {...defaultProps} />)
const keyArg = mockedUseSWR.mock.calls.at(-1)?.[0] as { url: string, params: Record<string, unknown> } // Assert
expect(keyArg).toMatchObject({ const callArg = getMockCallParams()
url: `/apps/${defaultProps.appDetail.id}/workflow-app-logs`, expect(callArg).toMatchObject({
appId: defaultProps.appDetail.id,
params: expect.objectContaining({ params: expect.objectContaining({
page: 1, page: 1,
detail: true, detail: true,
@ -336,34 +394,36 @@ describe('Logs Container', () => {
}) })
it('should include date filters for non-allTime periods', () => { it('should include date filters for non-allTime periods', () => {
mockedUseSWR.mockReturnValue({ // Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([], 0), data: createMockLogsResponse([], 0),
mutate: vi.fn(), }),
isValidating: false, )
isLoading: false,
error: undefined,
})
render(<Logs {...defaultProps} />) // Act
renderWithQueryClient(<Logs {...defaultProps} />)
const keyArg = mockedUseSWR.mock.calls.at(-1)?.[0] as { params?: Record<string, unknown> } // Assert
expect(keyArg?.params).toHaveProperty('created_at__after') const callArg = getMockCallParams()
expect(keyArg?.params).toHaveProperty('created_at__before') expect(callArg?.params).toHaveProperty('created_at__after')
expect(callArg?.params).toHaveProperty('created_at__before')
}) })
it('should not include status param when status is all', () => { it('should not include status param when status is all', () => {
mockedUseSWR.mockReturnValue({ // Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([], 0), data: createMockLogsResponse([], 0),
mutate: vi.fn(), }),
isValidating: false, )
isLoading: false,
error: undefined,
})
render(<Logs {...defaultProps} />) // Act
renderWithQueryClient(<Logs {...defaultProps} />)
const keyArg = mockedUseSWR.mock.calls.at(-1)?.[0] as { params?: Record<string, unknown> } // Assert
expect(keyArg?.params).not.toHaveProperty('status') const callArg = getMockCallParams()
expect(callArg?.params).not.toHaveProperty('status')
}) })
}) })
@ -372,24 +432,23 @@ describe('Logs Container', () => {
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
describe('Filter Integration', () => { describe('Filter Integration', () => {
it('should update query when selecting status filter', async () => { it('should update query when selecting status filter', async () => {
// Arrange
const user = userEvent.setup() const user = userEvent.setup()
mockedUseSWR.mockReturnValue({ mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([], 0), data: createMockLogsResponse([], 0),
mutate: vi.fn(), }),
isValidating: false, )
isLoading: false,
error: undefined,
})
render(<Logs {...defaultProps} />) renderWithQueryClient(<Logs {...defaultProps} />)
// Click status filter // Act
await user.click(screen.getByText('All')) await user.click(screen.getByText('All'))
await user.click(await screen.findByText('Success')) await user.click(await screen.findByText('Success'))
// Check that useSWR was called with updated params // Assert
await waitFor(() => { await waitFor(() => {
const lastCall = mockedUseSWR.mock.calls.at(-1)?.[0] as { params?: Record<string, unknown> } const lastCall = getMockCallParams()
expect(lastCall?.params).toMatchObject({ expect(lastCall?.params).toMatchObject({
status: 'succeeded', status: 'succeeded',
}) })
@ -397,46 +456,46 @@ describe('Logs Container', () => {
}) })
it('should update query when selecting period filter', async () => { it('should update query when selecting period filter', async () => {
// Arrange
const user = userEvent.setup() const user = userEvent.setup()
mockedUseSWR.mockReturnValue({ mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([], 0), data: createMockLogsResponse([], 0),
mutate: vi.fn(), }),
isValidating: false, )
isLoading: false,
error: undefined,
})
render(<Logs {...defaultProps} />) renderWithQueryClient(<Logs {...defaultProps} />)
// Click period filter // Act
await user.click(screen.getByText('appLog.filter.period.last7days')) await user.click(screen.getByText('appLog.filter.period.last7days'))
await user.click(await screen.findByText('appLog.filter.period.allTime')) await user.click(await screen.findByText('appLog.filter.period.allTime'))
// When period is allTime (9), date filters should be removed // Assert
await waitFor(() => { await waitFor(() => {
const lastCall = mockedUseSWR.mock.calls.at(-1)?.[0] as { params?: Record<string, unknown> } const lastCall = getMockCallParams()
expect(lastCall?.params).not.toHaveProperty('created_at__after') expect(lastCall?.params).not.toHaveProperty('created_at__after')
expect(lastCall?.params).not.toHaveProperty('created_at__before') expect(lastCall?.params).not.toHaveProperty('created_at__before')
}) })
}) })
it('should update query when typing keyword', async () => { it('should update query when typing keyword', async () => {
// Arrange
const user = userEvent.setup() const user = userEvent.setup()
mockedUseSWR.mockReturnValue({ mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([], 0), data: createMockLogsResponse([], 0),
mutate: vi.fn(), }),
isValidating: false, )
isLoading: false,
error: undefined,
})
render(<Logs {...defaultProps} />) renderWithQueryClient(<Logs {...defaultProps} />)
// Act
const searchInput = screen.getByPlaceholderText('common.operation.search') const searchInput = screen.getByPlaceholderText('common.operation.search')
await user.type(searchInput, 'test-keyword') await user.type(searchInput, 'test-keyword')
// Assert
await waitFor(() => { await waitFor(() => {
const lastCall = mockedUseSWR.mock.calls.at(-1)?.[0] as { params?: Record<string, unknown> } const lastCall = getMockCallParams()
expect(lastCall?.params).toMatchObject({ expect(lastCall?.params).toMatchObject({
keyword: 'test-keyword', keyword: 'test-keyword',
}) })
@ -449,36 +508,35 @@ describe('Logs Container', () => {
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
describe('Pagination', () => { describe('Pagination', () => {
it('should not render pagination when total is less than limit', () => { it('should not render pagination when total is less than limit', () => {
mockedUseSWR.mockReturnValue({ // Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([createMockWorkflowLog()], 1), data: createMockLogsResponse([createMockWorkflowLog()], 1),
mutate: vi.fn(), }),
isValidating: false, )
isLoading: false,
error: undefined,
})
render(<Logs {...defaultProps} />) // Act
renderWithQueryClient(<Logs {...defaultProps} />)
// Pagination component should not be rendered // Assert
expect(screen.queryByRole('navigation')).not.toBeInTheDocument() expect(screen.queryByRole('navigation')).not.toBeInTheDocument()
}) })
it('should render pagination when total exceeds limit', () => { it('should render pagination when total exceeds limit', () => {
// Arrange
const logs = Array.from({ length: APP_PAGE_LIMIT }, (_, i) => const logs = Array.from({ length: APP_PAGE_LIMIT }, (_, i) =>
createMockWorkflowLog({ id: `log-${i}` })) createMockWorkflowLog({ id: `log-${i}` }))
mockedUseSWR.mockReturnValue({ mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse(logs, APP_PAGE_LIMIT + 10), data: createMockLogsResponse(logs, APP_PAGE_LIMIT + 10),
mutate: vi.fn(), }),
isValidating: false, )
isLoading: false,
error: undefined,
})
render(<Logs {...defaultProps} />) // Act
renderWithQueryClient(<Logs {...defaultProps} />)
// Should show pagination - checking for any pagination-related element // Assert
// The Pagination component renders page controls
expect(screen.getByRole('table')).toBeInTheDocument() expect(screen.getByRole('table')).toBeInTheDocument()
}) })
}) })
@ -488,21 +546,24 @@ describe('Logs Container', () => {
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
describe('List Rendering', () => { describe('List Rendering', () => {
it('should render List component when data is available', () => { it('should render List component when data is available', () => {
mockedUseSWR.mockReturnValue({ // Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([createMockWorkflowLog()], 1), data: createMockLogsResponse([createMockWorkflowLog()], 1),
mutate: vi.fn(), }),
isValidating: false, )
isLoading: false,
error: undefined,
})
render(<Logs {...defaultProps} />) // Act
renderWithQueryClient(<Logs {...defaultProps} />)
// Assert
expect(screen.getByRole('table')).toBeInTheDocument() expect(screen.getByRole('table')).toBeInTheDocument()
}) })
it('should display log data in table', () => { it('should display log data in table', () => {
mockedUseSWR.mockReturnValue({ // Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([ data: createMockLogsResponse([
createMockWorkflowLog({ createMockWorkflowLog({
workflow_run: createMockWorkflowRun({ workflow_run: createMockWorkflowRun({
@ -511,14 +572,13 @@ describe('Logs Container', () => {
}), }),
}), }),
], 1), ], 1),
mutate: vi.fn(), }),
isValidating: false, )
isLoading: false,
error: undefined,
})
render(<Logs {...defaultProps} />) // Act
renderWithQueryClient(<Logs {...defaultProps} />)
// Assert
expect(screen.getByText('Success')).toBeInTheDocument() expect(screen.getByText('Success')).toBeInTheDocument()
expect(screen.getByText('500')).toBeInTheDocument() expect(screen.getByText('500')).toBeInTheDocument()
}) })
@ -541,52 +601,54 @@ describe('Logs Container', () => {
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
describe('Edge Cases', () => { describe('Edge Cases', () => {
it('should handle different app modes', () => { it('should handle different app modes', () => {
mockedUseSWR.mockReturnValue({ // Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([createMockWorkflowLog()], 1), data: createMockLogsResponse([createMockWorkflowLog()], 1),
mutate: vi.fn(), }),
isValidating: false, )
isLoading: false,
error: undefined,
})
const chatApp = createMockApp({ mode: 'advanced-chat' as AppModeEnum }) const chatApp = createMockApp({ mode: 'advanced-chat' as AppModeEnum })
render(<Logs appDetail={chatApp} />) // Act
renderWithQueryClient(<Logs appDetail={chatApp} />)
// Should render without trigger column // Assert
expect(screen.queryByText('appLog.table.header.triggered_from')).not.toBeInTheDocument() expect(screen.queryByText('appLog.table.header.triggered_from')).not.toBeInTheDocument()
}) })
it('should handle error state from useSWR', () => { it('should handle error state from useWorkflowLogs', () => {
mockedUseSWR.mockReturnValue({ // Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: undefined, data: undefined,
mutate: vi.fn(),
isValidating: false,
isLoading: false,
error: new Error('Failed to fetch'), error: new Error('Failed to fetch'),
}) }),
)
const { container } = render(<Logs {...defaultProps} />) // Act
const { container } = renderWithQueryClient(<Logs {...defaultProps} />)
// Should show loading state when data is undefined // Assert - should show loading state when data is undefined
expect(container.querySelector('.spin-animation')).toBeInTheDocument() expect(container.querySelector('.spin-animation')).toBeInTheDocument()
}) })
it('should handle app with different ID', () => { it('should handle app with different ID', () => {
mockedUseSWR.mockReturnValue({ // Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([], 0), data: createMockLogsResponse([], 0),
mutate: vi.fn(), }),
isValidating: false, )
isLoading: false,
error: undefined,
})
const customApp = createMockApp({ id: 'custom-app-123' }) const customApp = createMockApp({ id: 'custom-app-123' })
render(<Logs appDetail={customApp} />) // Act
renderWithQueryClient(<Logs appDetail={customApp} />)
const keyArg = mockedUseSWR.mock.calls.at(-1)?.[0] as { url: string } // Assert
expect(keyArg?.url).toBe('/apps/custom-app-123/workflow-app-logs') const callArg = getMockCallParams()
expect(callArg?.appId).toBe('custom-app-123')
}) })
}) })
}) })

View File

@ -9,13 +9,12 @@ import { omit } from 'lodash-es'
import * as React from 'react' import * as React from 'react'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import EmptyElement from '@/app/components/app/log/empty-element' import EmptyElement from '@/app/components/app/log/empty-element'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import Pagination from '@/app/components/base/pagination' import Pagination from '@/app/components/base/pagination'
import { APP_PAGE_LIMIT } from '@/config' import { APP_PAGE_LIMIT } from '@/config'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { fetchWorkflowLogs } from '@/service/log' import { useWorkflowLogs } from '@/service/use-log'
import Filter, { TIME_PERIOD_MAPPING } from './filter' import Filter, { TIME_PERIOD_MAPPING } from './filter'
import List from './list' import List from './list'
@ -55,10 +54,10 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
...omit(debouncedQueryParams, ['period', 'status']), ...omit(debouncedQueryParams, ['period', 'status']),
} }
const { data: workflowLogs, mutate } = useSWR({ const { data: workflowLogs, refetch: mutate } = useWorkflowLogs({
url: `/apps/${appDetail.id}/workflow-app-logs`, appId: appDetail.id,
params: query, params: query,
}, fetchWorkflowLogs) })
const total = workflowLogs?.total const total = workflowLogs?.total
return ( return (

View File

@ -36,7 +36,8 @@ export const SkeletonPoint: FC<SkeletonProps> = (props) => {
<div className={cn('text-xs font-medium text-text-quaternary', className)} {...rest}>·</div> <div className={cn('text-xs font-medium text-text-quaternary', className)} {...rest}>·</div>
) )
} }
/** Usage /**
* Usage
* <SkeletonContainer> * <SkeletonContainer>
* <SkeletonRow> * <SkeletonRow>
* <SkeletonRectangle className="w-96" /> * <SkeletonRectangle className="w-96" />

View File

@ -6,14 +6,16 @@ type ModelDisplayProps = {
} }
const ModelDisplay = ({ currentModel, modelId }: ModelDisplayProps) => { const ModelDisplay = ({ currentModel, modelId }: ModelDisplayProps) => {
return currentModel ? ( return currentModel
? (
<ModelName <ModelName
className="flex grow items-center gap-1 px-1 py-[3px]" className="flex grow items-center gap-1 px-1 py-[3px]"
modelItem={currentModel} modelItem={currentModel}
showMode showMode
showFeatures showFeatures
/> />
) : ( )
: (
<div className="flex grow items-center gap-1 truncate px-1 py-[3px] opacity-50"> <div className="flex grow items-center gap-1 truncate px-1 py-[3px] opacity-50">
<div className="system-sm-regular overflow-hidden text-ellipsis text-components-input-text-filled"> <div className="system-sm-regular overflow-hidden text-ellipsis text-components-input-text-filled">
{modelId} {modelId}

View File

@ -6,21 +6,6 @@ import type {
} from '@/app/components/workflow/types' } from '@/app/components/workflow/types'
import type { VisionFile } from '@/types/app' import type { VisionFile } from '@/types/app'
// Log type contains key:string conversation_id:string created_at:string question:string answer:string
export type Conversation = {
id: string
key: string
conversationId: string
question: string
answer: string
userRate: number
adminRate: number
}
export type ConversationListResponse = {
logs: Conversation[]
}
export const CompletionParams = ['temperature', 'top_p', 'presence_penalty', 'max_token', 'stop', 'frequency_penalty'] as const export const CompletionParams = ['temperature', 'top_p', 'presence_penalty', 'max_token', 'stop', 'frequency_penalty'] as const
export type CompletionParamType = typeof CompletionParams[number] export type CompletionParamType = typeof CompletionParams[number]

View File

@ -38,6 +38,7 @@
"gen:i18n-types": "node ./i18n-config/generate-i18n-types.js", "gen:i18n-types": "node ./i18n-config/generate-i18n-types.js",
"check:i18n-types": "node ./i18n-config/check-i18n-sync.js", "check:i18n-types": "node ./i18n-config/check-i18n-sync.js",
"test": "vitest run", "test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest --watch", "test:watch": "vitest --watch",
"analyze-component": "node testing/analyze-component.js", "analyze-component": "node testing/analyze-component.js",
"storybook": "storybook dev -p 6006", "storybook": "storybook dev -p 6006",

View File

@ -1,80 +1,38 @@
import type { Fetcher } from 'swr'
import type { import type {
AgentLogDetailRequest, AgentLogDetailRequest,
AgentLogDetailResponse, AgentLogDetailResponse,
AnnotationsCountResponse,
ChatConversationFullDetailResponse,
ChatConversationsRequest,
ChatConversationsResponse,
ChatMessagesRequest, ChatMessagesRequest,
ChatMessagesResponse, ChatMessagesResponse,
CompletionConversationFullDetailResponse,
CompletionConversationsRequest,
CompletionConversationsResponse,
ConversationListResponse,
LogMessageAnnotationsRequest, LogMessageAnnotationsRequest,
LogMessageAnnotationsResponse, LogMessageAnnotationsResponse,
LogMessageFeedbacksRequest, LogMessageFeedbacksRequest,
LogMessageFeedbacksResponse, LogMessageFeedbacksResponse,
WorkflowLogsResponse,
WorkflowRunDetailResponse, WorkflowRunDetailResponse,
} from '@/models/log' } from '@/models/log'
import type { NodeTracingListResponse } from '@/types/workflow' import type { NodeTracingListResponse } from '@/types/workflow'
import { get, post } from './base' import { get, post } from './base'
export const fetchConversationList: Fetcher<ConversationListResponse, { name: string, appId: string, params?: Record<string, any> }> = ({ appId, params }) => {
return get<ConversationListResponse>(`/console/api/apps/${appId}/messages`, params)
}
// (Text Generation Application) Session List
export const fetchCompletionConversations: Fetcher<CompletionConversationsResponse, { url: string, params?: CompletionConversationsRequest }> = ({ url, params }) => {
return get<CompletionConversationsResponse>(url, { params })
}
// (Text Generation Application) Session Detail
export const fetchCompletionConversationDetail: Fetcher<CompletionConversationFullDetailResponse, { url: string }> = ({ url }) => {
return get<CompletionConversationFullDetailResponse>(url, {})
}
// (Chat Application) Session List
export const fetchChatConversations: Fetcher<ChatConversationsResponse, { url: string, params?: ChatConversationsRequest }> = ({ url, params }) => {
return get<ChatConversationsResponse>(url, { params })
}
// (Chat Application) Session Detail
export const fetchChatConversationDetail: Fetcher<ChatConversationFullDetailResponse, { url: string }> = ({ url }) => {
return get<ChatConversationFullDetailResponse>(url, {})
}
// (Chat Application) Message list in one session // (Chat Application) Message list in one session
export const fetchChatMessages: Fetcher<ChatMessagesResponse, { url: string, params: ChatMessagesRequest }> = ({ url, params }) => { export const fetchChatMessages = ({ url, params }: { url: string, params: ChatMessagesRequest }): Promise<ChatMessagesResponse> => {
return get<ChatMessagesResponse>(url, { params }) return get<ChatMessagesResponse>(url, { params })
} }
export const updateLogMessageFeedbacks: Fetcher<LogMessageFeedbacksResponse, { url: string, body: LogMessageFeedbacksRequest }> = ({ url, body }) => { export const updateLogMessageFeedbacks = ({ url, body }: { url: string, body: LogMessageFeedbacksRequest }): Promise<LogMessageFeedbacksResponse> => {
return post<LogMessageFeedbacksResponse>(url, { body }) return post<LogMessageFeedbacksResponse>(url, { body })
} }
export const updateLogMessageAnnotations: Fetcher<LogMessageAnnotationsResponse, { url: string, body: LogMessageAnnotationsRequest }> = ({ url, body }) => { export const updateLogMessageAnnotations = ({ url, body }: { url: string, body: LogMessageAnnotationsRequest }): Promise<LogMessageAnnotationsResponse> => {
return post<LogMessageAnnotationsResponse>(url, { body }) return post<LogMessageAnnotationsResponse>(url, { body })
} }
export const fetchAnnotationsCount: Fetcher<AnnotationsCountResponse, { url: string }> = ({ url }) => { export const fetchRunDetail = (url: string): Promise<WorkflowRunDetailResponse> => {
return get<AnnotationsCountResponse>(url)
}
export const fetchWorkflowLogs: Fetcher<WorkflowLogsResponse, { url: string, params: Record<string, any> }> = ({ url, params }) => {
return get<WorkflowLogsResponse>(url, { params })
}
export const fetchRunDetail = (url: string) => {
return get<WorkflowRunDetailResponse>(url) return get<WorkflowRunDetailResponse>(url)
} }
export const fetchTracingList: Fetcher<NodeTracingListResponse, { url: string }> = ({ url }) => { export const fetchTracingList = ({ url }: { url: string }): Promise<NodeTracingListResponse> => {
return get<NodeTracingListResponse>(url) return get<NodeTracingListResponse>(url)
} }
export const fetchAgentLogDetail = ({ appID, params }: { appID: string, params: AgentLogDetailRequest }) => { export const fetchAgentLogDetail = ({ appID, params }: { appID: string, params: AgentLogDetailRequest }): Promise<AgentLogDetailResponse> => {
return get<AgentLogDetailResponse>(`/apps/${appID}/agent/logs`, { params }) return get<AgentLogDetailResponse>(`/apps/${appID}/agent/logs`, { params })
} }

89
web/service/use-log.ts Normal file
View File

@ -0,0 +1,89 @@
import type {
AnnotationsCountResponse,
ChatConversationFullDetailResponse,
ChatConversationsRequest,
ChatConversationsResponse,
CompletionConversationFullDetailResponse,
CompletionConversationsRequest,
CompletionConversationsResponse,
WorkflowLogsResponse,
} from '@/models/log'
import { useQuery } from '@tanstack/react-query'
import { get } from './base'
const NAME_SPACE = 'log'
// ============ Annotations Count ============
export const useAnnotationsCount = (appId: string) => {
return useQuery<AnnotationsCountResponse>({
queryKey: [NAME_SPACE, 'annotations-count', appId],
queryFn: () => get<AnnotationsCountResponse>(`/apps/${appId}/annotations/count`),
enabled: !!appId,
})
}
// ============ Chat Conversations ============
type ChatConversationsParams = {
appId: string
params?: Partial<ChatConversationsRequest>
}
export const useChatConversations = ({ appId, params }: ChatConversationsParams) => {
return useQuery<ChatConversationsResponse>({
queryKey: [NAME_SPACE, 'chat-conversations', appId, params],
queryFn: () => get<ChatConversationsResponse>(`/apps/${appId}/chat-conversations`, { params }),
enabled: !!appId,
})
}
// ============ Completion Conversations ============
type CompletionConversationsParams = {
appId: string
params?: Partial<CompletionConversationsRequest>
}
export const useCompletionConversations = ({ appId, params }: CompletionConversationsParams) => {
return useQuery<CompletionConversationsResponse>({
queryKey: [NAME_SPACE, 'completion-conversations', appId, params],
queryFn: () => get<CompletionConversationsResponse>(`/apps/${appId}/completion-conversations`, { params }),
enabled: !!appId,
})
}
// ============ Chat Conversation Detail ============
export const useChatConversationDetail = (appId?: string, conversationId?: string) => {
return useQuery<ChatConversationFullDetailResponse>({
queryKey: [NAME_SPACE, 'chat-conversation-detail', appId, conversationId],
queryFn: () => get<ChatConversationFullDetailResponse>(`/apps/${appId}/chat-conversations/${conversationId}`),
enabled: !!appId && !!conversationId,
})
}
// ============ Completion Conversation Detail ============
export const useCompletionConversationDetail = (appId?: string, conversationId?: string) => {
return useQuery<CompletionConversationFullDetailResponse>({
queryKey: [NAME_SPACE, 'completion-conversation-detail', appId, conversationId],
queryFn: () => get<CompletionConversationFullDetailResponse>(`/apps/${appId}/completion-conversations/${conversationId}`),
enabled: !!appId && !!conversationId,
})
}
// ============ Workflow Logs ============
type WorkflowLogsParams = {
appId: string
params?: Record<string, string | number | boolean | undefined>
}
export const useWorkflowLogs = ({ appId, params }: WorkflowLogsParams) => {
return useQuery<WorkflowLogsResponse>({
queryKey: [NAME_SPACE, 'workflow-logs', appId, params],
queryFn: () => get<WorkflowLogsResponse>(`/apps/${appId}/workflow-app-logs`, { params }),
enabled: !!appId,
})
}

View File

@ -21,10 +21,10 @@ pnpm test
pnpm test:watch pnpm test:watch
# Generate coverage report # Generate coverage report
pnpm test -- --coverage pnpm test:coverage
# Run specific file # Run specific file
pnpm test -- path/to/file.spec.tsx pnpm test path/to/file.spec.tsx
``` ```
## Project Test Setup ## Project Test Setup