mirror of https://github.com/langgenius/dify.git
refactor(web): migrate log service to TanStack Query (#30065)
This commit is contained in:
parent
dcde854c5e
commit
b2b7e82e28
|
|
@ -49,10 +49,10 @@ pnpm test
|
|||
pnpm test:watch
|
||||
|
||||
# Run specific file
|
||||
pnpm test -- path/to/file.spec.tsx
|
||||
pnpm test path/to/file.spec.tsx
|
||||
|
||||
# Generate coverage report
|
||||
pnpm test -- --coverage
|
||||
pnpm test:coverage
|
||||
|
||||
# Analyze component complexity
|
||||
pnpm analyze-component <path>
|
||||
|
|
@ -155,7 +155,7 @@ describe('ComponentName', () => {
|
|||
For each file:
|
||||
┌────────────────────────────────────────┐
|
||||
│ 1. Write test │
|
||||
│ 2. Run: pnpm test -- <file>.spec.tsx │
|
||||
│ 2. Run: pnpm test <file>.spec.tsx │
|
||||
│ 3. PASS? → Mark complete, next file │
|
||||
│ FAIL? → Fix first, then continue │
|
||||
└────────────────────────────────────────┘
|
||||
|
|
|
|||
|
|
@ -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
|
||||
describe('Async Operations', () => {
|
||||
|
|
|
|||
|
|
@ -114,15 +114,15 @@ For the current file being tested:
|
|||
|
||||
**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
|
||||
- [ ] Mark file as complete in todo list
|
||||
- [ ] Only then proceed to next file
|
||||
|
||||
### After All Files Complete
|
||||
|
||||
- [ ] Run full directory test: `pnpm test -- path/to/directory/`
|
||||
- [ ] Check coverage report: `pnpm test -- --coverage`
|
||||
- [ ] Run full directory test: `pnpm test path/to/directory/`
|
||||
- [ ] Check coverage report: `pnpm test:coverage`
|
||||
- [ ] Run `pnpm lint:fix` on all test files
|
||||
- [ ] Run `pnpm type-check:tsgo`
|
||||
|
||||
|
|
@ -186,16 +186,16 @@ Always test these scenarios:
|
|||
|
||||
```bash
|
||||
# Run specific test
|
||||
pnpm test -- path/to/file.spec.tsx
|
||||
pnpm test path/to/file.spec.tsx
|
||||
|
||||
# Run with coverage
|
||||
pnpm test -- --coverage path/to/file.spec.tsx
|
||||
pnpm test:coverage path/to/file.spec.tsx
|
||||
|
||||
# Watch mode
|
||||
pnpm test:watch -- path/to/file.spec.tsx
|
||||
pnpm test:watch path/to/file.spec.tsx
|
||||
|
||||
# Update snapshots (use sparingly)
|
||||
pnpm test -- -u path/to/file.spec.tsx
|
||||
pnpm test -u path/to/file.spec.tsx
|
||||
|
||||
# Analyze component
|
||||
pnpm analyze-component path/to/component.tsx
|
||||
|
|
|
|||
|
|
@ -242,32 +242,9 @@ describe('Component with Context', () => {
|
|||
})
|
||||
```
|
||||
|
||||
### 7. SWR / React Query
|
||||
### 7. React Query
|
||||
|
||||
```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'
|
||||
|
||||
const createTestQueryClient = () => new QueryClient({
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ When testing a **single component, hook, or utility**:
|
|||
2. Run `pnpm analyze-component <path>` (if available)
|
||||
3. Check complexity score and features detected
|
||||
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
|
||||
7. Verify coverage meets goals (100% function, >95% branch)
|
||||
```
|
||||
|
|
@ -80,7 +80,7 @@ Process files in this recommended order:
|
|||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 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 │
|
||||
│ 4. If PASS → Mark complete in todo list │
|
||||
│ 5. ONLY THEN proceed to next file │
|
||||
|
|
@ -95,10 +95,10 @@ After all individual tests pass:
|
|||
|
||||
```bash
|
||||
# Run all tests in the directory together
|
||||
pnpm test -- path/to/directory/
|
||||
pnpm test path/to/directory/
|
||||
|
||||
# Check coverage
|
||||
pnpm test -- --coverage path/to/directory/
|
||||
pnpm test:coverage path/to/directory/
|
||||
```
|
||||
|
||||
## Component Complexity Guidelines
|
||||
|
|
@ -201,9 +201,9 @@ Run pnpm test ← Multiple failures, hard to debug
|
|||
```
|
||||
# GOOD: Incremental with verification
|
||||
Write component-a.spec.tsx
|
||||
Run pnpm test -- component-a.spec.tsx ✅
|
||||
Run pnpm test component-a.spec.tsx ✅
|
||||
Write component-b.spec.tsx
|
||||
Run pnpm test -- component-b.spec.tsx ✅
|
||||
Run pnpm test component-b.spec.tsx ✅
|
||||
...continue...
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ jobs:
|
|||
run: pnpm run check:i18n-types
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test --coverage
|
||||
run: pnpm test:coverage
|
||||
|
||||
- name: Coverage Summary
|
||||
if: always()
|
||||
|
|
|
|||
|
|
@ -1,55 +1,209 @@
|
|||
import type { UseQueryResult } from '@tanstack/react-query'
|
||||
import type { Mock } from 'vitest'
|
||||
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 * as React from 'react'
|
||||
import useSWR from 'swr'
|
||||
import * as useLogModule from '@/service/use-log'
|
||||
import Filter from './filter'
|
||||
|
||||
vi.mock('swr', () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/service/use-log')
|
||||
|
||||
vi.mock('@/service/log', () => ({
|
||||
fetchAnnotationsCount: vi.fn(),
|
||||
}))
|
||||
const mockUseAnnotationsCount = useLogModule.useAnnotationsCount as Mock
|
||||
|
||||
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', () => {
|
||||
const appId = 'app-1'
|
||||
const childContent = 'child-content'
|
||||
const defaultQueryParams: QueryParam = { keyword: '' }
|
||||
|
||||
beforeEach(() => {
|
||||
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
|
||||
appId={appId}
|
||||
queryParams={{ keyword: '' }}
|
||||
queryParams={defaultQueryParams}
|
||||
setQueryParams={vi.fn()}
|
||||
>
|
||||
<div>{childContent}</div>
|
||||
</Filter>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(container.firstChild).toBeNull()
|
||||
expect(mockUseSWR).toHaveBeenCalledWith(
|
||||
{ url: `/apps/${appId}/annotations/count` },
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
|
||||
it('should propagate keyword changes and clearing behavior', () => {
|
||||
mockUseSWR.mockReturnValue({ data: { total: 20 } })
|
||||
const queryParams: QueryParam = { keyword: 'prefill' }
|
||||
it('should render nothing when data is undefined', () => {
|
||||
// Arrange
|
||||
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 { container } = render(
|
||||
renderWithQueryClient(
|
||||
<Filter
|
||||
appId={appId}
|
||||
queryParams={queryParams}
|
||||
|
|
@ -59,14 +213,120 @@ describe('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' } })
|
||||
|
||||
// Assert
|
||||
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)
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@
|
|||
import type { FC } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useSWR from 'swr'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { fetchAnnotationsCount } from '@/service/log'
|
||||
import { useAnnotationsCount } from '@/service/use-log'
|
||||
|
||||
export type QueryParam = {
|
||||
keyword?: string
|
||||
|
|
@ -23,10 +22,9 @@ const Filter: FC<IFilterProps> = ({
|
|||
setQueryParams,
|
||||
children,
|
||||
}) => {
|
||||
// TODO: change fetch list api
|
||||
const { data } = useSWR({ url: `/apps/${appId}/annotations/count` }, fetchAnnotationsCount)
|
||||
const { data, isLoading } = useAnnotationsCount(appId)
|
||||
const { t } = useTranslation()
|
||||
if (!data)
|
||||
if (isLoading || !data)
|
||||
return null
|
||||
return (
|
||||
<div className="mb-2 flex flex-row flex-wrap items-center justify-between gap-2">
|
||||
|
|
|
|||
|
|
@ -6,11 +6,10 @@ import dayjs from 'dayjs'
|
|||
import quarterOfYear from 'dayjs/plugin/quarterOfYear'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useSWR from 'swr'
|
||||
import Chip from '@/app/components/base/chip'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Sort from '@/app/components/base/sort'
|
||||
import { fetchAnnotationsCount } from '@/service/log'
|
||||
import { useAnnotationsCount } from '@/service/use-log'
|
||||
|
||||
dayjs.extend(quarterOfYear)
|
||||
|
||||
|
|
@ -36,9 +35,9 @@ type 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()
|
||||
if (!data)
|
||||
if (isLoading || !data)
|
||||
return null
|
||||
return (
|
||||
<div className="mb-2 flex flex-row flex-wrap items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -8,11 +8,10 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
|||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useSWR from 'swr'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Pagination from '@/app/components/base/pagination'
|
||||
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 EmptyElement from './empty-element'
|
||||
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
|
||||
const { data: chatConversations, mutate: mutateChatList } = useSWR(() => isChatMode
|
||||
? {
|
||||
url: `/apps/${appDetail.id}/chat-conversations`,
|
||||
const { data: chatConversations, refetch: mutateChatList } = useChatConversations({
|
||||
appId: isChatMode ? appDetail.id : '',
|
||||
params: query,
|
||||
}
|
||||
: null, fetchChatConversations)
|
||||
})
|
||||
|
||||
const { data: completionConversations, mutate: mutateCompletionList } = useSWR(() => !isChatMode
|
||||
? {
|
||||
url: `/apps/${appDetail.id}/completion-conversations`,
|
||||
const { data: completionConversations, refetch: mutateCompletionList } = useCompletionConversations({
|
||||
appId: !isChatMode ? appDetail.id : '',
|
||||
params: query,
|
||||
}
|
||||
: null, fetchCompletionConversations)
|
||||
})
|
||||
|
||||
const total = isChatMode ? chatConversations?.total : completionConversations?.total
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
|||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useSWR from 'swr'
|
||||
import { createContext, useContext } from 'use-context-selector'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
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 useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
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 { cn } from '@/utils/classnames'
|
||||
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 }) => {
|
||||
// Text Generator App Session Details Including Message List
|
||||
const detailParams = ({ url: `/apps/${appId}/completion-conversations/${conversationId}` })
|
||||
const { data: conversationDetail, mutate: conversationDetailMutate } = useSWR(() => (appId && conversationId) ? detailParams : null, fetchCompletionConversationDetail)
|
||||
const { data: conversationDetail, refetch: conversationDetailMutate } = useCompletionConversationDetail(appId, conversationId)
|
||||
const { notify } = useContext(ToastContext)
|
||||
const { t } = useTranslation()
|
||||
|
||||
|
|
@ -875,8 +874,7 @@ const CompletionConversationDetailComp: FC<{ appId?: string, conversationId?: st
|
|||
* Chat App Conversation Detail Component
|
||||
*/
|
||||
const ChatConversationDetailComp: FC<{ appId?: string, conversationId?: string }> = ({ appId, conversationId }) => {
|
||||
const detailParams = { url: `/apps/${appId}/chat-conversations/${conversationId}` }
|
||||
const { data: conversationDetail } = useSWR(() => (appId && conversationId) ? detailParams : null, fetchChatConversationDetail)
|
||||
const { data: conversationDetail } = useChatConversationDetail(appId, conversationId)
|
||||
const { notify } = useContext(ToastContext)
|
||||
const { t } = useTranslation()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import type { MockedFunction } from 'vitest'
|
||||
import type { UseQueryResult } from '@tanstack/react-query'
|
||||
/**
|
||||
* Logs Container Component Tests
|
||||
*
|
||||
* Tests the main Logs container component which:
|
||||
* - Fetches workflow logs via useSWR
|
||||
* - Fetches workflow logs via TanStack Query
|
||||
* - Manages query parameters (status, period, keyword)
|
||||
* - Handles pagination
|
||||
* - Renders Filter, List, and Empty states
|
||||
|
|
@ -15,14 +15,16 @@ import type { MockedFunction } from 'vitest'
|
|||
* - trigger-by-display.spec.tsx
|
||||
*/
|
||||
|
||||
import type { MockedFunction } from 'vitest'
|
||||
import type { ILogsProps } from './index'
|
||||
import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log'
|
||||
import type { App, AppIconType, AppModeEnum } from '@/types/app'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import useSWR from 'swr'
|
||||
import { APP_PAGE_LIMIT } from '@/config'
|
||||
import { WorkflowRunTriggeredFrom } from '@/models/log'
|
||||
import * as useLogModule from '@/service/use-log'
|
||||
import { TIME_PERIOD_MAPPING } from './filter'
|
||||
import Logs from './index'
|
||||
|
||||
|
|
@ -30,7 +32,7 @@ import Logs from './index'
|
|||
// Mocks
|
||||
// ============================================================================
|
||||
|
||||
vi.mock('swr')
|
||||
vi.mock('@/service/use-log')
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useDebounce: <T,>(value: T) => value,
|
||||
|
|
@ -72,10 +74,6 @@ vi.mock('@/app/components/base/amplitude/utils', () => ({
|
|||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/log', () => ({
|
||||
fetchWorkflowLogs: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
__esModule: true,
|
||||
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
|
||||
vi.mock('@/app/components/workflow/context', () => ({
|
||||
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
|
||||
|
|
@ -195,6 +231,20 @@ const createMockLogsResponse = (
|
|||
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
|
||||
// ============================================================================
|
||||
|
|
@ -213,45 +263,48 @@ describe('Logs Container', () => {
|
|||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
mockedUseSWR.mockReturnValue({
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
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()
|
||||
})
|
||||
|
||||
it('should render title and subtitle', () => {
|
||||
mockedUseSWR.mockReturnValue({
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
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.workflowSubtitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Filter component', () => {
|
||||
mockedUseSWR.mockReturnValue({
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
@ -261,30 +314,33 @@ describe('Logs Container', () => {
|
|||
// --------------------------------------------------------------------------
|
||||
describe('Loading State', () => {
|
||||
it('should show loading spinner when data is undefined', () => {
|
||||
mockedUseSWR.mockReturnValue({
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
data: undefined,
|
||||
mutate: vi.fn(),
|
||||
isValidating: true,
|
||||
isLoading: true,
|
||||
error: undefined,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const { container } = render(<Logs {...defaultProps} />)
|
||||
// Act
|
||||
const { container } = renderWithQueryClient(<Logs {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show loading spinner when data is available', () => {
|
||||
mockedUseSWR.mockReturnValue({
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
@ -294,16 +350,17 @@ describe('Logs Container', () => {
|
|||
// --------------------------------------------------------------------------
|
||||
describe('Empty State', () => {
|
||||
it('should render empty element when total is 0', () => {
|
||||
mockedUseSWR.mockReturnValue({
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
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.queryByRole('table')).not.toBeInTheDocument()
|
||||
})
|
||||
|
|
@ -313,20 +370,21 @@ describe('Logs Container', () => {
|
|||
// Data Fetching Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Data Fetching', () => {
|
||||
it('should call useSWR with correct URL and default params', () => {
|
||||
mockedUseSWR.mockReturnValue({
|
||||
it('should call useWorkflowLogs with correct appId and default params', () => {
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
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> }
|
||||
expect(keyArg).toMatchObject({
|
||||
url: `/apps/${defaultProps.appDetail.id}/workflow-app-logs`,
|
||||
// Assert
|
||||
const callArg = getMockCallParams()
|
||||
expect(callArg).toMatchObject({
|
||||
appId: defaultProps.appDetail.id,
|
||||
params: expect.objectContaining({
|
||||
page: 1,
|
||||
detail: true,
|
||||
|
|
@ -336,34 +394,36 @@ describe('Logs Container', () => {
|
|||
})
|
||||
|
||||
it('should include date filters for non-allTime periods', () => {
|
||||
mockedUseSWR.mockReturnValue({
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
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> }
|
||||
expect(keyArg?.params).toHaveProperty('created_at__after')
|
||||
expect(keyArg?.params).toHaveProperty('created_at__before')
|
||||
// Assert
|
||||
const callArg = getMockCallParams()
|
||||
expect(callArg?.params).toHaveProperty('created_at__after')
|
||||
expect(callArg?.params).toHaveProperty('created_at__before')
|
||||
})
|
||||
|
||||
it('should not include status param when status is all', () => {
|
||||
mockedUseSWR.mockReturnValue({
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
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> }
|
||||
expect(keyArg?.params).not.toHaveProperty('status')
|
||||
// Assert
|
||||
const callArg = getMockCallParams()
|
||||
expect(callArg?.params).not.toHaveProperty('status')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -372,24 +432,23 @@ describe('Logs Container', () => {
|
|||
// --------------------------------------------------------------------------
|
||||
describe('Filter Integration', () => {
|
||||
it('should update query when selecting status filter', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
mockedUseSWR.mockReturnValue({
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
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(await screen.findByText('Success'))
|
||||
|
||||
// Check that useSWR was called with updated params
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const lastCall = mockedUseSWR.mock.calls.at(-1)?.[0] as { params?: Record<string, unknown> }
|
||||
const lastCall = getMockCallParams()
|
||||
expect(lastCall?.params).toMatchObject({
|
||||
status: 'succeeded',
|
||||
})
|
||||
|
|
@ -397,46 +456,46 @@ describe('Logs Container', () => {
|
|||
})
|
||||
|
||||
it('should update query when selecting period filter', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
mockedUseSWR.mockReturnValue({
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
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(await screen.findByText('appLog.filter.period.allTime'))
|
||||
|
||||
// When period is allTime (9), date filters should be removed
|
||||
// Assert
|
||||
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__before')
|
||||
})
|
||||
})
|
||||
|
||||
it('should update query when typing keyword', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
mockedUseSWR.mockReturnValue({
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
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')
|
||||
await user.type(searchInput, 'test-keyword')
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const lastCall = mockedUseSWR.mock.calls.at(-1)?.[0] as { params?: Record<string, unknown> }
|
||||
const lastCall = getMockCallParams()
|
||||
expect(lastCall?.params).toMatchObject({
|
||||
keyword: 'test-keyword',
|
||||
})
|
||||
|
|
@ -449,36 +508,35 @@ describe('Logs Container', () => {
|
|||
// --------------------------------------------------------------------------
|
||||
describe('Pagination', () => {
|
||||
it('should not render pagination when total is less than limit', () => {
|
||||
mockedUseSWR.mockReturnValue({
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
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()
|
||||
})
|
||||
|
||||
it('should render pagination when total exceeds limit', () => {
|
||||
// Arrange
|
||||
const logs = Array.from({ length: APP_PAGE_LIMIT }, (_, i) =>
|
||||
createMockWorkflowLog({ id: `log-${i}` }))
|
||||
|
||||
mockedUseSWR.mockReturnValue({
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
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
|
||||
// The Pagination component renders page controls
|
||||
// Assert
|
||||
expect(screen.getByRole('table')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -488,21 +546,24 @@ describe('Logs Container', () => {
|
|||
// --------------------------------------------------------------------------
|
||||
describe('List Rendering', () => {
|
||||
it('should render List component when data is available', () => {
|
||||
mockedUseSWR.mockReturnValue({
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
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()
|
||||
})
|
||||
|
||||
it('should display log data in table', () => {
|
||||
mockedUseSWR.mockReturnValue({
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
data: createMockLogsResponse([
|
||||
createMockWorkflowLog({
|
||||
workflow_run: createMockWorkflowRun({
|
||||
|
|
@ -511,14 +572,13 @@ describe('Logs Container', () => {
|
|||
}),
|
||||
}),
|
||||
], 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('500')).toBeInTheDocument()
|
||||
})
|
||||
|
|
@ -541,52 +601,54 @@ describe('Logs Container', () => {
|
|||
// --------------------------------------------------------------------------
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle different app modes', () => {
|
||||
mockedUseSWR.mockReturnValue({
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
data: createMockLogsResponse([createMockWorkflowLog()], 1),
|
||||
mutate: vi.fn(),
|
||||
isValidating: false,
|
||||
isLoading: false,
|
||||
error: undefined,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
it('should handle error state from useSWR', () => {
|
||||
mockedUseSWR.mockReturnValue({
|
||||
it('should handle error state from useWorkflowLogs', () => {
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
data: undefined,
|
||||
mutate: vi.fn(),
|
||||
isValidating: false,
|
||||
isLoading: false,
|
||||
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()
|
||||
})
|
||||
|
||||
it('should handle app with different ID', () => {
|
||||
mockedUseSWR.mockReturnValue({
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
data: createMockLogsResponse([], 0),
|
||||
mutate: vi.fn(),
|
||||
isValidating: false,
|
||||
isLoading: false,
|
||||
error: undefined,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
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 }
|
||||
expect(keyArg?.url).toBe('/apps/custom-app-123/workflow-app-logs')
|
||||
// Assert
|
||||
const callArg = getMockCallParams()
|
||||
expect(callArg?.appId).toBe('custom-app-123')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,13 +9,12 @@ import { omit } from 'lodash-es'
|
|||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useSWR from 'swr'
|
||||
import EmptyElement from '@/app/components/app/log/empty-element'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Pagination from '@/app/components/base/pagination'
|
||||
import { APP_PAGE_LIMIT } from '@/config'
|
||||
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 List from './list'
|
||||
|
||||
|
|
@ -55,10 +54,10 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
|||
...omit(debouncedQueryParams, ['period', 'status']),
|
||||
}
|
||||
|
||||
const { data: workflowLogs, mutate } = useSWR({
|
||||
url: `/apps/${appDetail.id}/workflow-app-logs`,
|
||||
const { data: workflowLogs, refetch: mutate } = useWorkflowLogs({
|
||||
appId: appDetail.id,
|
||||
params: query,
|
||||
}, fetchWorkflowLogs)
|
||||
})
|
||||
const total = workflowLogs?.total
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@ export const SkeletonPoint: FC<SkeletonProps> = (props) => {
|
|||
<div className={cn('text-xs font-medium text-text-quaternary', className)} {...rest}>·</div>
|
||||
)
|
||||
}
|
||||
/** Usage
|
||||
/**
|
||||
* Usage
|
||||
* <SkeletonContainer>
|
||||
* <SkeletonRow>
|
||||
* <SkeletonRectangle className="w-96" />
|
||||
|
|
|
|||
|
|
@ -6,14 +6,16 @@ type ModelDisplayProps = {
|
|||
}
|
||||
|
||||
const ModelDisplay = ({ currentModel, modelId }: ModelDisplayProps) => {
|
||||
return currentModel ? (
|
||||
return currentModel
|
||||
? (
|
||||
<ModelName
|
||||
className="flex grow items-center gap-1 px-1 py-[3px]"
|
||||
modelItem={currentModel}
|
||||
showMode
|
||||
showFeatures
|
||||
/>
|
||||
) : (
|
||||
)
|
||||
: (
|
||||
<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">
|
||||
{modelId}
|
||||
|
|
|
|||
|
|
@ -6,21 +6,6 @@ import type {
|
|||
} from '@/app/components/workflow/types'
|
||||
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 type CompletionParamType = typeof CompletionParams[number]
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@
|
|||
"gen:i18n-types": "node ./i18n-config/generate-i18n-types.js",
|
||||
"check:i18n-types": "node ./i18n-config/check-i18n-sync.js",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:watch": "vitest --watch",
|
||||
"analyze-component": "node testing/analyze-component.js",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
|
|
|
|||
|
|
@ -1,80 +1,38 @@
|
|||
import type { Fetcher } from 'swr'
|
||||
import type {
|
||||
AgentLogDetailRequest,
|
||||
AgentLogDetailResponse,
|
||||
AnnotationsCountResponse,
|
||||
ChatConversationFullDetailResponse,
|
||||
ChatConversationsRequest,
|
||||
ChatConversationsResponse,
|
||||
ChatMessagesRequest,
|
||||
ChatMessagesResponse,
|
||||
CompletionConversationFullDetailResponse,
|
||||
CompletionConversationsRequest,
|
||||
CompletionConversationsResponse,
|
||||
ConversationListResponse,
|
||||
LogMessageAnnotationsRequest,
|
||||
LogMessageAnnotationsResponse,
|
||||
LogMessageFeedbacksRequest,
|
||||
LogMessageFeedbacksResponse,
|
||||
WorkflowLogsResponse,
|
||||
WorkflowRunDetailResponse,
|
||||
} from '@/models/log'
|
||||
import type { NodeTracingListResponse } from '@/types/workflow'
|
||||
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
|
||||
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 })
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
export const fetchAnnotationsCount: Fetcher<AnnotationsCountResponse, { url: string }> = ({ url }) => {
|
||||
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) => {
|
||||
export const fetchRunDetail = (url: string): Promise<WorkflowRunDetailResponse> => {
|
||||
return get<WorkflowRunDetailResponse>(url)
|
||||
}
|
||||
|
||||
export const fetchTracingList: Fetcher<NodeTracingListResponse, { url: string }> = ({ url }) => {
|
||||
export const fetchTracingList = ({ url }: { url: string }): Promise<NodeTracingListResponse> => {
|
||||
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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -21,10 +21,10 @@ pnpm test
|
|||
pnpm test:watch
|
||||
|
||||
# Generate coverage report
|
||||
pnpm test -- --coverage
|
||||
pnpm test:coverage
|
||||
|
||||
# Run specific file
|
||||
pnpm test -- path/to/file.spec.tsx
|
||||
pnpm test path/to/file.spec.tsx
|
||||
```
|
||||
|
||||
## Project Test Setup
|
||||
|
|
|
|||
Loading…
Reference in New Issue