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