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
|
pnpm test:watch
|
||||||
|
|
||||||
# Run specific file
|
# Run specific file
|
||||||
pnpm test -- path/to/file.spec.tsx
|
pnpm test path/to/file.spec.tsx
|
||||||
|
|
||||||
# Generate coverage report
|
# Generate coverage report
|
||||||
pnpm test -- --coverage
|
pnpm test:coverage
|
||||||
|
|
||||||
# Analyze component complexity
|
# Analyze component complexity
|
||||||
pnpm analyze-component <path>
|
pnpm analyze-component <path>
|
||||||
|
|
@ -155,7 +155,7 @@ describe('ComponentName', () => {
|
||||||
For each file:
|
For each file:
|
||||||
┌────────────────────────────────────────┐
|
┌────────────────────────────────────────┐
|
||||||
│ 1. Write test │
|
│ 1. Write test │
|
||||||
│ 2. Run: pnpm test -- <file>.spec.tsx │
|
│ 2. Run: pnpm test <file>.spec.tsx │
|
||||||
│ 3. PASS? → Mark complete, next file │
|
│ 3. PASS? → Mark complete, next file │
|
||||||
│ FAIL? → Fix first, then continue │
|
│ FAIL? → Fix first, then continue │
|
||||||
└────────────────────────────────────────┘
|
└────────────────────────────────────────┘
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,7 @@ describe('ComponentName', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
// Async Operations (if component fetches data - useSWR, useQuery, fetch)
|
// Async Operations (if component fetches data - useQuery, fetch)
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
// WHY: Async operations have 3 states users experience: loading, success, error
|
// WHY: Async operations have 3 states users experience: loading, success, error
|
||||||
describe('Async Operations', () => {
|
describe('Async Operations', () => {
|
||||||
|
|
|
||||||
|
|
@ -114,15 +114,15 @@ For the current file being tested:
|
||||||
|
|
||||||
**Run these checks after EACH test file, not just at the end:**
|
**Run these checks after EACH test file, not just at the end:**
|
||||||
|
|
||||||
- [ ] Run `pnpm test -- path/to/file.spec.tsx` - **MUST PASS before next file**
|
- [ ] Run `pnpm test path/to/file.spec.tsx` - **MUST PASS before next file**
|
||||||
- [ ] Fix any failures immediately
|
- [ ] Fix any failures immediately
|
||||||
- [ ] Mark file as complete in todo list
|
- [ ] Mark file as complete in todo list
|
||||||
- [ ] Only then proceed to next file
|
- [ ] Only then proceed to next file
|
||||||
|
|
||||||
### After All Files Complete
|
### After All Files Complete
|
||||||
|
|
||||||
- [ ] Run full directory test: `pnpm test -- path/to/directory/`
|
- [ ] Run full directory test: `pnpm test path/to/directory/`
|
||||||
- [ ] Check coverage report: `pnpm test -- --coverage`
|
- [ ] Check coverage report: `pnpm test:coverage`
|
||||||
- [ ] Run `pnpm lint:fix` on all test files
|
- [ ] Run `pnpm lint:fix` on all test files
|
||||||
- [ ] Run `pnpm type-check:tsgo`
|
- [ ] Run `pnpm type-check:tsgo`
|
||||||
|
|
||||||
|
|
@ -186,16 +186,16 @@ Always test these scenarios:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run specific test
|
# Run specific test
|
||||||
pnpm test -- path/to/file.spec.tsx
|
pnpm test path/to/file.spec.tsx
|
||||||
|
|
||||||
# Run with coverage
|
# Run with coverage
|
||||||
pnpm test -- --coverage path/to/file.spec.tsx
|
pnpm test:coverage path/to/file.spec.tsx
|
||||||
|
|
||||||
# Watch mode
|
# Watch mode
|
||||||
pnpm test:watch -- path/to/file.spec.tsx
|
pnpm test:watch path/to/file.spec.tsx
|
||||||
|
|
||||||
# Update snapshots (use sparingly)
|
# Update snapshots (use sparingly)
|
||||||
pnpm test -- -u path/to/file.spec.tsx
|
pnpm test -u path/to/file.spec.tsx
|
||||||
|
|
||||||
# Analyze component
|
# Analyze component
|
||||||
pnpm analyze-component path/to/component.tsx
|
pnpm analyze-component path/to/component.tsx
|
||||||
|
|
|
||||||
|
|
@ -242,32 +242,9 @@ describe('Component with Context', () => {
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7. SWR / React Query
|
### 7. React Query
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// SWR
|
|
||||||
vi.mock('swr', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import useSWR from 'swr'
|
|
||||||
const mockedUseSWR = vi.mocked(useSWR)
|
|
||||||
|
|
||||||
describe('Component with SWR', () => {
|
|
||||||
it('should show loading state', () => {
|
|
||||||
mockedUseSWR.mockReturnValue({
|
|
||||||
data: undefined,
|
|
||||||
error: undefined,
|
|
||||||
isLoading: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<Component />)
|
|
||||||
expect(screen.getByText(/loading/i)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// React Query
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
|
||||||
const createTestQueryClient = () => new QueryClient({
|
const createTestQueryClient = () => new QueryClient({
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ When testing a **single component, hook, or utility**:
|
||||||
2. Run `pnpm analyze-component <path>` (if available)
|
2. Run `pnpm analyze-component <path>` (if available)
|
||||||
3. Check complexity score and features detected
|
3. Check complexity score and features detected
|
||||||
4. Write the test file
|
4. Write the test file
|
||||||
5. Run test: `pnpm test -- <file>.spec.tsx`
|
5. Run test: `pnpm test <file>.spec.tsx`
|
||||||
6. Fix any failures
|
6. Fix any failures
|
||||||
7. Verify coverage meets goals (100% function, >95% branch)
|
7. Verify coverage meets goals (100% function, >95% branch)
|
||||||
```
|
```
|
||||||
|
|
@ -80,7 +80,7 @@ Process files in this recommended order:
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────┐
|
||||||
│ 1. Write test file │
|
│ 1. Write test file │
|
||||||
│ 2. Run: pnpm test -- <file>.spec.tsx │
|
│ 2. Run: pnpm test <file>.spec.tsx │
|
||||||
│ 3. If FAIL → Fix immediately, re-run │
|
│ 3. If FAIL → Fix immediately, re-run │
|
||||||
│ 4. If PASS → Mark complete in todo list │
|
│ 4. If PASS → Mark complete in todo list │
|
||||||
│ 5. ONLY THEN proceed to next file │
|
│ 5. ONLY THEN proceed to next file │
|
||||||
|
|
@ -95,10 +95,10 @@ After all individual tests pass:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run all tests in the directory together
|
# Run all tests in the directory together
|
||||||
pnpm test -- path/to/directory/
|
pnpm test path/to/directory/
|
||||||
|
|
||||||
# Check coverage
|
# Check coverage
|
||||||
pnpm test -- --coverage path/to/directory/
|
pnpm test:coverage path/to/directory/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Component Complexity Guidelines
|
## Component Complexity Guidelines
|
||||||
|
|
@ -201,9 +201,9 @@ Run pnpm test ← Multiple failures, hard to debug
|
||||||
```
|
```
|
||||||
# GOOD: Incremental with verification
|
# GOOD: Incremental with verification
|
||||||
Write component-a.spec.tsx
|
Write component-a.spec.tsx
|
||||||
Run pnpm test -- component-a.spec.tsx ✅
|
Run pnpm test component-a.spec.tsx ✅
|
||||||
Write component-b.spec.tsx
|
Write component-b.spec.tsx
|
||||||
Run pnpm test -- component-b.spec.tsx ✅
|
Run pnpm test component-b.spec.tsx ✅
|
||||||
...continue...
|
...continue...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ jobs:
|
||||||
run: pnpm run check:i18n-types
|
run: pnpm run check:i18n-types
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: pnpm test --coverage
|
run: pnpm test:coverage
|
||||||
|
|
||||||
- name: Coverage Summary
|
- name: Coverage Summary
|
||||||
if: always()
|
if: always()
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,209 @@
|
||||||
|
import type { UseQueryResult } from '@tanstack/react-query'
|
||||||
import type { Mock } from 'vitest'
|
import type { Mock } from 'vitest'
|
||||||
import type { QueryParam } from './filter'
|
import type { QueryParam } from './filter'
|
||||||
|
import type { AnnotationsCountResponse } from '@/models/log'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import useSWR from 'swr'
|
import * as useLogModule from '@/service/use-log'
|
||||||
import Filter from './filter'
|
import Filter from './filter'
|
||||||
|
|
||||||
vi.mock('swr', () => ({
|
vi.mock('@/service/use-log')
|
||||||
__esModule: true,
|
|
||||||
default: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/service/log', () => ({
|
const mockUseAnnotationsCount = useLogModule.useAnnotationsCount as Mock
|
||||||
fetchAnnotationsCount: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const mockUseSWR = useSWR as unknown as Mock
|
// ============================================================================
|
||||||
|
// Test Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const createQueryClient = () => new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderWithQueryClient = (ui: React.ReactElement) => {
|
||||||
|
const queryClient = createQueryClient()
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{ui}
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Mock Return Value Factory
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
type MockQueryResult<T> = Pick<UseQueryResult<T>, 'data' | 'isLoading' | 'error' | 'refetch'>
|
||||||
|
|
||||||
|
const createMockQueryResult = <T,>(
|
||||||
|
overrides: Partial<MockQueryResult<T>> = {},
|
||||||
|
): MockQueryResult<T> => ({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
describe('Filter', () => {
|
describe('Filter', () => {
|
||||||
const appId = 'app-1'
|
const appId = 'app-1'
|
||||||
const childContent = 'child-content'
|
const childContent = 'child-content'
|
||||||
|
const defaultQueryParams: QueryParam = { keyword: '' }
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render nothing until annotation count is fetched', () => {
|
// --------------------------------------------------------------------------
|
||||||
mockUseSWR.mockReturnValue({ data: undefined })
|
// Rendering Tests (REQUIRED)
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('should render nothing when data is loading', () => {
|
||||||
|
// Arrange
|
||||||
|
mockUseAnnotationsCount.mockReturnValue(
|
||||||
|
createMockQueryResult<AnnotationsCountResponse>({ isLoading: true }),
|
||||||
|
)
|
||||||
|
|
||||||
const { container } = render(
|
// Act
|
||||||
|
const { container } = renderWithQueryClient(
|
||||||
<Filter
|
<Filter
|
||||||
appId={appId}
|
appId={appId}
|
||||||
queryParams={{ keyword: '' }}
|
queryParams={defaultQueryParams}
|
||||||
setQueryParams={vi.fn()}
|
setQueryParams={vi.fn()}
|
||||||
>
|
>
|
||||||
<div>{childContent}</div>
|
<div>{childContent}</div>
|
||||||
</Filter>,
|
</Filter>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
expect(container.firstChild).toBeNull()
|
expect(container.firstChild).toBeNull()
|
||||||
expect(mockUseSWR).toHaveBeenCalledWith(
|
|
||||||
{ url: `/apps/${appId}/annotations/count` },
|
|
||||||
expect.any(Function),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should propagate keyword changes and clearing behavior', () => {
|
it('should render nothing when data is undefined', () => {
|
||||||
mockUseSWR.mockReturnValue({ data: { total: 20 } })
|
// Arrange
|
||||||
const queryParams: QueryParam = { keyword: 'prefill' }
|
mockUseAnnotationsCount.mockReturnValue(
|
||||||
|
createMockQueryResult<AnnotationsCountResponse>({ data: undefined, isLoading: false }),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { container } = renderWithQueryClient(
|
||||||
|
<Filter
|
||||||
|
appId={appId}
|
||||||
|
queryParams={defaultQueryParams}
|
||||||
|
setQueryParams={vi.fn()}
|
||||||
|
>
|
||||||
|
<div>{childContent}</div>
|
||||||
|
</Filter>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(container.firstChild).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render filter and children when data is available', () => {
|
||||||
|
// Arrange
|
||||||
|
mockUseAnnotationsCount.mockReturnValue(
|
||||||
|
createMockQueryResult<AnnotationsCountResponse>({
|
||||||
|
data: { count: 20 },
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
renderWithQueryClient(
|
||||||
|
<Filter
|
||||||
|
appId={appId}
|
||||||
|
queryParams={defaultQueryParams}
|
||||||
|
setQueryParams={vi.fn()}
|
||||||
|
>
|
||||||
|
<div>{childContent}</div>
|
||||||
|
</Filter>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(childContent)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// Props Tests (REQUIRED)
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
describe('Props', () => {
|
||||||
|
it('should call useAnnotationsCount with appId', () => {
|
||||||
|
// Arrange
|
||||||
|
mockUseAnnotationsCount.mockReturnValue(
|
||||||
|
createMockQueryResult<AnnotationsCountResponse>({
|
||||||
|
data: { count: 10 },
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
renderWithQueryClient(
|
||||||
|
<Filter
|
||||||
|
appId={appId}
|
||||||
|
queryParams={defaultQueryParams}
|
||||||
|
setQueryParams={vi.fn()}
|
||||||
|
>
|
||||||
|
<div>{childContent}</div>
|
||||||
|
</Filter>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(mockUseAnnotationsCount).toHaveBeenCalledWith(appId)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should display keyword value in input', () => {
|
||||||
|
// Arrange
|
||||||
|
mockUseAnnotationsCount.mockReturnValue(
|
||||||
|
createMockQueryResult<AnnotationsCountResponse>({
|
||||||
|
data: { count: 10 },
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const queryParams: QueryParam = { keyword: 'test-keyword' }
|
||||||
|
|
||||||
|
// Act
|
||||||
|
renderWithQueryClient(
|
||||||
|
<Filter
|
||||||
|
appId={appId}
|
||||||
|
queryParams={queryParams}
|
||||||
|
setQueryParams={vi.fn()}
|
||||||
|
>
|
||||||
|
<div>{childContent}</div>
|
||||||
|
</Filter>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(screen.getByPlaceholderText('common.operation.search')).toHaveValue('test-keyword')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// User Interactions
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
describe('User Interactions', () => {
|
||||||
|
it('should call setQueryParams when typing in search input', () => {
|
||||||
|
// Arrange
|
||||||
|
mockUseAnnotationsCount.mockReturnValue(
|
||||||
|
createMockQueryResult<AnnotationsCountResponse>({
|
||||||
|
data: { count: 20 },
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const queryParams: QueryParam = { keyword: '' }
|
||||||
const setQueryParams = vi.fn()
|
const setQueryParams = vi.fn()
|
||||||
|
|
||||||
const { container } = render(
|
renderWithQueryClient(
|
||||||
<Filter
|
<Filter
|
||||||
appId={appId}
|
appId={appId}
|
||||||
queryParams={queryParams}
|
queryParams={queryParams}
|
||||||
|
|
@ -59,14 +213,120 @@ describe('Filter', () => {
|
||||||
</Filter>,
|
</Filter>,
|
||||||
)
|
)
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText('common.operation.search') as HTMLInputElement
|
// Act
|
||||||
|
const input = screen.getByPlaceholderText('common.operation.search')
|
||||||
fireEvent.change(input, { target: { value: 'updated' } })
|
fireEvent.change(input, { target: { value: 'updated' } })
|
||||||
|
|
||||||
|
// Assert
|
||||||
expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: 'updated' })
|
expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: 'updated' })
|
||||||
|
})
|
||||||
|
|
||||||
const clearButton = input.parentElement?.querySelector('div.cursor-pointer') as HTMLElement
|
it('should call setQueryParams with empty keyword when clearing input', () => {
|
||||||
|
// Arrange
|
||||||
|
mockUseAnnotationsCount.mockReturnValue(
|
||||||
|
createMockQueryResult<AnnotationsCountResponse>({
|
||||||
|
data: { count: 20 },
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const queryParams: QueryParam = { keyword: 'prefill' }
|
||||||
|
const setQueryParams = vi.fn()
|
||||||
|
|
||||||
|
renderWithQueryClient(
|
||||||
|
<Filter
|
||||||
|
appId={appId}
|
||||||
|
queryParams={queryParams}
|
||||||
|
setQueryParams={setQueryParams}
|
||||||
|
>
|
||||||
|
<div>{childContent}</div>
|
||||||
|
</Filter>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const input = screen.getByPlaceholderText('common.operation.search')
|
||||||
|
const clearButton = input.parentElement?.querySelector('div.cursor-pointer')
|
||||||
|
if (clearButton)
|
||||||
fireEvent.click(clearButton)
|
fireEvent.click(clearButton)
|
||||||
expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: '' })
|
|
||||||
|
|
||||||
expect(container).toHaveTextContent(childContent)
|
// Assert
|
||||||
|
expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: '' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// Edge Cases (REQUIRED)
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('should handle empty keyword in queryParams', () => {
|
||||||
|
// Arrange
|
||||||
|
mockUseAnnotationsCount.mockReturnValue(
|
||||||
|
createMockQueryResult<AnnotationsCountResponse>({
|
||||||
|
data: { count: 5 },
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
renderWithQueryClient(
|
||||||
|
<Filter
|
||||||
|
appId={appId}
|
||||||
|
queryParams={{ keyword: '' }}
|
||||||
|
setQueryParams={vi.fn()}
|
||||||
|
>
|
||||||
|
<div>{childContent}</div>
|
||||||
|
</Filter>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(screen.getByPlaceholderText('common.operation.search')).toHaveValue('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle undefined keyword in queryParams', () => {
|
||||||
|
// Arrange
|
||||||
|
mockUseAnnotationsCount.mockReturnValue(
|
||||||
|
createMockQueryResult<AnnotationsCountResponse>({
|
||||||
|
data: { count: 5 },
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
renderWithQueryClient(
|
||||||
|
<Filter
|
||||||
|
appId={appId}
|
||||||
|
queryParams={{ keyword: undefined }}
|
||||||
|
setQueryParams={vi.fn()}
|
||||||
|
>
|
||||||
|
<div>{childContent}</div>
|
||||||
|
</Filter>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle zero count', () => {
|
||||||
|
// Arrange
|
||||||
|
mockUseAnnotationsCount.mockReturnValue(
|
||||||
|
createMockQueryResult<AnnotationsCountResponse>({
|
||||||
|
data: { count: 0 },
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
renderWithQueryClient(
|
||||||
|
<Filter
|
||||||
|
appId={appId}
|
||||||
|
queryParams={defaultQueryParams}
|
||||||
|
setQueryParams={vi.fn()}
|
||||||
|
>
|
||||||
|
<div>{childContent}</div>
|
||||||
|
</Filter>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert - should still render when count is 0
|
||||||
|
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,8 @@
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import useSWR from 'swr'
|
|
||||||
import Input from '@/app/components/base/input'
|
import Input from '@/app/components/base/input'
|
||||||
import { fetchAnnotationsCount } from '@/service/log'
|
import { useAnnotationsCount } from '@/service/use-log'
|
||||||
|
|
||||||
export type QueryParam = {
|
export type QueryParam = {
|
||||||
keyword?: string
|
keyword?: string
|
||||||
|
|
@ -23,10 +22,9 @@ const Filter: FC<IFilterProps> = ({
|
||||||
setQueryParams,
|
setQueryParams,
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
// TODO: change fetch list api
|
const { data, isLoading } = useAnnotationsCount(appId)
|
||||||
const { data } = useSWR({ url: `/apps/${appId}/annotations/count` }, fetchAnnotationsCount)
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
if (!data)
|
if (isLoading || !data)
|
||||||
return null
|
return null
|
||||||
return (
|
return (
|
||||||
<div className="mb-2 flex flex-row flex-wrap items-center justify-between gap-2">
|
<div className="mb-2 flex flex-row flex-wrap items-center justify-between gap-2">
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,10 @@ import dayjs from 'dayjs'
|
||||||
import quarterOfYear from 'dayjs/plugin/quarterOfYear'
|
import quarterOfYear from 'dayjs/plugin/quarterOfYear'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import useSWR from 'swr'
|
|
||||||
import Chip from '@/app/components/base/chip'
|
import Chip from '@/app/components/base/chip'
|
||||||
import Input from '@/app/components/base/input'
|
import Input from '@/app/components/base/input'
|
||||||
import Sort from '@/app/components/base/sort'
|
import Sort from '@/app/components/base/sort'
|
||||||
import { fetchAnnotationsCount } from '@/service/log'
|
import { useAnnotationsCount } from '@/service/use-log'
|
||||||
|
|
||||||
dayjs.extend(quarterOfYear)
|
dayjs.extend(quarterOfYear)
|
||||||
|
|
||||||
|
|
@ -36,9 +35,9 @@ type IFilterProps = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const Filter: FC<IFilterProps> = ({ isChatMode, appId, queryParams, setQueryParams }: IFilterProps) => {
|
const Filter: FC<IFilterProps> = ({ isChatMode, appId, queryParams, setQueryParams }: IFilterProps) => {
|
||||||
const { data } = useSWR({ url: `/apps/${appId}/annotations/count` }, fetchAnnotationsCount)
|
const { data, isLoading } = useAnnotationsCount(appId)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
if (!data)
|
if (isLoading || !data)
|
||||||
return null
|
return null
|
||||||
return (
|
return (
|
||||||
<div className="mb-2 flex flex-row flex-wrap items-center gap-2">
|
<div className="mb-2 flex flex-row flex-wrap items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,10 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import useSWR from 'swr'
|
|
||||||
import Loading from '@/app/components/base/loading'
|
import Loading from '@/app/components/base/loading'
|
||||||
import Pagination from '@/app/components/base/pagination'
|
import Pagination from '@/app/components/base/pagination'
|
||||||
import { APP_PAGE_LIMIT } from '@/config'
|
import { APP_PAGE_LIMIT } from '@/config'
|
||||||
import { fetchChatConversations, fetchCompletionConversations } from '@/service/log'
|
import { useChatConversations, useCompletionConversations } from '@/service/use-log'
|
||||||
import { AppModeEnum } from '@/types/app'
|
import { AppModeEnum } from '@/types/app'
|
||||||
import EmptyElement from './empty-element'
|
import EmptyElement from './empty-element'
|
||||||
import Filter, { TIME_PERIOD_MAPPING } from './filter'
|
import Filter, { TIME_PERIOD_MAPPING } from './filter'
|
||||||
|
|
@ -88,19 +87,15 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// When the details are obtained, proceed to the next request
|
// When the details are obtained, proceed to the next request
|
||||||
const { data: chatConversations, mutate: mutateChatList } = useSWR(() => isChatMode
|
const { data: chatConversations, refetch: mutateChatList } = useChatConversations({
|
||||||
? {
|
appId: isChatMode ? appDetail.id : '',
|
||||||
url: `/apps/${appDetail.id}/chat-conversations`,
|
|
||||||
params: query,
|
params: query,
|
||||||
}
|
})
|
||||||
: null, fetchChatConversations)
|
|
||||||
|
|
||||||
const { data: completionConversations, mutate: mutateCompletionList } = useSWR(() => !isChatMode
|
const { data: completionConversations, refetch: mutateCompletionList } = useCompletionConversations({
|
||||||
? {
|
appId: !isChatMode ? appDetail.id : '',
|
||||||
url: `/apps/${appDetail.id}/completion-conversations`,
|
|
||||||
params: query,
|
params: query,
|
||||||
}
|
})
|
||||||
: null, fetchCompletionConversations)
|
|
||||||
|
|
||||||
const total = isChatMode ? chatConversations?.total : completionConversations?.total
|
const total = isChatMode ? chatConversations?.total : completionConversations?.total
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import useSWR from 'swr'
|
|
||||||
import { createContext, useContext } from 'use-context-selector'
|
import { createContext, useContext } from 'use-context-selector'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import ModelInfo from '@/app/components/app/log/model-info'
|
import ModelInfo from '@/app/components/app/log/model-info'
|
||||||
|
|
@ -38,7 +37,8 @@ import { WorkflowContextProvider } from '@/app/components/workflow/context'
|
||||||
import { useAppContext } from '@/context/app-context'
|
import { useAppContext } from '@/context/app-context'
|
||||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||||
import useTimestamp from '@/hooks/use-timestamp'
|
import useTimestamp from '@/hooks/use-timestamp'
|
||||||
import { fetchChatConversationDetail, fetchChatMessages, fetchCompletionConversationDetail, updateLogMessageAnnotations, updateLogMessageFeedbacks } from '@/service/log'
|
import { fetchChatMessages, updateLogMessageAnnotations, updateLogMessageFeedbacks } from '@/service/log'
|
||||||
|
import { useChatConversationDetail, useCompletionConversationDetail } from '@/service/use-log'
|
||||||
import { AppModeEnum } from '@/types/app'
|
import { AppModeEnum } from '@/types/app'
|
||||||
import { cn } from '@/utils/classnames'
|
import { cn } from '@/utils/classnames'
|
||||||
import PromptLogModal from '../../base/prompt-log-modal'
|
import PromptLogModal from '../../base/prompt-log-modal'
|
||||||
|
|
@ -825,8 +825,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||||
*/
|
*/
|
||||||
const CompletionConversationDetailComp: FC<{ appId?: string, conversationId?: string }> = ({ appId, conversationId }) => {
|
const CompletionConversationDetailComp: FC<{ appId?: string, conversationId?: string }> = ({ appId, conversationId }) => {
|
||||||
// Text Generator App Session Details Including Message List
|
// Text Generator App Session Details Including Message List
|
||||||
const detailParams = ({ url: `/apps/${appId}/completion-conversations/${conversationId}` })
|
const { data: conversationDetail, refetch: conversationDetailMutate } = useCompletionConversationDetail(appId, conversationId)
|
||||||
const { data: conversationDetail, mutate: conversationDetailMutate } = useSWR(() => (appId && conversationId) ? detailParams : null, fetchCompletionConversationDetail)
|
|
||||||
const { notify } = useContext(ToastContext)
|
const { notify } = useContext(ToastContext)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
|
@ -875,8 +874,7 @@ const CompletionConversationDetailComp: FC<{ appId?: string, conversationId?: st
|
||||||
* Chat App Conversation Detail Component
|
* Chat App Conversation Detail Component
|
||||||
*/
|
*/
|
||||||
const ChatConversationDetailComp: FC<{ appId?: string, conversationId?: string }> = ({ appId, conversationId }) => {
|
const ChatConversationDetailComp: FC<{ appId?: string, conversationId?: string }> = ({ appId, conversationId }) => {
|
||||||
const detailParams = { url: `/apps/${appId}/chat-conversations/${conversationId}` }
|
const { data: conversationDetail } = useChatConversationDetail(appId, conversationId)
|
||||||
const { data: conversationDetail } = useSWR(() => (appId && conversationId) ? detailParams : null, fetchChatConversationDetail)
|
|
||||||
const { notify } = useContext(ToastContext)
|
const { notify } = useContext(ToastContext)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import type { MockedFunction } from 'vitest'
|
import type { UseQueryResult } from '@tanstack/react-query'
|
||||||
/**
|
/**
|
||||||
* Logs Container Component Tests
|
* Logs Container Component Tests
|
||||||
*
|
*
|
||||||
* Tests the main Logs container component which:
|
* Tests the main Logs container component which:
|
||||||
* - Fetches workflow logs via useSWR
|
* - Fetches workflow logs via TanStack Query
|
||||||
* - Manages query parameters (status, period, keyword)
|
* - Manages query parameters (status, period, keyword)
|
||||||
* - Handles pagination
|
* - Handles pagination
|
||||||
* - Renders Filter, List, and Empty states
|
* - Renders Filter, List, and Empty states
|
||||||
|
|
@ -15,14 +15,16 @@ import type { MockedFunction } from 'vitest'
|
||||||
* - trigger-by-display.spec.tsx
|
* - trigger-by-display.spec.tsx
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { MockedFunction } from 'vitest'
|
||||||
import type { ILogsProps } from './index'
|
import type { ILogsProps } from './index'
|
||||||
import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log'
|
import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log'
|
||||||
import type { App, AppIconType, AppModeEnum } from '@/types/app'
|
import type { App, AppIconType, AppModeEnum } from '@/types/app'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import useSWR from 'swr'
|
|
||||||
import { APP_PAGE_LIMIT } from '@/config'
|
import { APP_PAGE_LIMIT } from '@/config'
|
||||||
import { WorkflowRunTriggeredFrom } from '@/models/log'
|
import { WorkflowRunTriggeredFrom } from '@/models/log'
|
||||||
|
import * as useLogModule from '@/service/use-log'
|
||||||
import { TIME_PERIOD_MAPPING } from './filter'
|
import { TIME_PERIOD_MAPPING } from './filter'
|
||||||
import Logs from './index'
|
import Logs from './index'
|
||||||
|
|
||||||
|
|
@ -30,7 +32,7 @@ import Logs from './index'
|
||||||
// Mocks
|
// Mocks
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
vi.mock('swr')
|
vi.mock('@/service/use-log')
|
||||||
|
|
||||||
vi.mock('ahooks', () => ({
|
vi.mock('ahooks', () => ({
|
||||||
useDebounce: <T,>(value: T) => value,
|
useDebounce: <T,>(value: T) => value,
|
||||||
|
|
@ -72,10 +74,6 @@ vi.mock('@/app/components/base/amplitude/utils', () => ({
|
||||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/service/log', () => ({
|
|
||||||
fetchWorkflowLogs: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/hooks/use-theme', () => ({
|
vi.mock('@/hooks/use-theme', () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: () => {
|
default: () => {
|
||||||
|
|
@ -89,38 +87,76 @@ vi.mock('@/context/app-context', () => ({
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock useTimestamp
|
|
||||||
vi.mock('@/hooks/use-timestamp', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: () => ({
|
|
||||||
formatTime: (timestamp: number, _format: string) => `formatted-${timestamp}`,
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Mock useBreakpoints
|
|
||||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: () => 'pc',
|
|
||||||
MediaType: {
|
|
||||||
mobile: 'mobile',
|
|
||||||
pc: 'pc',
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Mock BlockIcon
|
|
||||||
vi.mock('@/app/components/workflow/block-icon', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: () => <div data-testid="block-icon">BlockIcon</div>,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Mock WorkflowContextProvider
|
// Mock WorkflowContextProvider
|
||||||
vi.mock('@/app/components/workflow/context', () => ({
|
vi.mock('@/app/components/workflow/context', () => ({
|
||||||
WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => (
|
WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => (
|
||||||
<div data-testid="workflow-context-provider">{children}</div>
|
<>{children}</>
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const mockedUseSWR = useSWR as unknown as MockedFunction<typeof useSWR>
|
const mockedUseWorkflowLogs = useLogModule.useWorkflowLogs as MockedFunction<typeof useLogModule.useWorkflowLogs>
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Test Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const createQueryClient = () => new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderWithQueryClient = (ui: React.ReactElement) => {
|
||||||
|
const queryClient = createQueryClient()
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{ui}
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Mock Return Value Factory
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const createMockQueryResult = <T,>(
|
||||||
|
overrides: { data?: T, isLoading?: boolean, error?: Error | null } = {},
|
||||||
|
): UseQueryResult<T, Error> => {
|
||||||
|
const isLoading = overrides.isLoading ?? false
|
||||||
|
const error = overrides.error ?? null
|
||||||
|
const data = overrides.data
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
isError: !!error,
|
||||||
|
isPending: isLoading,
|
||||||
|
isSuccess: !isLoading && !error && data !== undefined,
|
||||||
|
isFetching: isLoading,
|
||||||
|
isRefetching: false,
|
||||||
|
isLoadingError: false,
|
||||||
|
isRefetchError: false,
|
||||||
|
isInitialLoading: isLoading,
|
||||||
|
isPaused: false,
|
||||||
|
isEnabled: true,
|
||||||
|
status: isLoading ? 'pending' : error ? 'error' : 'success',
|
||||||
|
fetchStatus: isLoading ? 'fetching' : 'idle',
|
||||||
|
dataUpdatedAt: Date.now(),
|
||||||
|
errorUpdatedAt: 0,
|
||||||
|
failureCount: 0,
|
||||||
|
failureReason: null,
|
||||||
|
errorUpdateCount: 0,
|
||||||
|
isFetched: !isLoading,
|
||||||
|
isFetchedAfterMount: !isLoading,
|
||||||
|
isPlaceholderData: false,
|
||||||
|
isStale: false,
|
||||||
|
promise: Promise.resolve(data as T),
|
||||||
|
} as UseQueryResult<T, Error>
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Test Data Factories
|
// Test Data Factories
|
||||||
|
|
@ -195,6 +231,20 @@ const createMockLogsResponse = (
|
||||||
page: 1,
|
page: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Type-safe Mock Helper
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
type WorkflowLogsParams = {
|
||||||
|
appId: string
|
||||||
|
params?: Record<string, string | number | boolean | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMockCallParams = (): WorkflowLogsParams | undefined => {
|
||||||
|
const lastCall = mockedUseWorkflowLogs.mock.calls.at(-1)
|
||||||
|
return lastCall?.[0]
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Tests
|
// Tests
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -213,45 +263,48 @@ describe('Logs Container', () => {
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
describe('Rendering', () => {
|
describe('Rendering', () => {
|
||||||
it('should render without crashing', () => {
|
it('should render without crashing', () => {
|
||||||
mockedUseSWR.mockReturnValue({
|
// Arrange
|
||||||
|
mockedUseWorkflowLogs.mockReturnValue(
|
||||||
|
createMockQueryResult<WorkflowLogsResponse>({
|
||||||
data: createMockLogsResponse([], 0),
|
data: createMockLogsResponse([], 0),
|
||||||
mutate: vi.fn(),
|
}),
|
||||||
isValidating: false,
|
)
|
||||||
isLoading: false,
|
|
||||||
error: undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<Logs {...defaultProps} />)
|
// Act
|
||||||
|
renderWithQueryClient(<Logs {...defaultProps} />)
|
||||||
|
|
||||||
|
// Assert
|
||||||
expect(screen.getByText('appLog.workflowTitle')).toBeInTheDocument()
|
expect(screen.getByText('appLog.workflowTitle')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render title and subtitle', () => {
|
it('should render title and subtitle', () => {
|
||||||
mockedUseSWR.mockReturnValue({
|
// Arrange
|
||||||
|
mockedUseWorkflowLogs.mockReturnValue(
|
||||||
|
createMockQueryResult<WorkflowLogsResponse>({
|
||||||
data: createMockLogsResponse([], 0),
|
data: createMockLogsResponse([], 0),
|
||||||
mutate: vi.fn(),
|
}),
|
||||||
isValidating: false,
|
)
|
||||||
isLoading: false,
|
|
||||||
error: undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<Logs {...defaultProps} />)
|
// Act
|
||||||
|
renderWithQueryClient(<Logs {...defaultProps} />)
|
||||||
|
|
||||||
|
// Assert
|
||||||
expect(screen.getByText('appLog.workflowTitle')).toBeInTheDocument()
|
expect(screen.getByText('appLog.workflowTitle')).toBeInTheDocument()
|
||||||
expect(screen.getByText('appLog.workflowSubtitle')).toBeInTheDocument()
|
expect(screen.getByText('appLog.workflowSubtitle')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render Filter component', () => {
|
it('should render Filter component', () => {
|
||||||
mockedUseSWR.mockReturnValue({
|
// Arrange
|
||||||
|
mockedUseWorkflowLogs.mockReturnValue(
|
||||||
|
createMockQueryResult<WorkflowLogsResponse>({
|
||||||
data: createMockLogsResponse([], 0),
|
data: createMockLogsResponse([], 0),
|
||||||
mutate: vi.fn(),
|
}),
|
||||||
isValidating: false,
|
)
|
||||||
isLoading: false,
|
|
||||||
error: undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<Logs {...defaultProps} />)
|
// Act
|
||||||
|
renderWithQueryClient(<Logs {...defaultProps} />)
|
||||||
|
|
||||||
|
// Assert
|
||||||
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
|
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -261,30 +314,33 @@ describe('Logs Container', () => {
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
describe('Loading State', () => {
|
describe('Loading State', () => {
|
||||||
it('should show loading spinner when data is undefined', () => {
|
it('should show loading spinner when data is undefined', () => {
|
||||||
mockedUseSWR.mockReturnValue({
|
// Arrange
|
||||||
|
mockedUseWorkflowLogs.mockReturnValue(
|
||||||
|
createMockQueryResult<WorkflowLogsResponse>({
|
||||||
data: undefined,
|
data: undefined,
|
||||||
mutate: vi.fn(),
|
|
||||||
isValidating: true,
|
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
error: undefined,
|
}),
|
||||||
})
|
)
|
||||||
|
|
||||||
const { container } = render(<Logs {...defaultProps} />)
|
// Act
|
||||||
|
const { container } = renderWithQueryClient(<Logs {...defaultProps} />)
|
||||||
|
|
||||||
|
// Assert
|
||||||
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
|
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not show loading spinner when data is available', () => {
|
it('should not show loading spinner when data is available', () => {
|
||||||
mockedUseSWR.mockReturnValue({
|
// Arrange
|
||||||
|
mockedUseWorkflowLogs.mockReturnValue(
|
||||||
|
createMockQueryResult<WorkflowLogsResponse>({
|
||||||
data: createMockLogsResponse([createMockWorkflowLog()], 1),
|
data: createMockLogsResponse([createMockWorkflowLog()], 1),
|
||||||
mutate: vi.fn(),
|
}),
|
||||||
isValidating: false,
|
)
|
||||||
isLoading: false,
|
|
||||||
error: undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { container } = render(<Logs {...defaultProps} />)
|
// Act
|
||||||
|
const { container } = renderWithQueryClient(<Logs {...defaultProps} />)
|
||||||
|
|
||||||
|
// Assert
|
||||||
expect(container.querySelector('.spin-animation')).not.toBeInTheDocument()
|
expect(container.querySelector('.spin-animation')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -294,16 +350,17 @@ describe('Logs Container', () => {
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
describe('Empty State', () => {
|
describe('Empty State', () => {
|
||||||
it('should render empty element when total is 0', () => {
|
it('should render empty element when total is 0', () => {
|
||||||
mockedUseSWR.mockReturnValue({
|
// Arrange
|
||||||
|
mockedUseWorkflowLogs.mockReturnValue(
|
||||||
|
createMockQueryResult<WorkflowLogsResponse>({
|
||||||
data: createMockLogsResponse([], 0),
|
data: createMockLogsResponse([], 0),
|
||||||
mutate: vi.fn(),
|
}),
|
||||||
isValidating: false,
|
)
|
||||||
isLoading: false,
|
|
||||||
error: undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<Logs {...defaultProps} />)
|
// Act
|
||||||
|
renderWithQueryClient(<Logs {...defaultProps} />)
|
||||||
|
|
||||||
|
// Assert
|
||||||
expect(screen.getByText('appLog.table.empty.element.title')).toBeInTheDocument()
|
expect(screen.getByText('appLog.table.empty.element.title')).toBeInTheDocument()
|
||||||
expect(screen.queryByRole('table')).not.toBeInTheDocument()
|
expect(screen.queryByRole('table')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
@ -313,20 +370,21 @@ describe('Logs Container', () => {
|
||||||
// Data Fetching Tests
|
// Data Fetching Tests
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
describe('Data Fetching', () => {
|
describe('Data Fetching', () => {
|
||||||
it('should call useSWR with correct URL and default params', () => {
|
it('should call useWorkflowLogs with correct appId and default params', () => {
|
||||||
mockedUseSWR.mockReturnValue({
|
// Arrange
|
||||||
|
mockedUseWorkflowLogs.mockReturnValue(
|
||||||
|
createMockQueryResult<WorkflowLogsResponse>({
|
||||||
data: createMockLogsResponse([], 0),
|
data: createMockLogsResponse([], 0),
|
||||||
mutate: vi.fn(),
|
}),
|
||||||
isValidating: false,
|
)
|
||||||
isLoading: false,
|
|
||||||
error: undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<Logs {...defaultProps} />)
|
// Act
|
||||||
|
renderWithQueryClient(<Logs {...defaultProps} />)
|
||||||
|
|
||||||
const keyArg = mockedUseSWR.mock.calls.at(-1)?.[0] as { url: string, params: Record<string, unknown> }
|
// Assert
|
||||||
expect(keyArg).toMatchObject({
|
const callArg = getMockCallParams()
|
||||||
url: `/apps/${defaultProps.appDetail.id}/workflow-app-logs`,
|
expect(callArg).toMatchObject({
|
||||||
|
appId: defaultProps.appDetail.id,
|
||||||
params: expect.objectContaining({
|
params: expect.objectContaining({
|
||||||
page: 1,
|
page: 1,
|
||||||
detail: true,
|
detail: true,
|
||||||
|
|
@ -336,34 +394,36 @@ describe('Logs Container', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should include date filters for non-allTime periods', () => {
|
it('should include date filters for non-allTime periods', () => {
|
||||||
mockedUseSWR.mockReturnValue({
|
// Arrange
|
||||||
|
mockedUseWorkflowLogs.mockReturnValue(
|
||||||
|
createMockQueryResult<WorkflowLogsResponse>({
|
||||||
data: createMockLogsResponse([], 0),
|
data: createMockLogsResponse([], 0),
|
||||||
mutate: vi.fn(),
|
}),
|
||||||
isValidating: false,
|
)
|
||||||
isLoading: false,
|
|
||||||
error: undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<Logs {...defaultProps} />)
|
// Act
|
||||||
|
renderWithQueryClient(<Logs {...defaultProps} />)
|
||||||
|
|
||||||
const keyArg = mockedUseSWR.mock.calls.at(-1)?.[0] as { params?: Record<string, unknown> }
|
// Assert
|
||||||
expect(keyArg?.params).toHaveProperty('created_at__after')
|
const callArg = getMockCallParams()
|
||||||
expect(keyArg?.params).toHaveProperty('created_at__before')
|
expect(callArg?.params).toHaveProperty('created_at__after')
|
||||||
|
expect(callArg?.params).toHaveProperty('created_at__before')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not include status param when status is all', () => {
|
it('should not include status param when status is all', () => {
|
||||||
mockedUseSWR.mockReturnValue({
|
// Arrange
|
||||||
|
mockedUseWorkflowLogs.mockReturnValue(
|
||||||
|
createMockQueryResult<WorkflowLogsResponse>({
|
||||||
data: createMockLogsResponse([], 0),
|
data: createMockLogsResponse([], 0),
|
||||||
mutate: vi.fn(),
|
}),
|
||||||
isValidating: false,
|
)
|
||||||
isLoading: false,
|
|
||||||
error: undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<Logs {...defaultProps} />)
|
// Act
|
||||||
|
renderWithQueryClient(<Logs {...defaultProps} />)
|
||||||
|
|
||||||
const keyArg = mockedUseSWR.mock.calls.at(-1)?.[0] as { params?: Record<string, unknown> }
|
// Assert
|
||||||
expect(keyArg?.params).not.toHaveProperty('status')
|
const callArg = getMockCallParams()
|
||||||
|
expect(callArg?.params).not.toHaveProperty('status')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -372,24 +432,23 @@ describe('Logs Container', () => {
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
describe('Filter Integration', () => {
|
describe('Filter Integration', () => {
|
||||||
it('should update query when selecting status filter', async () => {
|
it('should update query when selecting status filter', async () => {
|
||||||
|
// Arrange
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
mockedUseSWR.mockReturnValue({
|
mockedUseWorkflowLogs.mockReturnValue(
|
||||||
|
createMockQueryResult<WorkflowLogsResponse>({
|
||||||
data: createMockLogsResponse([], 0),
|
data: createMockLogsResponse([], 0),
|
||||||
mutate: vi.fn(),
|
}),
|
||||||
isValidating: false,
|
)
|
||||||
isLoading: false,
|
|
||||||
error: undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<Logs {...defaultProps} />)
|
renderWithQueryClient(<Logs {...defaultProps} />)
|
||||||
|
|
||||||
// Click status filter
|
// Act
|
||||||
await user.click(screen.getByText('All'))
|
await user.click(screen.getByText('All'))
|
||||||
await user.click(await screen.findByText('Success'))
|
await user.click(await screen.findByText('Success'))
|
||||||
|
|
||||||
// Check that useSWR was called with updated params
|
// Assert
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const lastCall = mockedUseSWR.mock.calls.at(-1)?.[0] as { params?: Record<string, unknown> }
|
const lastCall = getMockCallParams()
|
||||||
expect(lastCall?.params).toMatchObject({
|
expect(lastCall?.params).toMatchObject({
|
||||||
status: 'succeeded',
|
status: 'succeeded',
|
||||||
})
|
})
|
||||||
|
|
@ -397,46 +456,46 @@ describe('Logs Container', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should update query when selecting period filter', async () => {
|
it('should update query when selecting period filter', async () => {
|
||||||
|
// Arrange
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
mockedUseSWR.mockReturnValue({
|
mockedUseWorkflowLogs.mockReturnValue(
|
||||||
|
createMockQueryResult<WorkflowLogsResponse>({
|
||||||
data: createMockLogsResponse([], 0),
|
data: createMockLogsResponse([], 0),
|
||||||
mutate: vi.fn(),
|
}),
|
||||||
isValidating: false,
|
)
|
||||||
isLoading: false,
|
|
||||||
error: undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<Logs {...defaultProps} />)
|
renderWithQueryClient(<Logs {...defaultProps} />)
|
||||||
|
|
||||||
// Click period filter
|
// Act
|
||||||
await user.click(screen.getByText('appLog.filter.period.last7days'))
|
await user.click(screen.getByText('appLog.filter.period.last7days'))
|
||||||
await user.click(await screen.findByText('appLog.filter.period.allTime'))
|
await user.click(await screen.findByText('appLog.filter.period.allTime'))
|
||||||
|
|
||||||
// When period is allTime (9), date filters should be removed
|
// Assert
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const lastCall = mockedUseSWR.mock.calls.at(-1)?.[0] as { params?: Record<string, unknown> }
|
const lastCall = getMockCallParams()
|
||||||
expect(lastCall?.params).not.toHaveProperty('created_at__after')
|
expect(lastCall?.params).not.toHaveProperty('created_at__after')
|
||||||
expect(lastCall?.params).not.toHaveProperty('created_at__before')
|
expect(lastCall?.params).not.toHaveProperty('created_at__before')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should update query when typing keyword', async () => {
|
it('should update query when typing keyword', async () => {
|
||||||
|
// Arrange
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
mockedUseSWR.mockReturnValue({
|
mockedUseWorkflowLogs.mockReturnValue(
|
||||||
|
createMockQueryResult<WorkflowLogsResponse>({
|
||||||
data: createMockLogsResponse([], 0),
|
data: createMockLogsResponse([], 0),
|
||||||
mutate: vi.fn(),
|
}),
|
||||||
isValidating: false,
|
)
|
||||||
isLoading: false,
|
|
||||||
error: undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<Logs {...defaultProps} />)
|
renderWithQueryClient(<Logs {...defaultProps} />)
|
||||||
|
|
||||||
|
// Act
|
||||||
const searchInput = screen.getByPlaceholderText('common.operation.search')
|
const searchInput = screen.getByPlaceholderText('common.operation.search')
|
||||||
await user.type(searchInput, 'test-keyword')
|
await user.type(searchInput, 'test-keyword')
|
||||||
|
|
||||||
|
// Assert
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const lastCall = mockedUseSWR.mock.calls.at(-1)?.[0] as { params?: Record<string, unknown> }
|
const lastCall = getMockCallParams()
|
||||||
expect(lastCall?.params).toMatchObject({
|
expect(lastCall?.params).toMatchObject({
|
||||||
keyword: 'test-keyword',
|
keyword: 'test-keyword',
|
||||||
})
|
})
|
||||||
|
|
@ -449,36 +508,35 @@ describe('Logs Container', () => {
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
describe('Pagination', () => {
|
describe('Pagination', () => {
|
||||||
it('should not render pagination when total is less than limit', () => {
|
it('should not render pagination when total is less than limit', () => {
|
||||||
mockedUseSWR.mockReturnValue({
|
// Arrange
|
||||||
|
mockedUseWorkflowLogs.mockReturnValue(
|
||||||
|
createMockQueryResult<WorkflowLogsResponse>({
|
||||||
data: createMockLogsResponse([createMockWorkflowLog()], 1),
|
data: createMockLogsResponse([createMockWorkflowLog()], 1),
|
||||||
mutate: vi.fn(),
|
}),
|
||||||
isValidating: false,
|
)
|
||||||
isLoading: false,
|
|
||||||
error: undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<Logs {...defaultProps} />)
|
// Act
|
||||||
|
renderWithQueryClient(<Logs {...defaultProps} />)
|
||||||
|
|
||||||
// Pagination component should not be rendered
|
// Assert
|
||||||
expect(screen.queryByRole('navigation')).not.toBeInTheDocument()
|
expect(screen.queryByRole('navigation')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render pagination when total exceeds limit', () => {
|
it('should render pagination when total exceeds limit', () => {
|
||||||
|
// Arrange
|
||||||
const logs = Array.from({ length: APP_PAGE_LIMIT }, (_, i) =>
|
const logs = Array.from({ length: APP_PAGE_LIMIT }, (_, i) =>
|
||||||
createMockWorkflowLog({ id: `log-${i}` }))
|
createMockWorkflowLog({ id: `log-${i}` }))
|
||||||
|
|
||||||
mockedUseSWR.mockReturnValue({
|
mockedUseWorkflowLogs.mockReturnValue(
|
||||||
|
createMockQueryResult<WorkflowLogsResponse>({
|
||||||
data: createMockLogsResponse(logs, APP_PAGE_LIMIT + 10),
|
data: createMockLogsResponse(logs, APP_PAGE_LIMIT + 10),
|
||||||
mutate: vi.fn(),
|
}),
|
||||||
isValidating: false,
|
)
|
||||||
isLoading: false,
|
|
||||||
error: undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<Logs {...defaultProps} />)
|
// Act
|
||||||
|
renderWithQueryClient(<Logs {...defaultProps} />)
|
||||||
|
|
||||||
// Should show pagination - checking for any pagination-related element
|
// Assert
|
||||||
// The Pagination component renders page controls
|
|
||||||
expect(screen.getByRole('table')).toBeInTheDocument()
|
expect(screen.getByRole('table')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -488,21 +546,24 @@ describe('Logs Container', () => {
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
describe('List Rendering', () => {
|
describe('List Rendering', () => {
|
||||||
it('should render List component when data is available', () => {
|
it('should render List component when data is available', () => {
|
||||||
mockedUseSWR.mockReturnValue({
|
// Arrange
|
||||||
|
mockedUseWorkflowLogs.mockReturnValue(
|
||||||
|
createMockQueryResult<WorkflowLogsResponse>({
|
||||||
data: createMockLogsResponse([createMockWorkflowLog()], 1),
|
data: createMockLogsResponse([createMockWorkflowLog()], 1),
|
||||||
mutate: vi.fn(),
|
}),
|
||||||
isValidating: false,
|
)
|
||||||
isLoading: false,
|
|
||||||
error: undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<Logs {...defaultProps} />)
|
// Act
|
||||||
|
renderWithQueryClient(<Logs {...defaultProps} />)
|
||||||
|
|
||||||
|
// Assert
|
||||||
expect(screen.getByRole('table')).toBeInTheDocument()
|
expect(screen.getByRole('table')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should display log data in table', () => {
|
it('should display log data in table', () => {
|
||||||
mockedUseSWR.mockReturnValue({
|
// Arrange
|
||||||
|
mockedUseWorkflowLogs.mockReturnValue(
|
||||||
|
createMockQueryResult<WorkflowLogsResponse>({
|
||||||
data: createMockLogsResponse([
|
data: createMockLogsResponse([
|
||||||
createMockWorkflowLog({
|
createMockWorkflowLog({
|
||||||
workflow_run: createMockWorkflowRun({
|
workflow_run: createMockWorkflowRun({
|
||||||
|
|
@ -511,14 +572,13 @@ describe('Logs Container', () => {
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
], 1),
|
], 1),
|
||||||
mutate: vi.fn(),
|
}),
|
||||||
isValidating: false,
|
)
|
||||||
isLoading: false,
|
|
||||||
error: undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<Logs {...defaultProps} />)
|
// Act
|
||||||
|
renderWithQueryClient(<Logs {...defaultProps} />)
|
||||||
|
|
||||||
|
// Assert
|
||||||
expect(screen.getByText('Success')).toBeInTheDocument()
|
expect(screen.getByText('Success')).toBeInTheDocument()
|
||||||
expect(screen.getByText('500')).toBeInTheDocument()
|
expect(screen.getByText('500')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
@ -541,52 +601,54 @@ describe('Logs Container', () => {
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
describe('Edge Cases', () => {
|
describe('Edge Cases', () => {
|
||||||
it('should handle different app modes', () => {
|
it('should handle different app modes', () => {
|
||||||
mockedUseSWR.mockReturnValue({
|
// Arrange
|
||||||
|
mockedUseWorkflowLogs.mockReturnValue(
|
||||||
|
createMockQueryResult<WorkflowLogsResponse>({
|
||||||
data: createMockLogsResponse([createMockWorkflowLog()], 1),
|
data: createMockLogsResponse([createMockWorkflowLog()], 1),
|
||||||
mutate: vi.fn(),
|
}),
|
||||||
isValidating: false,
|
)
|
||||||
isLoading: false,
|
|
||||||
error: undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
const chatApp = createMockApp({ mode: 'advanced-chat' as AppModeEnum })
|
const chatApp = createMockApp({ mode: 'advanced-chat' as AppModeEnum })
|
||||||
|
|
||||||
render(<Logs appDetail={chatApp} />)
|
// Act
|
||||||
|
renderWithQueryClient(<Logs appDetail={chatApp} />)
|
||||||
|
|
||||||
// Should render without trigger column
|
// Assert
|
||||||
expect(screen.queryByText('appLog.table.header.triggered_from')).not.toBeInTheDocument()
|
expect(screen.queryByText('appLog.table.header.triggered_from')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle error state from useSWR', () => {
|
it('should handle error state from useWorkflowLogs', () => {
|
||||||
mockedUseSWR.mockReturnValue({
|
// Arrange
|
||||||
|
mockedUseWorkflowLogs.mockReturnValue(
|
||||||
|
createMockQueryResult<WorkflowLogsResponse>({
|
||||||
data: undefined,
|
data: undefined,
|
||||||
mutate: vi.fn(),
|
|
||||||
isValidating: false,
|
|
||||||
isLoading: false,
|
|
||||||
error: new Error('Failed to fetch'),
|
error: new Error('Failed to fetch'),
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const { container } = render(<Logs {...defaultProps} />)
|
// Act
|
||||||
|
const { container } = renderWithQueryClient(<Logs {...defaultProps} />)
|
||||||
|
|
||||||
// Should show loading state when data is undefined
|
// Assert - should show loading state when data is undefined
|
||||||
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
|
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle app with different ID', () => {
|
it('should handle app with different ID', () => {
|
||||||
mockedUseSWR.mockReturnValue({
|
// Arrange
|
||||||
|
mockedUseWorkflowLogs.mockReturnValue(
|
||||||
|
createMockQueryResult<WorkflowLogsResponse>({
|
||||||
data: createMockLogsResponse([], 0),
|
data: createMockLogsResponse([], 0),
|
||||||
mutate: vi.fn(),
|
}),
|
||||||
isValidating: false,
|
)
|
||||||
isLoading: false,
|
|
||||||
error: undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
const customApp = createMockApp({ id: 'custom-app-123' })
|
const customApp = createMockApp({ id: 'custom-app-123' })
|
||||||
|
|
||||||
render(<Logs appDetail={customApp} />)
|
// Act
|
||||||
|
renderWithQueryClient(<Logs appDetail={customApp} />)
|
||||||
|
|
||||||
const keyArg = mockedUseSWR.mock.calls.at(-1)?.[0] as { url: string }
|
// Assert
|
||||||
expect(keyArg?.url).toBe('/apps/custom-app-123/workflow-app-logs')
|
const callArg = getMockCallParams()
|
||||||
|
expect(callArg?.appId).toBe('custom-app-123')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,12 @@ import { omit } from 'lodash-es'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import useSWR from 'swr'
|
|
||||||
import EmptyElement from '@/app/components/app/log/empty-element'
|
import EmptyElement from '@/app/components/app/log/empty-element'
|
||||||
import Loading from '@/app/components/base/loading'
|
import Loading from '@/app/components/base/loading'
|
||||||
import Pagination from '@/app/components/base/pagination'
|
import Pagination from '@/app/components/base/pagination'
|
||||||
import { APP_PAGE_LIMIT } from '@/config'
|
import { APP_PAGE_LIMIT } from '@/config'
|
||||||
import { useAppContext } from '@/context/app-context'
|
import { useAppContext } from '@/context/app-context'
|
||||||
import { fetchWorkflowLogs } from '@/service/log'
|
import { useWorkflowLogs } from '@/service/use-log'
|
||||||
import Filter, { TIME_PERIOD_MAPPING } from './filter'
|
import Filter, { TIME_PERIOD_MAPPING } from './filter'
|
||||||
import List from './list'
|
import List from './list'
|
||||||
|
|
||||||
|
|
@ -55,10 +54,10 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||||
...omit(debouncedQueryParams, ['period', 'status']),
|
...omit(debouncedQueryParams, ['period', 'status']),
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: workflowLogs, mutate } = useSWR({
|
const { data: workflowLogs, refetch: mutate } = useWorkflowLogs({
|
||||||
url: `/apps/${appDetail.id}/workflow-app-logs`,
|
appId: appDetail.id,
|
||||||
params: query,
|
params: query,
|
||||||
}, fetchWorkflowLogs)
|
})
|
||||||
const total = workflowLogs?.total
|
const total = workflowLogs?.total
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,8 @@ export const SkeletonPoint: FC<SkeletonProps> = (props) => {
|
||||||
<div className={cn('text-xs font-medium text-text-quaternary', className)} {...rest}>·</div>
|
<div className={cn('text-xs font-medium text-text-quaternary', className)} {...rest}>·</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/** Usage
|
/**
|
||||||
|
* Usage
|
||||||
* <SkeletonContainer>
|
* <SkeletonContainer>
|
||||||
* <SkeletonRow>
|
* <SkeletonRow>
|
||||||
* <SkeletonRectangle className="w-96" />
|
* <SkeletonRectangle className="w-96" />
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,16 @@ type ModelDisplayProps = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const ModelDisplay = ({ currentModel, modelId }: ModelDisplayProps) => {
|
const ModelDisplay = ({ currentModel, modelId }: ModelDisplayProps) => {
|
||||||
return currentModel ? (
|
return currentModel
|
||||||
|
? (
|
||||||
<ModelName
|
<ModelName
|
||||||
className="flex grow items-center gap-1 px-1 py-[3px]"
|
className="flex grow items-center gap-1 px-1 py-[3px]"
|
||||||
modelItem={currentModel}
|
modelItem={currentModel}
|
||||||
showMode
|
showMode
|
||||||
showFeatures
|
showFeatures
|
||||||
/>
|
/>
|
||||||
) : (
|
)
|
||||||
|
: (
|
||||||
<div className="flex grow items-center gap-1 truncate px-1 py-[3px] opacity-50">
|
<div className="flex grow items-center gap-1 truncate px-1 py-[3px] opacity-50">
|
||||||
<div className="system-sm-regular overflow-hidden text-ellipsis text-components-input-text-filled">
|
<div className="system-sm-regular overflow-hidden text-ellipsis text-components-input-text-filled">
|
||||||
{modelId}
|
{modelId}
|
||||||
|
|
|
||||||
|
|
@ -6,21 +6,6 @@ import type {
|
||||||
} from '@/app/components/workflow/types'
|
} from '@/app/components/workflow/types'
|
||||||
import type { VisionFile } from '@/types/app'
|
import type { VisionFile } from '@/types/app'
|
||||||
|
|
||||||
// Log type contains key:string conversation_id:string created_at:string question:string answer:string
|
|
||||||
export type Conversation = {
|
|
||||||
id: string
|
|
||||||
key: string
|
|
||||||
conversationId: string
|
|
||||||
question: string
|
|
||||||
answer: string
|
|
||||||
userRate: number
|
|
||||||
adminRate: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ConversationListResponse = {
|
|
||||||
logs: Conversation[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CompletionParams = ['temperature', 'top_p', 'presence_penalty', 'max_token', 'stop', 'frequency_penalty'] as const
|
export const CompletionParams = ['temperature', 'top_p', 'presence_penalty', 'max_token', 'stop', 'frequency_penalty'] as const
|
||||||
|
|
||||||
export type CompletionParamType = typeof CompletionParams[number]
|
export type CompletionParamType = typeof CompletionParams[number]
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@
|
||||||
"gen:i18n-types": "node ./i18n-config/generate-i18n-types.js",
|
"gen:i18n-types": "node ./i18n-config/generate-i18n-types.js",
|
||||||
"check:i18n-types": "node ./i18n-config/check-i18n-sync.js",
|
"check:i18n-types": "node ./i18n-config/check-i18n-sync.js",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:watch": "vitest --watch",
|
"test:watch": "vitest --watch",
|
||||||
"analyze-component": "node testing/analyze-component.js",
|
"analyze-component": "node testing/analyze-component.js",
|
||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
|
|
|
||||||
|
|
@ -1,80 +1,38 @@
|
||||||
import type { Fetcher } from 'swr'
|
|
||||||
import type {
|
import type {
|
||||||
AgentLogDetailRequest,
|
AgentLogDetailRequest,
|
||||||
AgentLogDetailResponse,
|
AgentLogDetailResponse,
|
||||||
AnnotationsCountResponse,
|
|
||||||
ChatConversationFullDetailResponse,
|
|
||||||
ChatConversationsRequest,
|
|
||||||
ChatConversationsResponse,
|
|
||||||
ChatMessagesRequest,
|
ChatMessagesRequest,
|
||||||
ChatMessagesResponse,
|
ChatMessagesResponse,
|
||||||
CompletionConversationFullDetailResponse,
|
|
||||||
CompletionConversationsRequest,
|
|
||||||
CompletionConversationsResponse,
|
|
||||||
ConversationListResponse,
|
|
||||||
LogMessageAnnotationsRequest,
|
LogMessageAnnotationsRequest,
|
||||||
LogMessageAnnotationsResponse,
|
LogMessageAnnotationsResponse,
|
||||||
LogMessageFeedbacksRequest,
|
LogMessageFeedbacksRequest,
|
||||||
LogMessageFeedbacksResponse,
|
LogMessageFeedbacksResponse,
|
||||||
WorkflowLogsResponse,
|
|
||||||
WorkflowRunDetailResponse,
|
WorkflowRunDetailResponse,
|
||||||
} from '@/models/log'
|
} from '@/models/log'
|
||||||
import type { NodeTracingListResponse } from '@/types/workflow'
|
import type { NodeTracingListResponse } from '@/types/workflow'
|
||||||
import { get, post } from './base'
|
import { get, post } from './base'
|
||||||
|
|
||||||
export const fetchConversationList: Fetcher<ConversationListResponse, { name: string, appId: string, params?: Record<string, any> }> = ({ appId, params }) => {
|
|
||||||
return get<ConversationListResponse>(`/console/api/apps/${appId}/messages`, params)
|
|
||||||
}
|
|
||||||
|
|
||||||
// (Text Generation Application) Session List
|
|
||||||
export const fetchCompletionConversations: Fetcher<CompletionConversationsResponse, { url: string, params?: CompletionConversationsRequest }> = ({ url, params }) => {
|
|
||||||
return get<CompletionConversationsResponse>(url, { params })
|
|
||||||
}
|
|
||||||
|
|
||||||
// (Text Generation Application) Session Detail
|
|
||||||
export const fetchCompletionConversationDetail: Fetcher<CompletionConversationFullDetailResponse, { url: string }> = ({ url }) => {
|
|
||||||
return get<CompletionConversationFullDetailResponse>(url, {})
|
|
||||||
}
|
|
||||||
|
|
||||||
// (Chat Application) Session List
|
|
||||||
export const fetchChatConversations: Fetcher<ChatConversationsResponse, { url: string, params?: ChatConversationsRequest }> = ({ url, params }) => {
|
|
||||||
return get<ChatConversationsResponse>(url, { params })
|
|
||||||
}
|
|
||||||
|
|
||||||
// (Chat Application) Session Detail
|
|
||||||
export const fetchChatConversationDetail: Fetcher<ChatConversationFullDetailResponse, { url: string }> = ({ url }) => {
|
|
||||||
return get<ChatConversationFullDetailResponse>(url, {})
|
|
||||||
}
|
|
||||||
|
|
||||||
// (Chat Application) Message list in one session
|
// (Chat Application) Message list in one session
|
||||||
export const fetchChatMessages: Fetcher<ChatMessagesResponse, { url: string, params: ChatMessagesRequest }> = ({ url, params }) => {
|
export const fetchChatMessages = ({ url, params }: { url: string, params: ChatMessagesRequest }): Promise<ChatMessagesResponse> => {
|
||||||
return get<ChatMessagesResponse>(url, { params })
|
return get<ChatMessagesResponse>(url, { params })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateLogMessageFeedbacks: Fetcher<LogMessageFeedbacksResponse, { url: string, body: LogMessageFeedbacksRequest }> = ({ url, body }) => {
|
export const updateLogMessageFeedbacks = ({ url, body }: { url: string, body: LogMessageFeedbacksRequest }): Promise<LogMessageFeedbacksResponse> => {
|
||||||
return post<LogMessageFeedbacksResponse>(url, { body })
|
return post<LogMessageFeedbacksResponse>(url, { body })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateLogMessageAnnotations: Fetcher<LogMessageAnnotationsResponse, { url: string, body: LogMessageAnnotationsRequest }> = ({ url, body }) => {
|
export const updateLogMessageAnnotations = ({ url, body }: { url: string, body: LogMessageAnnotationsRequest }): Promise<LogMessageAnnotationsResponse> => {
|
||||||
return post<LogMessageAnnotationsResponse>(url, { body })
|
return post<LogMessageAnnotationsResponse>(url, { body })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchAnnotationsCount: Fetcher<AnnotationsCountResponse, { url: string }> = ({ url }) => {
|
export const fetchRunDetail = (url: string): Promise<WorkflowRunDetailResponse> => {
|
||||||
return get<AnnotationsCountResponse>(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fetchWorkflowLogs: Fetcher<WorkflowLogsResponse, { url: string, params: Record<string, any> }> = ({ url, params }) => {
|
|
||||||
return get<WorkflowLogsResponse>(url, { params })
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fetchRunDetail = (url: string) => {
|
|
||||||
return get<WorkflowRunDetailResponse>(url)
|
return get<WorkflowRunDetailResponse>(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchTracingList: Fetcher<NodeTracingListResponse, { url: string }> = ({ url }) => {
|
export const fetchTracingList = ({ url }: { url: string }): Promise<NodeTracingListResponse> => {
|
||||||
return get<NodeTracingListResponse>(url)
|
return get<NodeTracingListResponse>(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchAgentLogDetail = ({ appID, params }: { appID: string, params: AgentLogDetailRequest }) => {
|
export const fetchAgentLogDetail = ({ appID, params }: { appID: string, params: AgentLogDetailRequest }): Promise<AgentLogDetailResponse> => {
|
||||||
return get<AgentLogDetailResponse>(`/apps/${appID}/agent/logs`, { params })
|
return get<AgentLogDetailResponse>(`/apps/${appID}/agent/logs`, { params })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
pnpm test:watch
|
||||||
|
|
||||||
# Generate coverage report
|
# Generate coverage report
|
||||||
pnpm test -- --coverage
|
pnpm test:coverage
|
||||||
|
|
||||||
# Run specific file
|
# Run specific file
|
||||||
pnpm test -- path/to/file.spec.tsx
|
pnpm test path/to/file.spec.tsx
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Test Setup
|
## Project Test Setup
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue