Files
ai-safety-console-web/tests/integration/pages.test.tsx
T
2026-07-19 00:29:06 +08:00

246 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(),
exportRun: 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, export: mocks.exportRun },
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.exportRun.mockResolvedValue(new Blob(['# report'], { type: 'text/markdown' }))
mocks.reports.mockResolvedValue([])
mocks.report.mockResolvedValue({ run_id: 1, summary: { test_result: {}, admission: { decision: 'pass', coverage: {} } } })
})
afterEach(() => {
cleanup()
localStorage.clear()
vi.clearAllMocks()
vi.unstubAllGlobals()
})
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('exports terminal run results with the active filters', async () => {
const createObjectURL = vi.fn(() => 'blob:report')
const revokeObjectURL = vi.fn()
class MockURL extends URL {}
MockURL.createObjectURL = createObjectURL
MockURL.revokeObjectURL = revokeObjectURL
vi.stubGlobal('URL', MockURL)
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
mount('/runs/1')
const exportButton = await screen.findByRole('button', { name: '导出详细报告' })
expect(exportButton.closest('.results-panel')).not.toBeNull()
expect(document.querySelector('.page-actions')).not.toContainElement(exportButton)
await userEvent.click(screen.getByRole('combobox', { name: '仲裁结论' }))
await userEvent.click(await screen.findByText('不通过'))
await userEvent.click(screen.getByRole('button', { name: '导出详细报告' }))
expect(mocks.exportRun).toHaveBeenCalledWith(1, { executionStatus: undefined, verdict: 'fail' })
expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
expect(click).toHaveBeenCalled()
expect(click.mock.contexts[0]).toMatchObject({ download: 'run_1_results.md', href: 'blob:report' })
expect(revokeObjectURL).toHaveBeenCalledWith('blob:report')
}, 20_000)
it('disables report export while the run is not terminal', async () => {
mocks.run.mockResolvedValueOnce({ ...run, status: 'running', terminal: false, phase: 'executing', progress_percent: 50, poll_after_seconds: 2 })
mount('/runs/1')
expect(await screen.findByRole('button', { name: '导出详细报告' })).toBeDisabled()
})
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()
}, 20_000)
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()
}, 20_000)
it('confirms and retries one result, then locks every result retry button', async () => {
mocks.run.mockResolvedValueOnce(run).mockResolvedValue({ ...run, status: 'pending', terminal: false, phase: 'pending', progress_percent: 0, poll_after_seconds: 2 })
const otherResult = { execution_id: 'R0050', case_kind: 'control', interaction_mode: 'single_turn', execution_status: 'completed', verdict: 'pass', model_input: {}, model_response: 'response', judge_result: {}, error_message: '' }
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: '' },
otherResult,
]).mockResolvedValue([otherResult])
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.findByText('R0049 重新执行中…')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '重新执行 R0050' })).toBeDisabled()
}, 20_000)
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()
}, 20_000)
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)
}, 20_000)
})