test(admin-v2): Phase 2 frontend tests — 61 tests for 5 pages
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- Billing (13 tests): plan cards, prices, limits, usage bars, payment flow - ScheduledTasks (16 tests): CRUD table, schedule/target types, color tags - Knowledge (12 tests): 4 tabs, items/categories/search/analytics panels - Roles (12 tests): roles + permission templates tabs - ConfigSync (8 tests): sync log viewer with action labels Fix: Knowledge.tsx missing </Select> and </Modal> closing tags (JSX parse error) Fix: tests/setup.ts added ResizeObserver mock for ProTable compatibility
This commit is contained in:
268
admin-v2/tests/pages/ScheduledTasks.test.tsx
Normal file
268
admin-v2/tests/pages/ScheduledTasks.test.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
import ScheduledTasks from '@/pages/ScheduledTasks'
|
||||
|
||||
// ── Mock data ──────────────────────────────────────────────────
|
||||
|
||||
const mockTasks = [
|
||||
{
|
||||
id: 'task-001',
|
||||
name: '每日早报',
|
||||
schedule: '0 8 * * *',
|
||||
schedule_type: 'cron',
|
||||
target: { type: 'agent', id: 'daily-news' },
|
||||
enabled: true,
|
||||
description: '每天早上8点推送新闻',
|
||||
last_run: '2026-04-07T08:00:00Z',
|
||||
next_run: '2026-04-08T08:00:00Z',
|
||||
run_count: 30,
|
||||
last_result: null,
|
||||
last_error: null,
|
||||
last_duration_ms: 1500,
|
||||
created_at: '2026-03-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'task-002',
|
||||
name: '定时采集',
|
||||
schedule: '30m',
|
||||
schedule_type: 'interval',
|
||||
target: { type: 'hand', id: 'collector' },
|
||||
enabled: false,
|
||||
description: null,
|
||||
last_run: null,
|
||||
next_run: null,
|
||||
run_count: 0,
|
||||
last_result: null,
|
||||
last_error: null,
|
||||
last_duration_ms: null,
|
||||
created_at: '2026-04-01T00:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeEach(() => {
|
||||
server.listen({ onUnhandledRequest: 'bypass' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
server.close()
|
||||
})
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{ui}
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ScheduledTasks', () => {
|
||||
it('renders page header', () => {
|
||||
server.use(
|
||||
http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json([])),
|
||||
)
|
||||
renderWithProviders(<ScheduledTasks />)
|
||||
expect(screen.getByText('定时任务')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows loading spinner', async () => {
|
||||
server.use(
|
||||
http.get('*/api/v1/scheduler/tasks', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
return HttpResponse.json(mockTasks)
|
||||
}),
|
||||
)
|
||||
renderWithProviders(<ScheduledTasks />)
|
||||
expect(document.querySelector('.ant-spin')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays tasks in table', async () => {
|
||||
server.use(
|
||||
http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)),
|
||||
)
|
||||
renderWithProviders(<ScheduledTasks />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('每日早报')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('定时采集')).toBeInTheDocument()
|
||||
expect(screen.getByText('0 8 * * *')).toBeInTheDocument()
|
||||
expect(screen.getByText('30m')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays Chinese schedule type labels', async () => {
|
||||
server.use(
|
||||
http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)),
|
||||
)
|
||||
renderWithProviders(<ScheduledTasks />)
|
||||
|
||||
await waitFor(() => {
|
||||
// schedule_type labels: cron → "Cron", interval → "间隔"
|
||||
expect(screen.getByText('Cron')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('间隔')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays target type with English labels', async () => {
|
||||
server.use(
|
||||
http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)),
|
||||
)
|
||||
renderWithProviders(<ScheduledTasks />)
|
||||
|
||||
await waitFor(() => {
|
||||
// target type labels: agent → "Agent", hand → "Hand"
|
||||
expect(screen.getByText('Agent')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('Hand')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays target IDs', async () => {
|
||||
server.use(
|
||||
http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)),
|
||||
)
|
||||
renderWithProviders(<ScheduledTasks />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('daily-news')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('collector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays enabled switches', async () => {
|
||||
server.use(
|
||||
http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)),
|
||||
)
|
||||
renderWithProviders(<ScheduledTasks />)
|
||||
|
||||
await waitFor(() => {
|
||||
const switches = document.querySelectorAll('.ant-switch')
|
||||
expect(switches.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
|
||||
it('displays run count', async () => {
|
||||
server.use(
|
||||
http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)),
|
||||
)
|
||||
renderWithProviders(<ScheduledTasks />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('每日早报')).toBeInTheDocument()
|
||||
})
|
||||
// run_count: 30 is displayed in tabular-nums span
|
||||
expect(screen.getByText('30')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows last error in red when present', async () => {
|
||||
const tasksWithError = [{ ...mockTasks[0], last_error: 'Connection timeout' }]
|
||||
server.use(
|
||||
http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(tasksWithError)),
|
||||
)
|
||||
renderWithProviders(<ScheduledTasks />)
|
||||
|
||||
await waitFor(() => {
|
||||
const errorEl = screen.getByText('Connection timeout')
|
||||
expect(errorEl).toHaveClass('text-red-500')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows dash for null last error', async () => {
|
||||
server.use(
|
||||
http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)),
|
||||
)
|
||||
renderWithProviders(<ScheduledTasks />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('每日早报')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows empty state when no tasks', async () => {
|
||||
server.use(
|
||||
http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json([])),
|
||||
)
|
||||
renderWithProviders(<ScheduledTasks />)
|
||||
|
||||
await waitFor(() => {
|
||||
const empties = screen.getAllByText('暂无数据')
|
||||
expect(empties.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error state on API failure', async () => {
|
||||
server.use(
|
||||
http.get('*/api/v1/scheduler/tasks', () => {
|
||||
return HttpResponse.json(
|
||||
{ error: 'internal_error', message: '数据库错误' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}),
|
||||
)
|
||||
renderWithProviders(<ScheduledTasks />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('加载失败')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('has 新建任务 button', async () => {
|
||||
server.use(
|
||||
http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json([])),
|
||||
)
|
||||
renderWithProviders(<ScheduledTasks />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('新建任务')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders action column with buttons', async () => {
|
||||
server.use(
|
||||
http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)),
|
||||
)
|
||||
renderWithProviders(<ScheduledTasks />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('每日早报')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// 操作 column header should be rendered
|
||||
expect(screen.getByText('操作')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('color-codes schedule type tags', async () => {
|
||||
server.use(
|
||||
http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)),
|
||||
)
|
||||
renderWithProviders(<ScheduledTasks />)
|
||||
|
||||
await waitFor(() => {
|
||||
const cronTag = screen.getByText('Cron').closest('.ant-tag')
|
||||
expect(cronTag?.className).toMatch(/blue/)
|
||||
})
|
||||
const intervalTag = screen.getByText('间隔').closest('.ant-tag')
|
||||
expect(intervalTag?.className).toMatch(/green/)
|
||||
})
|
||||
|
||||
it('color-codes target type tags', async () => {
|
||||
server.use(
|
||||
http.get('*/api/v1/scheduler/tasks', () => HttpResponse.json(mockTasks)),
|
||||
)
|
||||
renderWithProviders(<ScheduledTasks />)
|
||||
|
||||
await waitFor(() => {
|
||||
const agentTag = screen.getByText('Agent').closest('.ant-tag')
|
||||
expect(agentTag?.className).toMatch(/purple/)
|
||||
})
|
||||
const handTag = screen.getByText('Hand').closest('.ant-tag')
|
||||
expect(handTag?.className).toMatch(/cyan/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user