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
# 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 │
└────────────────────────────────────────┘

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
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 `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

View File

@ -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({

View File

@ -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...
```

View File

@ -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()

View File

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

View File

@ -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">

View File

@ -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">

View File

@ -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

View File

@ -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()

View File

@ -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')
})
})
})

View File

@ -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 (

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>
)
}
/** Usage
/**
* Usage
* <SkeletonContainer>
* <SkeletonRow>
* <SkeletonRectangle className="w-96" />

View File

@ -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}

View File

@ -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]

View File

@ -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",

View File

@ -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 })
}

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
# 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