feature: 增加功能 导出详细报告

This commit is contained in:
baozaotumao2025
2026-07-19 00:29:06 +08:00
parent 789d4fc4e9
commit 12d8293250
12 changed files with 92 additions and 15 deletions
+2
View File
@@ -23,6 +23,8 @@ docs/ # UI、交互、架构、API、测试与部署文档
认证支持 access/refresh token 原子轮换、活跃用户静默续约、标签页内/跨标签页 single-flight,以及业务请求 401 后的一次续约与重放。跨标签页协调使用浏览器 Web Locks API。
终态测试运行支持按执行状态和仲裁结论筛选并导出 Markdown 详细报告。
## 本地开发
需要 Node.js 2024 和 Corepack。
+3 -2
View File
@@ -9,7 +9,7 @@
| 提供商 | 列表、详情、创建、修改、删除 | `GET/POST /providers`, `GET/PATCH/DELETE /providers/{id}` |
| 提供商 | 连通检查、模型发现 | `POST /providers/{id}/check`, `GET /providers/{id}/models` |
| 运行 | 列表、创建、详情、取消、删除 | `GET/POST /runs`, `GET/PATCH/DELETE /runs/{id}` |
| 运行 | 恢复、重试错误、单条重试、逐条结果 | `POST /runs/{id}/resume`, `POST /runs/{id}/retry-errors`, `POST /runs/{id}/results/{execution_id}/retry`, `GET /runs/{id}/results` |
| 运行 | 恢复、重试错误、单条重试、逐条结果、导出详细报告 | `POST /runs/{id}/resume`, `POST /runs/{id}/retry-errors`, `POST /runs/{id}/results/{execution_id}/retry`, `GET /runs/{id}/results`, `GET /runs/{id}/export` |
| 报告 | 列表、详情 | `GET /reports`, `GET /reports/{run_id}` |
| 系统 | 健康状态 | `GET /health` |
@@ -19,6 +19,7 @@
- 运行详情必须满足 `processed_count = completed_count + error_count``error_count` 仅统计执行异常,不包含 `verdict = fail`;终态必须有 `poll_after_seconds = 0`
- 逐条结果使用 `execution_status``completed | error`)表示调用是否完成,使用可空 `verdict``pass | fail | needs_human_review | judge_format_error | null`)表示仲裁结论;前端不读取旧 `status`
- 逐条结果的执行 ID 搜索直接使用 `GET /runs/{id}/results` 已返回的 `execution_id` 在 UI 过滤,不新增请求参数或后端端点。
- `GET /runs/{id}/export` 仅允许终态 Run,返回 `text/markdown` 文件而非 JSON;可组合传入 `execution_status=completed|error``verdict=pass|fail|needs_human_review|judge_format_error|none`,其中 `none` 表示未评价。前端复用结果筛选器,但执行 ID 搜索不参与导出。
- 报告的 `summary.execution_statuses``summary.verdicts` 分开统计;`by_mode` 下也分别包含 `execution_statuses``verdicts`
- `POST /runs/{id}/retry-errors` 只重试 `execution_status = error`,不重试任何仲裁结论。
- `POST /runs/{id}/results/{execution_id}/retry` 无请求体,沿用原 Run 的测试范围和自动仲裁配置,并以相同 `execution_id` 替换旧结果;仅 `completed / completed_with_errors` 状态允许提交。
@@ -29,4 +30,4 @@
- access token 剩余不超过 30 分钟且用户最近 30 分钟有真实操作时,`POST /auth/refresh` 携带同一共享生成器产生的 UUID v4 `Idempotency-Key`
- 网络错误和 5xx 最多尝试 3 次,且一直复用原 refresh token 和原 key409 保留凭据并等待 `refresh_after`
- 普通业务请求首次 401 共享 single-flight 续约,成功后仅重放一次;只有 refresh 本身返回 401 才清除会话。
- HTTP 204 按无响应体处理;403/404/409/422/502 映射为可操作的用户消息。
- HTTP 204 按无响应体处理;Markdown 成功响应按 Blob 读取;错误响应仍按 JSON 归一化。403/404/409/422/502 映射为可操作的用户消息。
+2
View File
@@ -4,6 +4,8 @@
UI 页面只调用 `services/`service 先用 `schemas/` 中的 Zod schema 校验请求,再由 `api/client.ts` 发起请求并用 Zod 校验响应。跨字段业务不变量也在 schema 层完成。错误统一进入 `errors/`,日志进入 `logging/` 并在输出前递归脱敏。
API 客户端默认读取并校验 JSON;文件端点显式选择 Blob 响应类型,同时复用 Bearer Token、401 单次续约重放和 JSON 错误归一化。下载由页面使用浏览器原生 Object URL 完成,不引入额外下载依赖。
创建运行和 Token 续约统一通过 `idempotencyKey.ts` 生成 UUID v4 幂等键:安全上下文优先使用浏览器原生 `crypto.randomUUID()`HTTP 或旧浏览器回退到 `crypto.getRandomValues()`;不使用可预测的 `Math.random()`
```text
+2 -1
View File
@@ -8,10 +8,11 @@
3. 管理员在模型配置页建立 target/judge 配置,可查询模型并执行最小连通性检查。
4. 用户选择 smoke/all 与是否自动裁判,确认后创建运行。客户端自动携带 UUID 幂等键,避免重复提交。
5. 详情页展示探测、执行、裁判和完成阶段,按后端 `poll_after_seconds` 轮询。
6. 运行中可取消;中断后可恢复;局部错误可只重试错误样例;终态后可查看逐条结果、汇总报告或确认删除。
6. 运行中可取消;中断后可恢复;局部错误可只重试错误样例;终态后可查看逐条结果、汇总报告、导出 Markdown 详细报告或确认删除。
`cancelled / failed / pending / probing / running` 可确认后“恢复运行”;`completed_with_errors``error_count > 0` 可确认后“重试错误(N)”。提交期间按钮禁用,成功提示跳过或重试数量;409 会刷新详情以校正操作状态。
7. 逐条结果可按执行状态、仲裁结论筛选,并可按执行 ID 搜索;三项条件组合生效,执行 ID 支持忽略大小写的片段匹配。
8. `completed / completed_with_errors` 的每条结果可确认后单独“重新执行”。提交中仅该按钮显示“提交中…”,受理后该 Run 的全部单条重试按钮锁定并显示目标执行 ID“重新执行中…”,直到 Run 再次进入终态。目标结果会进入跟踪状态并置顶,暂时绕过执行状态、仲裁结论和执行 ID 筛选,用户可手动停止跟踪;原筛选值始终保留。
9. “导出详细报告”位于逐条结果筛选栏,复用当前执行状态和仲裁结论筛选,不占用页头运行控制区;“全部”不传参数,“未仲裁”映射为 `verdict=none`,执行 ID 搜索不参与导出。非终态按钮禁用,导出期间显示加载状态并阻止重复提交,文件名为 `run_{run_id}_results.md`409 会刷新运行详情。
## 模型等待交互
+1 -1
View File
@@ -2,7 +2,7 @@
## 测试
`tests/unit` 覆盖脱敏、错误归一化、主题持久化、API 响应校验、认证原子存储、single-flight 续约、HTTP/HTTPS UUID v4 幂等键生成、幂等重试、401 单次重放、运行数据不变量、结果语义标识和运行进入终态时的最终结果同步。`tests/integration` 验证主题切换、逐条结果筛选与搜索、单条重试结果跨筛选置顶跟踪等页面交互,以及所有 service 到后端路由的映射。
`tests/unit` 覆盖脱敏、错误归一化、主题持久化、JSON/Blob API 响应、认证原子存储、single-flight 续约、HTTP/HTTPS UUID v4 幂等键生成、幂等重试、401 单次重放、运行数据不变量、结果语义标识和运行进入终态时的最终结果同步。`tests/integration` 验证主题切换、逐条结果筛选与搜索、终态 Markdown 导出、单条重试结果跨筛选置顶跟踪等页面交互,以及所有 service 到后端路由的映射。
```bash
pnpm test
+1
View File
@@ -49,6 +49,7 @@
- 运行详情:阶段、进度、计数和当前样例位于首屏,结果和报告位于下方标签页。
- 结果卡片:顶部使用统一 Tag 明确标识“类型 / 模式 / 执行状态 / 仲裁结论”,将后端原始值转为中文语义。执行状态和仲裁结论使用两个独立筛选器,不混合 `completed/error``pass/fail`;执行 ID 搜索与两个筛选器组合生效,并支持忽略大小写的片段匹配。所有标签使用相同的 `8px` 圆角;结论只增加字重和状态点。通过为绿色、未通过或错误为红色、待复核为橙色,文字始终保留。
- 结果卡片在 Run 完成或部分错误时提供“重新执行”;使用 Ant Design 确认框说明结果将被替换。提交、运行中状态以按钮文字和信息提示表达,不只依赖颜色;Run 非终态时同 Run 的全部单条重试按钮禁用。重试目标使用信息提示说明跟踪状态并置顶显示,不修改用户已有筛选;提示提供“停止跟踪”按钮。
- Run 详情页在逐条结果筛选栏提供 Markdown 详细报告导出,复用执行状态和仲裁结论筛选,不复制第二组筛选器,也不占用页头运行控制区;非终态保留禁用按钮以说明能力,加载状态使用按钮文字和图标表达。执行 ID 搜索只影响页面结果,不影响导出。
## 响应式与可访问性
+7 -5
View File
@@ -5,6 +5,7 @@ import { logger } from '../logging'
export type RequestOptions<T> = Omit<RequestInit, 'body'> & {
body?: unknown
schema?: ZodType<T>
responseType?: 'json' | 'blob'
}
export class ApiClient {
@@ -22,26 +23,27 @@ export class ApiClient {
private async execute<T>(path: string, options: RequestOptions<T>, replayed: boolean): Promise<T> {
const token = this.getToken()
const headers = new Headers(options.headers)
headers.set('Accept', 'application/json')
headers.set('Accept', options.responseType === 'blob' ? 'text/markdown' : 'application/json')
if (options.body !== undefined) headers.set('Content-Type', 'application/json')
if (token) headers.set('Authorization', `Bearer ${token}`)
try {
const { responseType, schema, ...requestOptions } = options
const response = await fetch(`${this.baseUrl}${path}`, {
...options,
...requestOptions,
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
})
if (response.status === 204) return undefined as T
const body = await response.json().catch(() => null)
const body = response.ok && responseType === 'blob' ? await response.blob() : await response.json().catch(() => null)
if (!response.ok) {
if (response.status === 401 && !replayed && await this.refreshAuth?.(true)) {
return this.execute(path, options, true)
}
throw { status: response.status, body }
}
if (!options.schema) return body as T
const parsed = options.schema.safeParse(body)
if (!schema) return body as T
const parsed = schema.safeParse(body)
if (!parsed.success) {
throw new AppError('服务响应格式异常', 'validation', response.status, 'INVALID_RESPONSE', undefined, {
issues: parsed.error.issues,
+21 -3
View File
@@ -43,6 +43,24 @@ export function RunDetailPage() {
message.error(status === undefined || (typeof status === 'number' && status >= 500) ? '重新执行提交失败,请稍后重试' : error.message)
},
})
const exportReport = useMutation({
mutationFn: () => services.runs.export(id, {
executionStatus: executionFilter === 'all' ? undefined : executionFilter as 'completed' | 'error',
verdict: verdictFilter === 'all' ? undefined : verdictFilter === 'unjudged' ? 'none' : verdictFilter as 'pass' | 'fail' | 'needs_human_review' | 'judge_format_error',
}),
onSuccess: (blob) => {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `run_${id}_results.md`
link.click()
URL.revokeObjectURL(url)
},
onError: (error) => {
message.error(error.message)
if ('status' in error && error.status === 409) refresh()
},
})
const action = useMutation({
mutationFn: async (name: 'cancel' | 'resume' | 'retry' | 'delete') => {
if (name === 'cancel') return services.runs.cancel(id)
@@ -83,16 +101,16 @@ export function RunDetailPage() {
<div className="stat-strip"><span><strong>{data.processed_count}</strong></span><span><strong>{data.completed_count}</strong></span><span><strong>{data.error_count}</strong></span><span><strong>{data.selected_count}</strong></span></div>
</Card>
<Tabs items={[
{ key: 'results', label: `逐条结果 (${filtered.length} / ${results.data?.length || 0})`, children: <ResultsPanel rows={filtered} executionFilter={executionFilter} verdictFilter={verdictFilter} executionIdQuery={executionIdQuery} onExecutionFilter={setExecutionFilter} onVerdictFilter={setVerdictFilter} onExecutionIdQuery={setExecutionIdQuery} loading={results.isLoading} canRetry={['completed', 'completed_with_errors'].includes(data.status)} retryingExecutionId={activeRetryingExecutionId} followedExecutionId={followedExecutionId} submittingExecutionId={retryResult.isPending ? retryResult.variables : null} onStopFollowing={() => setFollowedExecutionId(null)} onRetry={(executionId) => Modal.confirm({ title: `确定重新执行 ${executionId} 吗?`, content: '当前测试结果将被新结果替换。', onOk: () => retryResult.mutate(executionId) })} /> },
{ key: 'results', label: `逐条结果 (${filtered.length} / ${results.data?.length || 0})`, children: <ResultsPanel rows={filtered} executionFilter={executionFilter} verdictFilter={verdictFilter} executionIdQuery={executionIdQuery} onExecutionFilter={setExecutionFilter} onVerdictFilter={setVerdictFilter} onExecutionIdQuery={setExecutionIdQuery} loading={results.isLoading} canRetry={['completed', 'completed_with_errors'].includes(data.status)} canExport={data.terminal} exporting={exportReport.isPending} retryingExecutionId={activeRetryingExecutionId} followedExecutionId={followedExecutionId} submittingExecutionId={retryResult.isPending ? retryResult.variables : null} onExport={() => exportReport.mutate()} onStopFollowing={() => setFollowedExecutionId(null)} onRetry={(executionId) => Modal.confirm({ title: `确定重新执行 ${executionId} 吗?`, content: '当前测试结果将被新结果替换。', onOk: () => retryResult.mutate(executionId) })} /> },
{ key: 'report', label: '汇总报告', children: report.data ? <ReportSummary summary={report.data.summary} /> : <Empty description={data.terminal ? '暂无报告' : '运行结束后生成报告'} /> },
]} />
</>
)
}
function ResultsPanel({ rows, executionFilter, verdictFilter, executionIdQuery, onExecutionFilter, onVerdictFilter, onExecutionIdQuery, loading, canRetry, retryingExecutionId, followedExecutionId, submittingExecutionId, onStopFollowing, onRetry }: { rows: ResultItem[]; executionFilter: string; verdictFilter: string; executionIdQuery: string; onExecutionFilter: (value: string) => void; onVerdictFilter: (value: string) => void; onExecutionIdQuery: (value: string) => void; loading: boolean; canRetry: boolean; retryingExecutionId: string | null; followedExecutionId: string | null; submittingExecutionId: string | null; onStopFollowing: () => void; onRetry: (executionId: string) => void }) {
function ResultsPanel({ rows, executionFilter, verdictFilter, executionIdQuery, onExecutionFilter, onVerdictFilter, onExecutionIdQuery, loading, canRetry, canExport, exporting, retryingExecutionId, followedExecutionId, submittingExecutionId, onExport, onStopFollowing, onRetry }: { rows: ResultItem[]; executionFilter: string; verdictFilter: string; executionIdQuery: string; onExecutionFilter: (value: string) => void; onVerdictFilter: (value: string) => void; onExecutionIdQuery: (value: string) => void; loading: boolean; canRetry: boolean; canExport: boolean; exporting: boolean; retryingExecutionId: string | null; followedExecutionId: string | null; submittingExecutionId: string | null; onExport: () => void; onStopFollowing: () => void; onRetry: (executionId: string) => void }) {
if (loading) return <LoadingState />
return <div className="results-panel"><div className="action-group"><Select aria-label="执行状态" value={executionFilter} onChange={onExecutionFilter} labelRender={({ label }) => `执行状态:${label}`} options={[{ value: 'all', label: '全部' }, { value: 'completed', label: '已完成' }, { value: 'error', label: '执行错误' }]} /><Select aria-label="仲裁结论" value={verdictFilter} onChange={onVerdictFilter} labelRender={({ label }) => `仲裁结论:${label}`} options={[{ value: 'all', label: '全部' }, { value: 'pass', label: '通过' }, { value: 'fail', label: '不通过' }, { value: 'needs_human_review', label: '人工复核' }, { value: 'judge_format_error', label: '裁判格式错误' }, { value: 'unjudged', label: '未仲裁' }]} /><Input type="search" allowClear aria-label="执行 ID" placeholder="根据执行 ID 搜索,如 R0029" value={executionIdQuery} onChange={(event) => onExecutionIdQuery(event.target.value)} /></div>
return <div className="results-panel"><div className="action-group"><Select aria-label="执行状态" value={executionFilter} onChange={onExecutionFilter} labelRender={({ label }) => `执行状态:${label}`} options={[{ value: 'all', label: '全部' }, { value: 'completed', label: '已完成' }, { value: 'error', label: '执行错误' }]} /><Select aria-label="仲裁结论" value={verdictFilter} onChange={onVerdictFilter} labelRender={({ label }) => `仲裁结论:${label}`} options={[{ value: 'all', label: '全部' }, { value: 'pass', label: '通过' }, { value: 'fail', label: '不通过' }, { value: 'needs_human_review', label: '人工复核' }, { value: 'judge_format_error', label: '裁判格式错误' }, { value: 'unjudged', label: '未仲裁' }]} /><Input type="search" allowClear aria-label="执行 ID" placeholder="根据执行 ID 搜索,如 R0029" value={executionIdQuery} onChange={(event) => onExecutionIdQuery(event.target.value)} /><Button type="primary" disabled={!canExport} loading={exporting} title={canExport ? undefined : '运行结束后可导出'} onClick={onExport}></Button></div>
{followedExecutionId && <Alert type="info" showIcon message={retryingExecutionId ? `${followedExecutionId} 重新执行中…` : `正在跟踪 ${followedExecutionId};即使不符合当前筛选也会置顶显示。`} action={<Button size="small" onClick={onStopFollowing}></Button>} />}
{!rows.length ? <Empty description={followedExecutionId ? '正在等待跟踪结果…' : '暂无符合条件的结果'} /> : rows.map((row) => <Card key={row.execution_id} className="result-card"><div className="result-title"><strong>{row.execution_id}</strong><span className="result-tags" aria-label="结果标识">{resultTags({ caseKind: row.case_kind, mode: row.interaction_mode, executionStatus: row.execution_status, verdict: row.verdict }).map((tag) => <Tag key={tag.label} className={`result-tag-${tag.color}${tag.emphasis ? ' result-verdict' : ''}`}>{tag.label}</Tag>)}</span><Button disabled={!canRetry || Boolean(retryingExecutionId) || Boolean(submittingExecutionId)} loading={submittingExecutionId === row.execution_id} onClick={() => onRetry(row.execution_id)} aria-label={submittingExecutionId === row.execution_id ? '提交中…' : retryingExecutionId === row.execution_id ? '重新执行中…' : `重新执行 ${row.execution_id}`}>{submittingExecutionId === row.execution_id ? '提交中…' : retryingExecutionId === row.execution_id ? '重新执行中…' : '重新执行'}</Button></div><div className="result-columns"><section><h3></h3><pre>{JSON.stringify(row.model_input, null, 2)}</pre></section><section><h3></h3><pre>{row.model_response || '-'}</pre></section></div>{Object.keys(row.judge_result).length > 0 && <section className="judge-result"><h3>Judge </h3><pre>{JSON.stringify(row.judge_result, null, 2)}</pre></section>}{row.error_message && <Alert type="error" message={row.error_message} />}</Card>)}</div>
}
+9
View File
@@ -2,6 +2,8 @@ import type { ApiClient } from '../api/client'
import { createIdempotencyKey } from '../idempotencyKey'
import { resultListSchema, retryErrorsSchema, retryResultSchema, runDetailSchema, runListSchema, runResponseSchema, startRunSchema, resumeRunSchema, type StartRunInput } from '../schemas/runs'
type ExportFilters = { executionStatus?: 'completed' | 'error'; verdict?: 'pass' | 'fail' | 'needs_human_review' | 'judge_format_error' | 'none' }
export const runService = (api: ApiClient) => ({
list: (limit = 50) => api.request(`/runs?limit=${limit}`, { schema: runListSchema }),
get: (id: number) => api.request(`/runs/${id}`, { schema: runDetailSchema }),
@@ -13,4 +15,11 @@ export const runService = (api: ApiClient) => ({
retryErrors: (id: number) => api.request(`/runs/${id}/retry-errors`, { method: 'POST', schema: retryErrorsSchema }),
retryResult: (id: number, executionId: string) => api.request(`/runs/${id}/results/${encodeURIComponent(executionId)}/retry`, { method: 'POST', schema: retryResultSchema }),
results: (id: number) => api.request(`/runs/${id}/results`, { schema: resultListSchema }),
export: (id: number, filters: ExportFilters = {}) => {
const params = new URLSearchParams()
if (filters.executionStatus) params.set('execution_status', filters.executionStatus)
if (filters.verdict) params.set('verdict', filters.verdict)
const query = params.toString()
return api.request<Blob>(`/runs/${id}/export${query ? `?${query}` : ''}`, { responseType: 'blob' })
},
})
+34 -1
View File
@@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({
retryErrors: vi.fn(),
retryResult: vi.fn(),
results: vi.fn(),
exportRun: vi.fn(),
reports: vi.fn(),
report: vi.fn(),
}))
@@ -24,7 +25,7 @@ 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 },
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 },
} }))
@@ -56,6 +57,7 @@ beforeEach(() => {
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: {} } } })
})
@@ -64,6 +66,7 @@ afterEach(() => {
cleanup()
localStorage.clear()
vi.clearAllMocks()
vi.unstubAllGlobals()
})
describe('page mounting and route security', () => {
@@ -122,6 +125,36 @@ describe('page mounting and route security', () => {
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: '' },
+2 -2
View File
@@ -37,13 +37,13 @@ describe('service endpoint contracts', () => {
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 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.export(1, { executionStatus: 'error', verdict: 'none' }); 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',
'/runs/1/retry-errors', '/runs/1/results/R0049/retry', '/runs/1/results', '/runs/1/export?execution_status=error&verdict=none', '/runs/1', '/reports?limit=50', '/reports/1',
])
expect(vi.mocked(api.request).mock.calls[10][1]).toMatchObject({ headers: { 'Idempotency-Key': idempotencyKey } })
})
+8
View File
@@ -17,6 +17,14 @@ describe('ApiClient', () => {
).rejects.toMatchObject({ kind: 'validation' })
})
it('returns file responses without parsing them as JSON', async () => {
const blob = new Blob(['# report'], { type: 'text/markdown' })
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(blob))
await expect(new ApiClient('/api', () => 'token').request('/runs/1/export', { responseType: 'blob' })).resolves.toEqual(expect.any(Blob))
expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get('Accept')).toBe('text/markdown')
})
it('refreshes and replays a business request once after 401', async () => {
let token = 'old-token'
const refresh = vi.fn(async (force?: boolean) => {