first commit
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { App } from '../../src/app/App'
|
||||
import { AuthProvider } from '../../src/auth/AuthProvider'
|
||||
import { ThemeProvider } from '../../src/design/ThemeProvider'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
me: vi.fn(),
|
||||
health: vi.fn(),
|
||||
providers: vi.fn(),
|
||||
runs: vi.fn(),
|
||||
run: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
retryErrors: vi.fn(),
|
||||
retryResult: vi.fn(),
|
||||
results: vi.fn(),
|
||||
reports: vi.fn(),
|
||||
report: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../src/services', () => ({ services: {
|
||||
auth: { me: mocks.me, login: vi.fn(), logout: vi.fn(), changePassword: vi.fn(), listUsers: vi.fn().mockResolvedValue([]) },
|
||||
health: { get: mocks.health },
|
||||
providers: { list: mocks.providers },
|
||||
runs: { list: mocks.runs, get: mocks.run, resume: mocks.resume, retryErrors: mocks.retryErrors, retryResult: mocks.retryResult, results: mocks.results },
|
||||
reports: { list: mocks.reports, get: mocks.report },
|
||||
} }))
|
||||
|
||||
const user = { id: 1, username: 'admin', is_active: true, is_admin: true }
|
||||
const run = {
|
||||
run_id: 1, status: 'completed', terminal: true, phase: 'completed', selected_count: 0,
|
||||
processed_count: 0, completed_count: 0, error_count: 0, progress_percent: 100,
|
||||
current_execution_id: null, started_at: '2026-01-01T00:00:00Z', finished_at: '2026-01-01T00:01:00Z',
|
||||
error_message: null, poll_after_seconds: 0, summary: {},
|
||||
}
|
||||
|
||||
function mount(path: string, authenticated = true) {
|
||||
history.replaceState({}, '', path)
|
||||
if (authenticated) localStorage.setItem('safety-session', JSON.stringify({
|
||||
access_token: 'token', token_type: 'bearer', expires_at: Math.floor(Date.now() / 1000) + 3_600,
|
||||
refresh_token: 'refresh', refresh_expires_at: Math.floor(Date.now() / 1000) + 28_800,
|
||||
}))
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
return render(<QueryClientProvider client={client}><ThemeProvider><AuthProvider><App /></AuthProvider></ThemeProvider></QueryClientProvider>)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.me.mockResolvedValue(user)
|
||||
mocks.health.mockResolvedValue({ status: 'ok', environment: 'test', version: '1' })
|
||||
mocks.providers.mockResolvedValue([])
|
||||
mocks.runs.mockResolvedValue([])
|
||||
mocks.run.mockResolvedValue(run)
|
||||
mocks.resume.mockResolvedValue({ run_id: 1, status: 'pending', selected_count: 100, skipped_count: 60 })
|
||||
mocks.retryErrors.mockResolvedValue({ run_id: 1, status: 'pending', selected_count: 100, retry_count: 5 })
|
||||
mocks.retryResult.mockResolvedValue({ run_id: 1, status: 'pending', selected_count: 1, execution_id: 'R0049' })
|
||||
mocks.results.mockResolvedValue([])
|
||||
mocks.reports.mockResolvedValue([])
|
||||
mocks.report.mockResolvedValue({ run_id: 1, summary: { test_result: {}, admission: { decision: 'pass', coverage: {} } } })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('page mounting and route security', () => {
|
||||
it.each([
|
||||
['/login', '登录控制台', false],
|
||||
['/overview', '安全概览', true],
|
||||
['/runs/new', '新建安全测试', true],
|
||||
['/runs', '测试运行', true],
|
||||
['/runs/1', 'RUN-1', true],
|
||||
['/reports', '汇总报告', true],
|
||||
['/providers', '模型提供商', true],
|
||||
['/account', '账户设置', true],
|
||||
['/users', '用户管理', true],
|
||||
])('mounts %s', async (path, heading, authenticated) => {
|
||||
mount(path as string, authenticated as boolean)
|
||||
expect(await screen.findByRole('heading', { name: heading as string })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('redirects a visitor without a token to login', async () => {
|
||||
mount('/reports', false)
|
||||
expect(await screen.findByText('登录控制台')).toBeInTheDocument()
|
||||
expect(location.pathname).toBe('/login')
|
||||
})
|
||||
|
||||
it('fails closed when the stored token cannot be verified', async () => {
|
||||
mocks.me.mockRejectedValueOnce(new Error('invalid session'))
|
||||
mount('/reports')
|
||||
expect(await screen.findByText('登录控制台')).toBeInTheDocument()
|
||||
expect(location.pathname).toBe('/login')
|
||||
})
|
||||
|
||||
it('renders a decision summary and opens its run details', async () => {
|
||||
mocks.reports.mockResolvedValueOnce([{ run_id: 7, summary: { selected_count: 4, verdicts: { pass: 3, fail: 1 } } }])
|
||||
mount('/reports')
|
||||
expect(await screen.findByText('存在风险')).toBeInTheDocument()
|
||||
expect(screen.queryByText('verdicts')).not.toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: '查看详情' }))
|
||||
expect(location.pathname).toBe('/runs/7')
|
||||
})
|
||||
|
||||
it('separates execution-status and verdict filters on run results', async () => {
|
||||
mocks.results.mockResolvedValueOnce([
|
||||
{ execution_id: 'R0001', case_kind: 'risk', interaction_mode: 'single_turn', execution_status: 'completed', verdict: 'fail', model_input: {}, model_response: 'response', judge_result: {}, error_message: '' },
|
||||
{ execution_id: 'R0002', case_kind: 'risk', interaction_mode: 'single_turn', execution_status: 'completed', verdict: 'pass', model_input: {}, model_response: 'response', judge_result: {}, error_message: '' },
|
||||
])
|
||||
mount('/runs/1')
|
||||
expect(await screen.findAllByText('执行状态:已完成')).toHaveLength(2)
|
||||
expect(screen.getByText('结论:未通过')).toBeInTheDocument()
|
||||
expect(screen.getByText('逐条结果 (2 / 2)')).toBeInTheDocument()
|
||||
expect(screen.getByRole('combobox', { name: '执行状态' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('combobox', { name: '仲裁结论' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('全部状态')).not.toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('combobox', { name: '仲裁结论' }))
|
||||
await userEvent.click(await screen.findByText('不通过'))
|
||||
expect(screen.getAllByText('仲裁结论:不通过')).toHaveLength(2)
|
||||
expect(screen.getByText('逐条结果 (1 / 2)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('searches run results by execution ID', async () => {
|
||||
mocks.results.mockResolvedValueOnce([
|
||||
{ execution_id: 'R0001', case_kind: 'risk', interaction_mode: 'single_turn', execution_status: 'completed', verdict: 'fail', model_input: {}, model_response: 'response', judge_result: {}, error_message: '' },
|
||||
{ execution_id: 'R0029', case_kind: 'risk', interaction_mode: 'single_turn', execution_status: 'completed', verdict: 'pass', model_input: {}, model_response: 'response', judge_result: {}, error_message: '' },
|
||||
])
|
||||
mount('/runs/1')
|
||||
|
||||
await screen.findByText('R0001')
|
||||
await userEvent.type(screen.getByRole('searchbox', { name: '执行 ID' }), 'r0029')
|
||||
|
||||
expect(screen.queryByText('R0001')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('R0029')).toBeInTheDocument()
|
||||
expect(screen.getByText('逐条结果 (1 / 2)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('confirms and resumes an interrupted run with skipped-result feedback', async () => {
|
||||
mocks.run.mockResolvedValueOnce({ ...run, status: 'cancelled' })
|
||||
mount('/runs/1')
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: '恢复运行' }))
|
||||
expect(screen.getByText('将继续执行未完成样例,已有结果不会重复执行。')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'OK' }))
|
||||
|
||||
expect(mocks.resume).toHaveBeenCalledWith(1)
|
||||
expect(await screen.findByText('运行已恢复,将跳过 60 条已有结果')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('only retries runs completed with errors and reports the queued count', async () => {
|
||||
mocks.run.mockResolvedValueOnce({ ...run, status: 'completed_with_errors', error_count: 5, processed_count: 5 })
|
||||
mount('/runs/1')
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: '重试错误(5)' }))
|
||||
expect(screen.getByText('将重新执行 5 条错误样例,已有成功结果会保留。')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'OK' }))
|
||||
|
||||
expect(mocks.retryErrors).toHaveBeenCalledWith(1)
|
||||
expect(await screen.findByText('已重新提交 5 条错误样例')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('confirms and retries one result, then locks every result retry button', async () => {
|
||||
mocks.results.mockResolvedValueOnce([
|
||||
{ execution_id: 'R0049', case_kind: 'risk', interaction_mode: 'single_turn', execution_status: 'completed', verdict: 'fail', model_input: {}, model_response: 'response', judge_result: {}, error_message: '' },
|
||||
{ execution_id: 'R0050', case_kind: 'control', interaction_mode: 'single_turn', execution_status: 'completed', verdict: 'pass', model_input: {}, model_response: 'response', judge_result: {}, error_message: '' },
|
||||
])
|
||||
mount('/runs/1')
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: '重新执行 R0049' }))
|
||||
expect(screen.getByText('当前测试结果将被新结果替换。')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'OK' }))
|
||||
|
||||
expect(mocks.retryResult).toHaveBeenCalledWith(1, 'R0049')
|
||||
expect(await screen.findByRole('button', { name: '重新执行中…' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '重新执行 R0050' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('keeps a tracked retry visible when its verdict no longer matches the filter', async () => {
|
||||
mocks.results.mockResolvedValueOnce([
|
||||
{ execution_id: 'R0079', case_kind: 'risk', interaction_mode: 'tool', execution_status: 'completed', verdict: 'needs_human_review', model_input: {}, model_response: '', judge_result: {}, error_message: '' },
|
||||
]).mockResolvedValue([
|
||||
{ execution_id: 'R0079', case_kind: 'risk', interaction_mode: 'tool', execution_status: 'completed', verdict: 'pass', model_input: {}, model_response: 'new response', judge_result: {}, error_message: '' },
|
||||
])
|
||||
mocks.retryResult.mockResolvedValueOnce({ run_id: 1, status: 'pending', selected_count: 1, execution_id: 'R0079' })
|
||||
mount('/runs/1')
|
||||
|
||||
await userEvent.click(await screen.findByRole('combobox', { name: '仲裁结论' }))
|
||||
await userEvent.click(await screen.findByText('人工复核'))
|
||||
await userEvent.click(screen.getByRole('button', { name: '重新执行 R0079' }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'OK' }))
|
||||
|
||||
expect(await screen.findByText('结论:通过')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('仲裁结论:人工复核')).not.toHaveLength(0)
|
||||
expect(screen.getByText('正在跟踪 R0079;即使不符合当前筛选也会置顶显示。')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('refreshes run details after an action conflict', async () => {
|
||||
mocks.run.mockResolvedValueOnce({ ...run, status: 'failed' })
|
||||
mocks.resume.mockRejectedValueOnce(Object.assign(new Error('当前状态不允许恢复'), { status: 409 }))
|
||||
mount('/runs/1')
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: '恢复运行' }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'OK' }))
|
||||
|
||||
expect(await screen.findByText('当前状态不允许恢复')).toBeInTheDocument()
|
||||
expect(mocks.run).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ApiClient } from '../../src/api/client'
|
||||
import { authService } from '../../src/services/authService'
|
||||
import { healthService } from '../../src/services/healthService'
|
||||
import { providerService } from '../../src/services/providerService'
|
||||
import { reportService } from '../../src/services/reportService'
|
||||
import { runService } from '../../src/services/runService'
|
||||
|
||||
const fakeApi = () => ({ request: vi.fn().mockResolvedValue(undefined) }) as unknown as ApiClient
|
||||
|
||||
describe('service endpoint contracts', () => {
|
||||
it('maps every authentication and user operation to the backend route', async () => {
|
||||
const api = fakeApi()
|
||||
const service = authService(api)
|
||||
await service.login({ username: 'user', password: 'password' })
|
||||
await service.me()
|
||||
await service.changePassword({ current_password: 'old', new_password: 'new-pass', confirm_password: 'new-pass' })
|
||||
await service.listUsers()
|
||||
await service.createUser({ username: 'new', password: 'password', is_admin: false })
|
||||
await service.setUserActive(2, false)
|
||||
await service.resetPassword(2, 'new-pass')
|
||||
await service.deleteUser(2)
|
||||
await service.logout()
|
||||
expect(vi.mocked(api.request).mock.calls.map(([path]) => path)).toEqual([
|
||||
'/auth/login', '/auth/me', '/auth/password', '/auth/users', '/auth/users',
|
||||
'/auth/users/2', '/auth/users/2/password', '/auth/users/2', '/auth/logout',
|
||||
])
|
||||
})
|
||||
|
||||
it('maps provider, run, result, report and health operations to every backend route', async () => {
|
||||
const api = fakeApi()
|
||||
const providers = providerService(api)
|
||||
const runs = runService(api)
|
||||
const reports = reportService(api)
|
||||
await healthService(api).get()
|
||||
await providers.list(); await providers.get('target')
|
||||
await providers.create({ provider_id: 'target', base_url: 'https://example.com', chat_path: '/chat', models_path: '/models', model_name: 'm', auth_type: 'none', api_key: '', auth_header: 'Authorization', auth_prefix: 'Bearer', verify_ssl: true })
|
||||
await providers.update('target', { model_name: 'm2' }); await providers.check('target'); await providers.models('target'); await providers.remove('target')
|
||||
const idempotencyKey = '00000000-0000-4000-8000-000000000001'
|
||||
await runs.list(); await runs.get(1); await runs.start({ profile: 'smoke', auto_judge: true }, idempotencyKey); await runs.cancel(1); await runs.resume(1); await runs.retryErrors(1); await runs.retryResult(1, 'R0049'); await runs.results(1); await runs.remove(1)
|
||||
await reports.list(); await reports.get(1)
|
||||
expect(vi.mocked(api.request).mock.calls.map(([path]) => path)).toEqual([
|
||||
'/health', '/providers', '/providers/target', '/providers', '/providers/target',
|
||||
'/providers/target/check', '/providers/target/models', '/providers/target',
|
||||
'/runs?limit=50', '/runs/1', '/runs', '/runs/1', '/runs/1/resume',
|
||||
'/runs/1/retry-errors', '/runs/1/results/R0049/retry', '/runs/1/results', '/runs/1', '/reports?limit=50', '/reports/1',
|
||||
])
|
||||
expect(vi.mocked(api.request).mock.calls[10][1]).toMatchObject({ headers: { 'Idempotency-Key': idempotencyKey } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ThemeToggle } from '../../src/components/ThemeToggle'
|
||||
import { ThemeProvider } from '../../src/design/ThemeProvider'
|
||||
|
||||
describe('ThemeToggle', () => {
|
||||
it('switches between the independent blue and light schemes', async () => {
|
||||
localStorage.clear()
|
||||
render(<ThemeProvider><ThemeToggle /></ThemeProvider>)
|
||||
await userEvent.click(screen.getByRole('button', { name: '切换前端 UI 方案' }))
|
||||
expect(document.documentElement.dataset.theme).toBe('light')
|
||||
expect(localStorage.getItem('safety-theme')).toBe('light')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user