From 12d82932505f51948a1cf8cb1a0571be822f23d6 Mon Sep 17 00:00:00 2001 From: baozaotumao2025 Date: Sun, 19 Jul 2026 00:29:06 +0800 Subject: [PATCH] =?UTF-8?q?feature:=20=E5=A2=9E=E5=8A=A0=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=20=E5=AF=BC=E5=87=BA=E8=AF=A6=E7=BB=86=E6=8A=A5=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 ++ docs/api-contracts.md | 5 ++-- docs/architecture.md | 2 ++ docs/interaction-design.md | 3 +- docs/operations.md | 2 +- docs/ui-design.md | 1 + src/api/client.ts | 12 ++++---- src/pages/RunDetailPage.tsx | 24 +++++++++++++-- src/services/runService.ts | 9 ++++++ tests/integration/pages.test.tsx | 35 +++++++++++++++++++++- tests/integration/serviceContracts.test.ts | 4 +-- tests/unit/api/client.test.ts | 8 +++++ 12 files changed, 92 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 7ca9f3b..2e9772f 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ docs/ # UI、交互、架构、API、测试与部署文档 认证支持 access/refresh token 原子轮换、活跃用户静默续约、标签页内/跨标签页 single-flight,以及业务请求 401 后的一次续约与重放。跨标签页协调使用浏览器 Web Locks API。 +终态测试运行支持按执行状态和仲裁结论筛选并导出 Markdown 详细报告。 + ## 本地开发 需要 Node.js 20–24 和 Corepack。 diff --git a/docs/api-contracts.md b/docs/api-contracts.md index fe730fc..5d0609a 100644 --- a/docs/api-contracts.md +++ b/docs/api-contracts.md @@ -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 和原 key;409 保留凭据并等待 `refresh_after`。 - 普通业务请求首次 401 共享 single-flight 续约,成功后仅重放一次;只有 refresh 本身返回 401 才清除会话。 -- HTTP 204 按无响应体处理;403/404/409/422/502 映射为可操作的用户消息。 +- HTTP 204 按无响应体处理;Markdown 成功响应按 Blob 读取;错误响应仍按 JSON 归一化。403/404/409/422/502 映射为可操作的用户消息。 diff --git a/docs/architecture.md b/docs/architecture.md index c3f9622..3e1c959 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/interaction-design.md b/docs/interaction-design.md index bcd433a..118b593 100644 --- a/docs/interaction-design.md +++ b/docs/interaction-design.md @@ -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 会刷新运行详情。 ## 模型等待交互 diff --git a/docs/operations.md b/docs/operations.md index c80a478..43806b7 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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 diff --git a/docs/ui-design.md b/docs/ui-design.md index d9854e5..7da55b2 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -49,6 +49,7 @@ - 运行详情:阶段、进度、计数和当前样例位于首屏,结果和报告位于下方标签页。 - 结果卡片:顶部使用统一 Tag 明确标识“类型 / 模式 / 执行状态 / 仲裁结论”,将后端原始值转为中文语义。执行状态和仲裁结论使用两个独立筛选器,不混合 `completed/error` 与 `pass/fail`;执行 ID 搜索与两个筛选器组合生效,并支持忽略大小写的片段匹配。所有标签使用相同的 `8px` 圆角;结论只增加字重和状态点。通过为绿色、未通过或错误为红色、待复核为橙色,文字始终保留。 - 结果卡片在 Run 完成或部分错误时提供“重新执行”;使用 Ant Design 确认框说明结果将被替换。提交、运行中状态以按钮文字和信息提示表达,不只依赖颜色;Run 非终态时同 Run 的全部单条重试按钮禁用。重试目标使用信息提示说明跟踪状态并置顶显示,不修改用户已有筛选;提示提供“停止跟踪”按钮。 +- Run 详情页在逐条结果筛选栏提供 Markdown 详细报告导出,复用执行状态和仲裁结论筛选,不复制第二组筛选器,也不占用页头运行控制区;非终态保留禁用按钮以说明能力,加载状态使用按钮文字和图标表达。执行 ID 搜索只影响页面结果,不影响导出。 ## 响应式与可访问性 diff --git a/src/api/client.ts b/src/api/client.ts index f80dfdc..b9937c6 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -5,6 +5,7 @@ import { logger } from '../logging' export type RequestOptions = Omit & { body?: unknown schema?: ZodType + responseType?: 'json' | 'blob' } export class ApiClient { @@ -22,26 +23,27 @@ export class ApiClient { private async execute(path: string, options: RequestOptions, replayed: boolean): Promise { 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, diff --git a/src/pages/RunDetailPage.tsx b/src/pages/RunDetailPage.tsx index fe12227..300473a 100644 --- a/src/pages/RunDetailPage.tsx +++ b/src/pages/RunDetailPage.tsx @@ -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() {
{data.processed_count}已处理{data.completed_count}已完成{data.error_count}执行错误{data.selected_count}总样例
setFollowedExecutionId(null)} onRetry={(executionId) => Modal.confirm({ title: `确定重新执行 ${executionId} 吗?`, content: '当前测试结果将被新结果替换。', onOk: () => retryResult.mutate(executionId) })} /> }, + { key: 'results', label: `逐条结果 (${filtered.length} / ${results.data?.length || 0})`, children: exportReport.mutate()} onStopFollowing={() => setFollowedExecutionId(null)} onRetry={(executionId) => Modal.confirm({ title: `确定重新执行 ${executionId} 吗?`, content: '当前测试结果将被新结果替换。', onOk: () => retryResult.mutate(executionId) })} /> }, { key: 'report', label: '汇总报告', children: report.data ? : }, ]} /> ) } -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 - return
`仲裁结论:${label}`} options={[{ value: 'all', label: '全部' }, { value: 'pass', label: '通过' }, { value: 'fail', label: '不通过' }, { value: 'needs_human_review', label: '人工复核' }, { value: 'judge_format_error', label: '裁判格式错误' }, { value: 'unjudged', label: '未仲裁' }]} /> onExecutionIdQuery(event.target.value)} />
+ return
`仲裁结论:${label}`} options={[{ value: 'all', label: '全部' }, { value: 'pass', label: '通过' }, { value: 'fail', label: '不通过' }, { value: 'needs_human_review', label: '人工复核' }, { value: 'judge_format_error', label: '裁判格式错误' }, { value: 'unjudged', label: '未仲裁' }]} /> onExecutionIdQuery(event.target.value)} />
{followedExecutionId && 停止跟踪} />} {!rows.length ? : rows.map((row) =>
{row.execution_id}{resultTags({ caseKind: row.case_kind, mode: row.interaction_mode, executionStatus: row.execution_status, verdict: row.verdict }).map((tag) => {tag.label})}

模型输入

{JSON.stringify(row.model_input, null, 2)}

模型回复

{row.model_response || '-'}
{Object.keys(row.judge_result).length > 0 &&

Judge 结果

{JSON.stringify(row.judge_result, null, 2)}
}{row.error_message && }
)}
} diff --git a/src/services/runService.ts b/src/services/runService.ts index c4e26ac..3081704 100644 --- a/src/services/runService.ts +++ b/src/services/runService.ts @@ -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(`/runs/${id}/export${query ? `?${query}` : ''}`, { responseType: 'blob' }) + }, }) diff --git a/tests/integration/pages.test.tsx b/tests/integration/pages.test.tsx index 6426a62..bd434d6 100644 --- a/tests/integration/pages.test.tsx +++ b/tests/integration/pages.test.tsx @@ -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: '' }, diff --git a/tests/integration/serviceContracts.test.ts b/tests/integration/serviceContracts.test.ts index a777f47..705ef42 100644 --- a/tests/integration/serviceContracts.test.ts +++ b/tests/integration/serviceContracts.test.ts @@ -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 } }) }) diff --git a/tests/unit/api/client.test.ts b/tests/unit/api/client.test.ts index 20b000a..766131a 100644 --- a/tests/unit/api/client.test.ts +++ b/tests/unit/api/client.test.ts @@ -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) => {