AI 大模型接入 + 自动化测试(手动/AI/定时三种触发)

- 自动化测试核心(packages/core/src/testing.ts):
  - JSONPath 求值($.a.b[0].c)、断言评估(状态码/耗时/Header/Body × 10 种操作)
  - 变量提取(响应 → 当前环境,{{var}} 链式传值)、Markdown 报告导出
- 用例模型:场景用例 = 有序步骤(引用 HTTP 接口 + 断言 + 提取),失败后继续可选
- 执行器 testRunner + 定时调度器 scheduler(应用运行期间按间隔自动执行)
- TestingView:用例列表 / 步骤编排 / 断言与提取编辑器 / 报告(统计卡+断言明细+过滤+再次运行+导出)
- AI 助手:OpenAI 兼容客户端(DeepSeek/智谱/Kimi/OpenAI/Ollama 预设)
  - 设置 → AI 助手:供应商 CRUD + 测试连接 + 当前使用切换
  - AI 生成场景用例:读取集合接口清单 → 编排步骤/断言/提取 → 预览保存
- 入口:侧栏底部「自动化测试」+ ⋯ 菜单;文档新增 /docs/testing /docs/ai
- 新增 11 个引擎单测(总 48 前端用例全绿);本地 E2E:运行通过+断言通过+petId 提取验证
This commit is contained in:
logan committed 2026-09-18 01:57:57 +08:00
1 parent 9a019408ab
commit 1bcd7be2ac
23 files changed
+2538 -7

No files matched your search

