first commit

This commit is contained in:
baozaotumao2025
2026-07-18 21:10:39 +08:00
commit 1b90e552a5
91 changed files with 9056 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
import { expect, test } from 'vitest'
const baseUrl = import.meta.env.E2E_API_BASE_URL?.replace(/\/$/, '')
const username = import.meta.env.E2E_USERNAME
const password = import.meta.env.E2E_PASSWORD
test('real backend is healthy and accepts an authenticated request', async () => {
expect(baseUrl, '请设置 E2E_API_BASE_URL').toBeTruthy()
expect(username, '请设置 E2E_USERNAME').toBeTruthy()
expect(password, '请设置 E2E_PASSWORD').toBeTruthy()
const health = await fetch(`${baseUrl}/health`)
expect(health.status).toBe(200)
expect(await health.json()).toMatchObject({ status: 'ok' })
const unauthorized = await fetch(`${baseUrl}/reports?limit=1`)
expect(unauthorized.status).toBe(401)
const login = await fetch(`${baseUrl}/auth/login`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }),
})
expect(login.status).toBe(200)
const token = (await login.json()).access_token
expect(token).toBeTruthy()
const reports = await fetch(`${baseUrl}/reports?limit=1`, { headers: { Authorization: `Bearer ${token}` } })
expect(reports.status).toBe(200)
expect(await reports.json()).toBeInstanceOf(Array)
})
+210
View File
@@ -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 } })
})
})
+15
View File
@@ -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')
})
})
+15
View File
@@ -0,0 +1,15 @@
import '@testing-library/jest-dom/vitest'
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
}),
})
+48
View File
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import { ApiClient } from '../../../src/api/client'
describe('ApiClient', () => {
afterEach(() => vi.restoreAllMocks())
it('handles empty 204 responses', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 204 }))
await expect(new ApiClient('/api', () => 'token').request('/logout', { method: 'POST' })).resolves.toBeUndefined()
})
it('validates successful responses with zod', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(Response.json({ id: 'wrong' }))
await expect(
new ApiClient('/api', () => null).request('/me', { schema: z.object({ id: z.number() }) }),
).rejects.toMatchObject({ kind: 'validation' })
})
it('refreshes and replays a business request once after 401', async () => {
let token = 'old-token'
const refresh = vi.fn(async (force?: boolean) => {
if (!force) return false
token = 'new-token'
return true
})
const fetchMock = vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(Response.json({ error: { message: '需要有效的访问令牌' } }, { status: 401 }))
.mockResolvedValueOnce(Response.json({ ok: true }))
await expect(new ApiClient('/api', () => token, refresh).request('/runs/1')).resolves.toEqual({ ok: true })
expect(refresh).toHaveBeenNthCalledWith(1, false)
expect(refresh).toHaveBeenCalledWith(true)
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(new Headers(fetchMock.mock.calls[1][1]?.headers).get('Authorization')).toBe('Bearer new-token')
})
it('does not refresh or replay a request more than once', async () => {
const refresh = vi.fn().mockImplementation(async (force?: boolean) => Boolean(force))
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
Response.json({ error: { message: '需要有效的访问令牌' } }, { status: 401 }),
)
await expect(new ApiClient('/api', () => 'token', refresh).request('/me')).rejects.toMatchObject({ kind: 'authentication' })
expect(refresh).toHaveBeenCalledTimes(2)
expect(refresh).toHaveBeenLastCalledWith(true)
expect(fetch).toHaveBeenCalledTimes(2)
})
})
+33
View File
@@ -0,0 +1,33 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { activity } from '../../../src/auth/activity'
describe('user activity', () => {
beforeEach(() => localStorage.clear())
it('records real input at most once every 30 seconds', () => {
const checkRefresh = vi.fn()
const now = vi.spyOn(Date, 'now').mockReturnValue(1_000_000)
const stop = activity.start(checkRefresh)
window.dispatchEvent(new MouseEvent('click'))
expect(activity.get()).toBe(1_000_000)
now.mockReturnValue(1_010_000)
window.dispatchEvent(new KeyboardEvent('keydown'))
expect(activity.get()).toBe(1_000_000)
now.mockReturnValue(1_031_000)
window.dispatchEvent(new TouchEvent('touchstart'))
expect(activity.get()).toBe(1_031_000)
expect(checkRefresh).toHaveBeenCalledTimes(3)
stop()
})
it('checks on foreground resume without treating visibility as activity', () => {
const checkRefresh = vi.fn()
const stop = activity.start(checkRefresh)
document.dispatchEvent(new Event('visibilitychange'))
expect(checkRefresh).toHaveBeenCalledOnce()
expect(activity.get()).toBe(0)
window.dispatchEvent(new PopStateEvent('popstate'))
expect(activity.get()).toBeGreaterThan(0)
stop()
})
})
+101
View File
@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { activity } from '../../../src/auth/activity'
import { createAuthRefresh } from '../../../src/auth/refresh'
import { session } from '../../../src/auth/session'
const now = 1_800_000_000
const credentials = (overrides = {}) => ({
access_token: 'access-a', token_type: 'bearer' as const, expires_at: now + 1_800,
refresh_token: 'refresh-a', refresh_expires_at: now + 28_800, ...overrides,
})
const refreshed = credentials({ access_token: 'access-b', refresh_token: 'refresh-b', expires_at: now + 3_600 })
describe('auth refresh', () => {
beforeEach(() => {
localStorage.clear()
vi.restoreAllMocks()
vi.spyOn(Date, 'now').mockReturnValue(now * 1000)
session.set(credentials())
activity.record(true)
})
it('does not refresh before the renewal window or while inactive', async () => {
const fetcher = vi.fn()
session.set(credentials({ expires_at: now + 1_801 }))
expect(await createAuthRefresh('/api', fetcher)()).toBe(false)
session.set(credentials())
localStorage.setItem('safety-last-activity-at', String((now - 1_801) * 1000))
expect(await createAuthRefresh('/api', fetcher)()).toBe(false)
expect(fetcher).not.toHaveBeenCalled()
})
it('uses one in-flight refresh and atomically rotates all credentials', async () => {
let resolve!: (response: Response) => void
const fetcher = vi.fn(() => new Promise<Response>((done) => { resolve = done }))
const refresh = createAuthRefresh('/api', fetcher)
const first = refresh()
const second = refresh()
resolve(Response.json(refreshed))
await expect(Promise.all([first, second])).resolves.toEqual([true, true])
expect(fetcher).toHaveBeenCalledOnce()
expect(session.get()).toEqual(refreshed)
})
it('reuses the refresh token and idempotency key for network and 5xx retries', async () => {
const fetcher = vi.fn()
.mockRejectedValueOnce(new TypeError('offline'))
.mockResolvedValueOnce(Response.json({ error: { message: 'down' } }, { status: 503 }))
.mockResolvedValueOnce(Response.json(refreshed))
await expect(createAuthRefresh('/api', fetcher)()).resolves.toBe(true)
const calls = fetcher.mock.calls.map(([, init]) => ({
key: new Headers(init?.headers).get('Idempotency-Key'), body: init?.body,
}))
expect(new Set(calls.map(({ key }) => key)).size).toBe(1)
expect(new Set(calls.map(({ body }) => body)).size).toBe(1)
})
it('keeps the same idempotency key after all network retries fail', async () => {
const offline = vi.fn().mockRejectedValue(new TypeError('offline'))
await expect(createAuthRefresh('/api', offline)()).rejects.toMatchObject({ kind: 'network' })
const firstKey = new Headers(offline.mock.calls[0][1]?.headers).get('Idempotency-Key')
const recovered = vi.fn().mockResolvedValue(Response.json(refreshed))
await expect(createAuthRefresh('/api', recovered)()).resolves.toBe(true)
expect(new Headers(recovered.mock.calls[0][1]?.headers).get('Idempotency-Key')).toBe(firstKey)
})
it('coordinates refreshes with the browser cross-tab lock', async () => {
const request = vi.fn(async (_name: string, callback: () => Promise<boolean>) => callback())
Object.defineProperty(navigator, 'locks', { configurable: true, value: { request } })
await createAuthRefresh('/api', vi.fn().mockResolvedValue(Response.json(refreshed)))()
expect(request).toHaveBeenCalledWith('safety-auth-refresh', expect.any(Function))
Object.defineProperty(navigator, 'locks', { configurable: true, value: undefined })
})
it('keeps credentials on 409 and clears them only when refresh returns 401', async () => {
const conflictFetch = vi.fn().mockResolvedValue(Response.json({
error: { code: 'REFRESH_NOT_DUE', message: 'not due', details: { refresh_after: now + 60 } },
}, { status: 409 }))
const conflict = createAuthRefresh('/api', conflictFetch)
await expect(conflict()).resolves.toBe(false)
await expect(conflict()).resolves.toBe(false)
expect(conflictFetch).toHaveBeenCalledOnce()
expect(session.get()).toEqual(credentials())
localStorage.removeItem('safety-refresh-after')
const unauthorized = createAuthRefresh('/api', vi.fn().mockResolvedValue(
Response.json({ error: { code: 'UNAUTHORIZED', message: 'expired' } }, { status: 401 }),
))
await expect(unauthorized(true)).rejects.toMatchObject({ kind: 'authentication' })
expect(session.get()).toBeNull()
expect(localStorage.getItem('safety-refresh-pending')).toBeNull()
})
it('does not retry malformed refresh requests', async () => {
const fetcher = vi.fn().mockResolvedValue(Response.json(
{ error: { code: 'VALIDATION_ERROR', message: 'bad key' } }, { status: 422 },
))
await expect(createAuthRefresh('/api', fetcher)()).rejects.toMatchObject({ kind: 'validation' })
expect(fetcher).toHaveBeenCalledOnce()
expect(session.get()).toEqual(credentials())
})
})
+30
View File
@@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { session } from '../../../src/auth/session'
const credentials = {
access_token: 'access', token_type: 'bearer' as const, expires_at: 2,
refresh_token: 'refresh', refresh_expires_at: 3,
}
describe('session', () => {
beforeEach(() => localStorage.clear())
it('stores and replaces the complete credential set atomically', () => {
const listener = vi.fn()
const unsubscribe = session.subscribe(listener)
session.set(credentials)
expect(session.get()).toEqual(credentials)
expect(JSON.parse(localStorage.getItem('safety-session') || 'null')).toEqual(credentials)
expect(listener).toHaveBeenCalledOnce()
unsubscribe()
})
it('clears credentials and pending refresh state together', () => {
localStorage.setItem('safety-session', JSON.stringify(credentials))
localStorage.setItem('safety-refresh-pending', 'secret')
localStorage.setItem('safety-refresh-after', '2')
localStorage.setItem('safety-last-activity-at', '1')
session.clear()
expect(localStorage.length).toBe(0)
})
})
@@ -0,0 +1,32 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { ReportSummary } from '../../../src/components/ReportSummary'
afterEach(cleanup)
describe('ReportSummary', () => {
it('turns report data into decision metrics', () => {
render(<ReportSummary summary={{ selected_count: 11, processed_count: 11, error_count: 1, verdicts: { pass: 7, fail: 2, needs_human_review: 1, judge_format_error: 1 }, by_mode: { single_turn: { execution_statuses: { completed: 5, error: 1 }, verdicts: { pass: 4, fail: 1 } }, multi_turn: { execution_statuses: { completed: 5, error: 0 }, verdicts: { pass: 3, needs_human_review: 1, judge_format_error: 1 } } } }} />)
expect(screen.getByText('存在风险')).toHaveClass('result-tag-error')
expect(screen.getByText('已评估 11 / 11 条样例')).toBeInTheDocument()
expect(screen.getByText('single_turn · 6 条')).toBeInTheDocument()
expect(screen.getByText('执行错误').previousSibling).toHaveTextContent('1')
expect(screen.getByText('格式错误').previousSibling).toHaveTextContent('1')
expect(screen.queryByText('verdicts')).not.toBeInTheDocument()
})
it.each([
[{ verdicts: { needs_human_review: 2 } }, '需要人工复核'],
[{ verdicts: { pass: 3 } }, '未发现风险'],
[{}, '等待结论'],
])('shows the correct decision for %#', (summary, decision) => {
render(<ReportSummary summary={summary} />)
expect(screen.getByText(decision)).toBeInTheDocument()
})
it('counts modes from nested execution statuses', () => {
render(<ReportSummary summary={{ total_count: 5, processed_count: 5, verdicts: { pass: 3, fail: 1, needs_human_review: 1 }, by_mode: { multi_turn: { execution_statuses: { completed: 3, error: 2 }, verdicts: { pass: 2, fail: 1 } } } }} />)
expect(screen.getByText('已评估 5 / 5 条样例')).toBeInTheDocument()
expect(screen.getByText('multi_turn · 5 条')).toBeInTheDocument()
})
})
+37
View File
@@ -0,0 +1,37 @@
/// <reference types="node" />
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { themes } from '../../../src/design/theme'
const css = readFileSync('src/styles.css', 'utf8')
const guidelines = readFileSync('docs/ui-design.md', 'utf8')
describe('global shape and spacing system', () => {
it('defines shared tokens and forbids one-off pill radii', () => {
expect(css).toContain('--radius-control: 8px')
expect(css).toContain('--radius-surface: 16px')
expect(css).toContain('--space-card: 24px')
expect(css).toContain('--space-section: 24px')
expect(css).not.toMatch(/border-radius:\s*999px/)
})
it('keeps the written UI rules aligned with enforced tokens', () => {
for (const token of ['--radius-control', '--radius-surface', '--space-card', '--space-section']) {
expect(guidelines).toContain(token)
}
expect(guidelines).toContain('pnpm check')
})
it('applies the same control radius globally', () => {
expect(css).toMatch(/\.ant-tag[^}]+border-radius:\s*var\(--radius-control\)/)
expect(themes.blue.token?.borderRadius).toBe(8)
expect(themes.light.token?.borderRadius).toBe(8)
})
it('gives result content consistent breathing room', () => {
expect(css).toMatch(/\.result-card\s*>\s*\.ant-card-body[^}]+gap:\s*var\(--space-section\)/)
expect(css).toMatch(/\.result-columns[^}]+gap:\s*var\(--space-section\)/)
expect(css).toMatch(/\.result-columns section[^}]+padding:\s*var\(--space-card\)/)
expect(css).toMatch(/\.result-verdict\s*\{[^}]*padding:\s*0 7px !important/)
})
})
+38
View File
@@ -0,0 +1,38 @@
/// <reference types="node" />
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { themes } from '../../../src/design/theme'
const css = readFileSync('src/styles.css', 'utf8')
const variants = ['success', 'error', 'warning', 'info', 'blue', 'teal', 'purple', 'mauve', 'default']
describe('result tag contrast', () => {
it.each(['blue', 'light'])('%s theme meets WCAG AA for every tag', (theme) => {
const block = css.match(new RegExp(`:root\\[data-theme="${theme}"\\] \\{([^}]+)`))?.[1] || ''
for (const variant of variants) {
const foreground = variable(block, `--tag-${variant}-text`)
const background = variable(block, `--tag-${variant}-bg`)
expect(contrast(foreground, background), `${theme} ${variant}`).toBeGreaterThanOrEqual(4.5)
expect(css).toContain(`.result-tag-${variant}`)
}
})
it.each(['blue', 'light'] as const)('%s primary buttons keep white text readable', (theme) => {
expect(contrast(themes[theme].token!.colorPrimary as string, '#ffffff')).toBeGreaterThanOrEqual(4.5)
})
})
function variable(block: string, name: string) {
const value = block.match(new RegExp(`${name}:\\s*(#[0-9a-f]{6})`, 'i'))?.[1]
expect(value, name).toBeTruthy()
return value!
}
function contrast(a: string, b: string) {
const luminance = (hex: string) => {
const channels = hex.slice(1).match(/.{2}/g)!.map((value) => parseInt(value, 16) / 255).map((value) => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4)
return .2126 * channels[0] + .7152 * channels[1] + .0722 * channels[2]
}
const [lighter, darker] = [luminance(a), luminance(b)].sort((x, y) => y - x)
return (lighter + .05) / (darker + .05)
}
+21
View File
@@ -0,0 +1,21 @@
import { theme } from 'antd'
import { describe, expect, it } from 'vitest'
import { getInitialTheme, setTheme, themes } from '../../../src/design/theme'
describe('theme registry', () => {
it('uses Ant Design dark rendering for the blue scheme', () => {
expect(themes.blue.algorithm).toBe(theme.darkAlgorithm)
})
it('registers the blue and light visual schemes', () => {
expect(Object.keys(themes)).toEqual(['blue', 'light'])
})
it('persists a valid theme and ignores invalid stored values', () => {
setTheme('light')
expect(document.documentElement.dataset.theme).toBe('light')
expect(getInitialTheme()).toBe('light')
localStorage.setItem('safety-theme', 'unknown')
expect(getInitialTheme()).toBe('blue')
})
})
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { AppError, normalizeError } from '../../../src/errors'
describe('normalizeError', () => {
it('preserves backend diagnostics in a safe application error', () => {
const error = normalizeError({
status: 409,
body: { error: { code: 'CONFLICT', message: '运行状态冲突', request_id: 'req-42' } },
})
expect(error).toBeInstanceOf(AppError)
expect(error).toMatchObject({ kind: 'conflict', status: 409, requestId: 'req-42' })
})
it('classifies fetch failures as network errors', () => {
expect(normalizeError(new TypeError('Failed to fetch')).kind).toBe('network')
})
})
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { sanitize } from '../../../src/logging/sanitize'
describe('sanitize', () => {
it('redacts secrets recursively without changing safe context', () => {
expect(
sanitize({
requestId: 'req-1',
authorization: 'Bearer secret',
nested: { password: 'secret', api_key: 'secret', runId: 42 },
}),
).toEqual({
requestId: 'req-1',
authorization: '[REDACTED]',
nested: { password: '[REDACTED]', api_key: '[REDACTED]', runId: 42 },
})
})
})
+43
View File
@@ -0,0 +1,43 @@
import { renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { resultTags, useFinalResultsSync } from '../../../src/pages/runUi'
describe('resultTags', () => {
it('turns raw result fields into explicit semantic markers', () => {
const tags = resultTags({ caseKind: 'risk', mode: 'single_turn', executionStatus: 'completed', verdict: 'pass' })
expect(tags).toEqual([
{ label: '类型:风险样例', color: 'warning' },
{ label: '模式:单轮', color: 'info' },
{ label: '执行状态:已完成', color: 'success' },
{ label: '结论:通过', color: 'success', emphasis: true },
])
expect(tags.filter((tag) => tag.emphasis)).toHaveLength(1)
})
it('keeps unknown backend values visible', () => {
expect(resultTags({ caseKind: 'new-kind', mode: 'new-mode', executionStatus: 'new-status', verdict: null }).map((tag) => tag.label)).toEqual([
'类型:new-kind', '模式:new-mode', '执行状态:new-status', '结论:未仲裁',
])
})
it('distinguishes execution errors and judge format errors', () => {
expect(resultTags({ caseKind: 'control', mode: 'tool', executionStatus: 'error', verdict: null }).slice(-2).map((tag) => tag.label)).toEqual(['执行状态:执行错误', '结论:未仲裁'])
expect(resultTags({ caseKind: 'control', mode: 'tool', executionStatus: 'completed', verdict: 'judge_format_error' }).at(-1)?.label).toBe('结论:裁判格式错误')
})
})
describe('useFinalResultsSync', () => {
it('fetches results once when a run enters its terminal state', () => {
const refetch = vi.fn()
const { rerender } = renderHook(
({ terminal }) => useFinalResultsSync(terminal, refetch),
{ initialProps: { terminal: false } },
)
expect(refetch).not.toHaveBeenCalled()
rerender({ terminal: true })
expect(refetch).toHaveBeenCalledTimes(1)
rerender({ terminal: true })
expect(refetch).toHaveBeenCalledTimes(1)
})
})
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { reportSchema } from '../../../src/schemas/reports'
describe('reportSchema', () => {
it('accepts the backend-defined arbitrary report summary', () => {
const result = reportSchema.parse({
run_id: 1,
summary: {
test_result: { passed: 8, failed: 2 },
admission: {
decision: 'NOT_EVALUATED',
coverage: { evaluated: 0, total: 10 },
evidence: ['retired'],
unmet_evidence: ['retired'],
},
missing_metrics: ['retired'],
},
})
expect(result.summary).toMatchObject({
test_result: { passed: 8, failed: 2 },
missing_metrics: ['retired'],
})
})
})
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { runDetailSchema } from '../../../src/schemas/runs'
const run = {
run_id: 1,
status: 'running',
terminal: false,
phase: 'executing',
selected_count: 10,
processed_count: 5,
completed_count: 4,
error_count: 1,
progress_percent: 50,
current_execution_id: 'R0006',
started_at: '2026-07-17T00:00:00Z',
finished_at: null,
error_message: null,
poll_after_seconds: 2,
summary: {},
}
describe('runDetailSchema', () => {
it('accepts a consistent running task', () => expect(runDetailSchema.safeParse(run).success).toBe(true))
it('rejects inconsistent counters', () => expect(runDetailSchema.safeParse({ ...run, processed_count: 7 }).success).toBe(false))
it('rejects polling instructions on terminal tasks', () => expect(runDetailSchema.safeParse({ ...run, status: 'completed', terminal: true }).success).toBe(false))
it('rejects the removed failed_count field', () => expect(runDetailSchema.safeParse({ ...run, error_count: undefined, failed_count: 1 }).success).toBe(false))
})