+693
View File
@@ -0,0 +1,693 @@
/**
* 自动化测试视图:场景用例列表 / 步骤编排(断言 + 变量提取)/ 运行报告 / 定时任务。
* 三种触发方式:手动运行(本视图)、AI 生成(AiCaseDialog)、定时调度(scheduler + 定时设置)。
*/
import type {
AssertOp,
AssertTarget,
Collection,
HttpRequestData,
RequestNode,
TestCase,
TestRun,
} from "@opsxapi/core";
import {
ASSERT_OP_LABELS,
exportRunMarkdown,
newAssertion,
newExtract,
newStep,
runStats,
} from "@opsxapi/core";
import { useEffect, useMemo, useState } from "react";
import { nextRunAt } from "../scheduler";
import { runTestCase } from "../testRunner";
import { useWorkspace } from "../workspaceStore";
import { cx } from "./common";
import { Icon } from "./icons";
import { AiCaseDialog } from "./dialogs/AiCaseDialog";
import { ScheduleDialog } from "./dialogs/ScheduleDialog";
const TARGET_LABELS: Record<AssertTarget, string> = {
status: "状态码",
timeMs: "耗时(ms)",
header: "Header",
body: "Body",
};
/** 汇总所有集合中的 HTTP 节点为选择器选项 */
function useHttpNodeOptions(): { id: string; label: string; method: string }[] {
const collections = useWorkspace((s) => s.collections);
return useMemo(() => {
const out: { id: string; label: string; method: string }[] = [];
const walk = (nodes: Collection["children"], prefix: string): void => {
for (const n of nodes) {
if (n.type === "folder") walk(n.children, prefix ? `${prefix}/${n.name}` : n.name);
else if (n.kind === "http") {
const d = (n as RequestNode & { kind: "http" }).data as HttpRequestData;
out.push({ id: n.id, label: `${prefix ? `${prefix}/` : ""}${n.name}`, method: d.method });
}
}
};
for (const c of collections) walk(c.children, c.name);
return out;
}, [collections]);
}
export function TestingView() {
const testCases = useWorkspace((s) => s.testCases);
const testRuns = useWorkspace((s) => s.testRuns);
const schedules = useWorkspace((s) => s.schedules);
const setView = useWorkspace((s) => s.setView);
const addTestCase = useWorkspace((s) => s.addTestCase);
const [selected, setSelected] = useState<string | null>(testCases[0]?.id ?? null);
const [showAi, setShowAi] = useState(false);
const [showSchedule, setShowSchedule] = useState(false);
const [runAllBusy, setRunAllBusy] = useState(false);
const tc = testCases.find((c) => c.id === selected) ?? null;
const lastRunOf = (caseId: string): TestRun | undefined => testRuns.find((r) => r.caseId === caseId);
const runAll = async () => {
setRunAllBusy(true);
try {
for (const c of testCases) await runTestCase(c.id, "manual");
} finally {
setRunAllBusy(false);
}
};
return (
<div className="flex min-h-0 flex-1">
{/* 左栏:用例列表 */}
<aside className="ops-panel flex w-64 shrink-0 flex-col" style={{ borderRight: "1px solid var(--border)" }}>
<div className="flex items-center gap-1.5 p-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
<Icon name="list-checks" size={15} className="ops-accent" />
<span className="text-[13px] font-semibold">场景用例</span>
<span className="ops-faint ml-auto text-[11px]">{testCases.length}</span>
</div>
<div className="p-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
<div className="grid grid-cols-2 gap-1.5">
<button className="ops-btn ops-btn-secondary !px-2" onClick={() => setSelected(addTestCase())}>
<Icon name="plus" size={13} /> 新建
</button>
<button className="ops-btn ops-btn-secondary !px-2" onClick={() => setShowAi(true)}>
<Icon name="bot" size={13} /> AI 生成
</button>
</div>
</div>
<div className="ops-scroll min-h-0 flex-1 p-2">
{testCases.length === 0 && (
<div className="ops-faint px-2 py-8 text-center text-xs leading-5">
还没有用例
<br />
点「新建」手动编排,或用「AI 生成」
</div>
)}
{testCases.map((c) => {
const last = lastRunOf(c.id);
const sched = schedules.find((s) => s.caseId === c.id && s.enabled);
return (
<button
key={c.id}
className={cx("ops-tree-row w-full text-left", selected === c.id && "ops-tree-row-active")}
onClick={() => setSelected(c.id)}
>
<span className="truncate text-[12px] font-medium">{c.name}</span>
<span className="ops-faint ml-auto shrink-0 text-[10px]">{c.steps.length} 步</span>
{sched && <Icon name="calendar-clock" size={11} className="ops-accent" />}
{last && (
<Icon
name={last.passed ? "circle-check" : "circle-x"}
size={12}
className={last.passed ? "ops-success" : "ops-danger"}
/>
)}
</button>
);
})}
</div>
<div className="space-y-1.5 p-2.5" style={{ borderTop: "1px solid var(--border)" }}>
<button className="ops-btn ops-btn-secondary w-full" onClick={() => setShowSchedule(true)}>
<Icon name="calendar-clock" size={13} /> 定时任务
{schedules.filter((s) => s.enabled).length > 0 && (
<span className="ops-count">{schedules.filter((s) => s.enabled).length}</span>
)}
</button>
<button className="ops-btn ops-btn-primary w-full" disabled={runAllBusy || testCases.length === 0} onClick={runAll}>
<Icon name="play" size={13} /> {runAllBusy ? "运行中…" : "运行全部"}
</button>
<button className="ops-btn ops-btn-ghost w-full" onClick={() => setView("api")}>
<Icon name="arrow-left-right" size={13} /> 返回接口工作台
</button>
</div>
</aside>
{/* 右侧:用例详情 */}
{tc ? (
<CaseDetail key={tc.id} tc={tc} />
) : (
<div className="grid min-w-0 flex-1 place-items-center">
<div className="text-center">
<div className="ops-dim mb-2 flex justify-center"><Icon name="list-checks" size={40} /></div>
<div className="ops-dim text-sm font-semibold">自动化测试</div>
<div className="ops-faint mt-1 text-xs leading-5">
手动编排 · AI 生成 · 定时调度,三种方式共用同一套场景用例
<br />
左侧新建用例,或从「⋯ 菜单 → 自动化测试」随时返回
</div>
</div>
</div>
)}
{showAi && <AiCaseDialog onClose={() => setShowAi(false)} onSaved={(id) => setSelected(id)} />}
{showSchedule && <ScheduleDialog onClose={() => setShowSchedule(false)} />}
</div>
);
}
/* ---------------- 用例详情:编辑 + 报告 ---------------- */
function CaseDetail({ tc }: { tc: TestCase }) {
const updateTestCase = useWorkspace((s) => s.updateTestCase);
const removeTestCase = useWorkspace((s) => s.removeTestCase);
const allRuns = useWorkspace((s) => s.testRuns);
const runs = useMemo(() => allRuns.filter((r) => r.caseId === tc.id), [allRuns, tc.id]);
const schedules = useWorkspace((s) => s.schedules);
const nodeOptions = useHttpNodeOptions();
const [tab, setTab] = useState<"edit" | "report">("edit");
const [busy, setBusy] = useState(false);
const sched = schedules.find((s) => s.caseId === tc.id);
const patch = (p: Partial<TestCase>) => updateTestCase(tc.id, p);
const patchStep = (stepId: string, p: Partial<TestCase["steps"][number]>) =>
patch({ steps: tc.steps.map((st) => (st.id === stepId ? { ...st, ...p } : st)) });
const moveStep = (idx: number, dir: -1 | 1) => {
const steps = [...tc.steps];
const j = idx + dir;
if (j < 0 || j >= steps.length) return;
[steps[idx], steps[j]] = [steps[j], steps[idx]];
patch({ steps });
};
const run = async () => {
setBusy(true);
setTab("report");
try {
await runTestCase(tc.id, "manual");
} finally {
setBusy(false);
}
};
return (
<div className="flex min-w-0 flex-1 flex-col">
{/* 头部 */}
<div className="flex flex-wrap items-center gap-2 px-3 py-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
<input
className="ops-input ops-input-sm min-w-40 flex-1 font-semibold"
value={tc.name}
onChange={(e) => patch({ name: e.target.value })}
placeholder="用例名称"
/>
<label className="ops-faint flex items-center gap-1.5 text-[11px]" title="某步失败后是否继续执行后续步骤">
<input
type="checkbox"
checked={tc.continueOnFailure}
onChange={(e) => patch({ continueOnFailure: e.target.checked })}
/>
失败后继续
</label>
<ScheduleInline caseId={tc.id} />
<button className="ops-btn ops-btn-primary" disabled={busy || tc.steps.length === 0} onClick={run}>
<Icon name="play" size={13} /> {busy ? "运行中…" : "运行"}
</button>
<button
className="ops-btn ops-btn-ghost ops-danger"
title="删除用例"
onClick={() => {
if (confirm(`删除用例「${tc.name}」及其运行记录?`)) removeTestCase(tc.id);
}}
>
<Icon name="trash" size={13} />
</button>
</div>
{/* Tabs */}
<div className="flex shrink-0 px-2" style={{ borderBottom: "1px solid var(--border)" }}>
<button className={cx("ops-tab", tab === "edit" && "ops-tab-active")} onClick={() => setTab("edit")}>
用例编排
</button>
<button className={cx("ops-tab", tab === "report" && "ops-tab-active")} onClick={() => setTab("report")}>
测试报告 {runs.length > 0 && <span className="ops-count">{runs.length}</span>}
</button>
</div>
{tab === "edit" ? (
<div className="ops-scroll flex-1 p-3.5">
<textarea
className="ops-input mb-3 min-h-14 w-full resize-y text-[12px]"
placeholder="用例说明(可选):场景目的、前置条件…"
value={tc.description ?? ""}
onChange={(e) => patch({ description: e.target.value })}
/>
{tc.steps.length === 0 && (
<div className="ops-faint py-8 text-center text-xs leading-5">
还没有步骤,点击下方「添加步骤」从集合中选择 HTTP 接口
</div>
)}
<div className="space-y-2.5">
{tc.steps.map((step, idx) => (
<StepEditor
key={step.id}
index={idx}
step={step}
nodeOptions={nodeOptions}
onPatch={(p) => patchStep(step.id, p)}
onRemove={() => patch({ steps: tc.steps.filter((st) => st.id !== step.id) })}
onMove={(dir) => moveStep(idx, dir)}
/>
))}
</div>
<div className="mt-3">
<button
className="ops-btn ops-btn-secondary w-full border-dashed"
onClick={() => patch({ steps: [...tc.steps, newStep("")] })}
>
<Icon name="plus" size={13} /> 添加步骤
</button>
</div>
</div>
) : (
<ReportPanel caseId={tc.id} />
)}
</div>
);
}
/* ---------------- 单步骤编辑 ---------------- */
function StepEditor({
index,
step,
nodeOptions,
onPatch,
onRemove,
onMove,
}: {
index: number;
step: TestCase["steps"][number];
nodeOptions: { id: string; label: string; method: string }[];
onPatch: (p: Partial<TestCase["steps"][number]>) => void;
onRemove: () => void;
onMove: (dir: -1 | 1) => void;
}) {
const node = nodeOptions.find((o) => o.id === step.nodeId);
const [showDetail, setShowDetail] = useState(true);
return (
<div className={cx("rounded-xl", !step.enabled && "opacity-50")} style={{ border: "1px solid var(--border)" }}>
<div className="flex flex-wrap items-center gap-1.5 px-3 py-2" style={{ borderBottom: showDetail ? "1px solid var(--border)" : "none" }}>
<span className="ops-faint w-5 text-center text-[11px] font-bold">{index + 1}</span>
<select
className="ops-input ops-input-sm min-w-52 flex-1"
value={step.nodeId}
onChange={(e) => onPatch({ nodeId: e.target.value })}
>
<option value="">选择 HTTP 接口…</option>
{nodeOptions.map((o) => (
<option key={o.id} value={o.id}>
{o.method} · {o.label}
</option>
))}
</select>
{node && (
<span className={cx("ops-chip", `ops-method-${node.method}`)} style={{ padding: "1px 8px", fontSize: 10 }}>
{node.method}
</span>
)}
<button className="ops-btn ops-btn-ghost px-1.5" title="上移" onClick={() => onMove(-1)}>
<Icon name="chevron-right" size={12} style={{ transform: "rotate(-90deg)" }} />
</button>
<button className="ops-btn ops-btn-ghost px-1.5" title="下移" onClick={() => onMove(1)}>
<Icon name="chevron-down" size={12} />
</button>
<button
className="ops-btn ops-btn-ghost px-1.5"
title={step.enabled ? "禁用步骤" : "启用步骤"}
onClick={() => onPatch({ enabled: !step.enabled })}
>
<Icon name={step.enabled ? "circle-check" : "circle-x"} size={13} className={step.enabled ? "ops-success" : "ops-faint"} />
</button>
<button className="ops-btn ops-btn-ghost px-1.5" title="删除步骤" onClick={onRemove}>
<Icon name="trash" size={13} />
</button>
</div>
{showDetail && (
<div className="space-y-2.5 px-3 py-2.5">
{/* 断言 */}
<div>
<div className="ops-faint mb-1 text-[11px] font-semibold uppercase tracking-wider">断言</div>
{step.assertions.map((a) => (
<div key={a.id} className="test-assert-row">
<select
className="ops-input ops-input-sm"
value={a.target}
onChange={(e) =>
onPatch({
assertions: step.assertions.map((x) =>
x.id === a.id ? { ...x, target: e.target.value as AssertTarget, locator: "", expected: "" } : x,
),
})
}
>
{(Object.keys(TARGET_LABELS) as AssertTarget[]).map((t) => (
<option key={t} value={t}>
{TARGET_LABELS[t]}
</option>
))}
</select>
{(a.target === "header" || a.op.startsWith("jsonpath")) && (
<input
className="ops-input ops-input-sm test-assert-locator"
placeholder={a.target === "header" ? "Header 名" : "$.data.id"}
value={a.locator}
onChange={(e) =>
onPatch({ assertions: step.assertions.map((x) => (x.id === a.id ? { ...x, locator: e.target.value } : x)) })
}
/>
)}
<select
className="ops-input ops-input-sm"
value={a.op}
onChange={(e) =>
onPatch({ assertions: step.assertions.map((x) => (x.id === a.id ? { ...x, op: e.target.value as AssertOp } : x)) })
}
>
{(Object.keys(ASSERT_OP_LABELS) as AssertOp[]).map((op) => (
<option key={op} value={op}>
{ASSERT_OP_LABELS[op]}
</option>
))}
</select>
{a.op !== "exists" && a.op !== "not_exists" && (
<input
className="ops-input ops-input-sm test-assert-expected"
placeholder="期望值"
value={a.expected}
onChange={(e) =>
onPatch({ assertions: step.assertions.map((x) => (x.id === a.id ? { ...x, expected: e.target.value } : x)) })
}
/>
)}
<button
className="ops-btn ops-btn-ghost px-1.5"
title={a.enabled ? "禁用" : "启用"}
onClick={() =>
onPatch({ assertions: step.assertions.map((x) => (x.id === a.id ? { ...x, enabled: !x.enabled } : x)) })
}
>
<Icon name={a.enabled ? "circle-check" : "circle-x"} size={12} className={a.enabled ? "ops-success" : "ops-faint"} />
</button>
<button
className="ops-btn ops-btn-ghost ops-danger px-1.5"
title="删除断言"
onClick={() => onPatch({ assertions: step.assertions.filter((x) => x.id !== a.id) })}
>
<Icon name="x" size={12} />
</button>
</div>
))}
<button className="ops-btn ops-btn-ghost mt-1 !py-0.5 text-[11px]" onClick={() => onPatch({ assertions: [...step.assertions, newAssertion()] })}>
<Icon name="plus" size={11} /> 添加断言
</button>
</div>
{/* 变量提取 */}
<div>
<div className="ops-faint mb-1 text-[11px] font-semibold uppercase tracking-wider">
变量提取 <span className="normal-case">(响应 → 当前环境,后续步骤用 {"{{变量名}}"} 引用)</span>
</div>
{step.extracts.length === 0 && (
<button
className="ops-btn ops-btn-ghost !py-0.5 text-[11px]"
onClick={() => onPatch({ extracts: [...step.extracts, newExtract()] })}
>
<Icon name="plus" size={11} /> 添加提取
</button>
)}
{step.extracts.map((ex) => (
<div key={ex.id} className="test-assert-row">
<input
className="ops-input ops-input-sm test-assert-locator"
placeholder="$.data.token"
value={ex.jsonpath}
onChange={(e) =>
onPatch({ extracts: step.extracts.map((x) => (x.id === ex.id ? { ...x, jsonpath: e.target.value } : x)) })
}
/>
<span className="ops-faint text-[11px]">→</span>
<input
className="ops-input ops-input-sm test-assert-expected"
placeholder="变量名 token"
value={ex.varName}
onChange={(e) =>
onPatch({ extracts: step.extracts.map((x) => (x.id === ex.id ? { ...x, varName: e.target.value } : x)) })
}
/>
<button
className="ops-btn ops-btn-ghost ops-danger px-1.5"
title="删除"
onClick={() => onPatch({ extracts: step.extracts.filter((x) => x.id !== ex.id) })}
>
<Icon name="x" size={12} />
</button>
</div>
))}
{step.extracts.length > 0 && (
<button className="ops-btn ops-btn-ghost mt-1 !py-0.5 text-[11px]" onClick={() => onPatch({ extracts: [...step.extracts, newExtract()] })}>
<Icon name="plus" size={11} /> 添加提取
</button>
)}
</div>
</div>
)}
</div>
);
}
/* ---------------- 行内定时配置 ---------------- */
function ScheduleInline({ caseId }: { caseId: string }) {
const schedules = useWorkspace((s) => s.schedules);
const upsertSchedule = useWorkspace((s) => s.upsertSchedule);
const removeSchedule = useWorkspace((s) => s.removeSchedule);
const sc = schedules.find((s) => s.caseId === caseId);
if (!sc) {
return (
<button className="ops-btn ops-btn-secondary !px-2" title="设置定时运行" onClick={() => upsertSchedule(caseId, {})}>
<Icon name="calendar-clock" size={13} />
</button>
);
}
return (
<span className="flex items-center gap-1">
<select
className="ops-input ops-input-sm !w-24"
value={sc.intervalMinutes}
onChange={(e) => upsertSchedule(caseId, { intervalMinutes: Number(e.target.value) })}
title={`下次运行:${new Date(nextRunAt(sc.intervalMinutes, sc.lastRunAt)).toLocaleTimeString("zh-CN")}`}
>
{[5, 15, 30, 60, 360, 1440].map((m) => (
<option key={m} value={m}>
{m < 60 ? `每 ${m} 分钟` : m < 1440 ? `每 ${m / 60} 小时` : "每天"}
</option>
))}
</select>
<button
className="ops-btn ops-btn-ghost px-1.5"
title={sc.enabled ? "暂停定时" : "启用定时"}
onClick={() => upsertSchedule(caseId, { enabled: !sc.enabled })}
>
<Icon name={sc.enabled ? "calendar-clock" : "circle-x"} size={13} className={sc.enabled ? "ops-accent" : "ops-faint"} />
</button>
<button className="ops-btn ops-btn-ghost ops-danger px-1.5" title="删除定时" onClick={() => removeSchedule(caseId)}>
<Icon name="x" size={12} />
</button>
</span>
);
}
/* ---------------- 运行报告 ---------------- */
function ReportPanel({ caseId }: { caseId: string }) {
const allRuns = useWorkspace((s) => s.testRuns);
const runs = useMemo(() => allRuns.filter((r) => r.caseId === caseId), [allRuns, caseId]);
const [selectedRun, setSelectedRun] = useState<string | null>(null);
const [filter, setFilter] = useState<"all" | "pass" | "fail">("all");
const run = runs.find((r) => r.id === selectedRun) ?? runs[0];
const [busy, setBusy] = useState(false);
useEffect(() => {
if (run && selectedRun && !runs.some((r) => r.id === selectedRun)) setSelectedRun(null);
}, [runs, selectedRun, run]);
if (!run) {
return (
<div className="ops-faint grid flex-1 place-items-center text-center text-xs leading-5">
还没有运行记录
<br />
点击右上「运行」执行用例后在此查看报告
</div>
);
}
const stats = runStats(run);
const assertCount = run.stepResults.reduce((acc, s) => acc + s.assertionResults.length, 0);
const shown = run.stepResults.filter((s) => (filter === "all" ? true : filter === "pass" ? s.ok : !s.ok));
const download = () => {
const md = exportRunMarkdown(run);
const blob = new Blob([md], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${run.caseName}-报告-${new Date(run.at).toISOString().slice(0, 10)}.md`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="ops-scroll flex-1 p-3.5">
{/* 运行选择 + 操作 */}
<div className="mb-3 flex flex-wrap items-center gap-2">
<select
className="ops-input ops-input-sm w-64"
value={run.id}
onChange={(e) => setSelectedRun(e.target.value)}
>
{runs.map((r) => (
<option key={r.id} value={r.id}>
{new Date(r.at).toLocaleString("zh-CN")} · {r.trigger === "schedule" ? "定时" : r.trigger === "ai" ? "AI" : "手动"} ·{" "}
{r.passed ? "通过" : "失败"}
</option>
))}
</select>
<div className="ml-auto flex gap-1.5">
<button
className="ops-btn ops-btn-secondary"
disabled={busy}
onClick={async () => {
setBusy(true);
try {
await runTestCase(caseId, "manual");
} finally {
setBusy(false);
}
}}
>
<Icon name="rotate-cw" size={13} /> 再次运行
</button>
<button className="ops-btn ops-btn-secondary" onClick={download}>
<Icon name="file-down" size={13} /> 导出报告
</button>
</div>
</div>
{/* 统计卡 */}
<div className="test-stat-grid mb-3">
<div className={cx("test-stat", run.passed && "ok")}>
<div className="test-stat-value">{stats.ok}</div>
<div className="test-stat-label">通过步骤</div>
</div>
<div className={cx("test-stat", stats.fail > 0 && "bad")}>
<div className="test-stat-value">{stats.fail}</div>
<div className="test-stat-label">失败步骤</div>
</div>
<div className="test-stat">
<div className="test-stat-value">{run.totalMs}</div>
<div className="test-stat-label">总耗时 (ms)</div>
</div>
<div className="test-stat">
<div className="test-stat-value">{assertCount}</div>
<div className="test-stat-label">断言数</div>
</div>
<div className="test-stat">
<div className="test-stat-value" style={{ fontSize: 16 }}>
{run.stepResults.length > 0 ? `${Math.round((stats.ok / run.stepResults.length) * 100)}%` : "—"}
</div>
<div className="test-stat-label">通过率</div>
</div>
</div>
{/* 过滤 */}
<div className="mb-2 flex gap-1.5">
{([["all", `全部 ${run.stepResults.length}`], ["pass", `通过 ${stats.ok}`], ["fail", `失败 ${stats.fail}`]] as const).map(
([k, label]) => (
<button key={k} className={cx("ops-chip", filter === k && "ops-chip-active")} onClick={() => setFilter(k as never)}>
{label}
</button>
),
)}
</div>
{/* 步骤明细 */}
<div className="space-y-2">
{shown.map((s) => (
<div key={s.stepId} className="test-step-result">
<div className="flex flex-wrap items-center gap-2 px-3 py-2">
<Icon name={s.ok ? "circle-check" : "circle-x"} size={15} className={s.ok ? "ops-success" : "ops-danger"} />
<span className="text-[13px] font-medium">{s.name}</span>
{s.method && s.url !== "—" && (
<span className="ops-faint truncate font-mono text-[11px]">
{s.method} {s.url}
</span>
)}
{s.httpStatus !== null && (
<span className={cx("text-[11px] font-bold", s.httpStatus < 400 ? "ops-success" : "ops-danger")}>{s.httpStatus}</span>
)}
{s.durationMs !== null && (
<span className="ops-faint ml-auto flex items-center gap-1 text-[11px]">
<Icon name="timer" size={11} /> {s.durationMs} ms
</span>
)}
</div>
{s.error && <div className="ops-danger px-3 pb-2 text-[11px]">{s.error}</div>}
{s.assertionResults.length > 0 && (
<div className="px-3 pb-2">
<table className="test-assert-table">
<thead>
<tr>
<th>断言</th>
<th>期望</th>
<th>实际</th>
<th>结果</th>
</tr>
</thead>
<tbody>
{s.assertionResults.map((a, j) => (
<tr key={j}>
<td>{a.label}</td>
<td className="font-mono">{a.expected || "—"}</td>
<td className="font-mono">{a.actual || "—"}</td>
<td className={a.passed ? "ops-success" : "ops-danger"}>{a.passed ? "通过" : "失败"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
))}
</div>
</div>
);
}