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

+11
View File
@@ -115,6 +115,17 @@ export function Sidebar() {
)}
</div>
{/* 自动化测试入口 */}
<div className="p-2" style={{ borderTop: "1px solid var(--border)" }}>
<button
className="ops-btn ops-btn-secondary w-full"
onClick={() => useWorkspace.getState().setView("testing")}
title="场景用例 · AI 生成 · 定时任务"
>
<Icon name="list-checks" size={13} /> 自动化测试
</button>
</div>
<ContextMenu menu={menu} onClose={() => setMenu(null)} />
</aside>
);
+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>
);
}
+5
View File
@@ -100,6 +100,11 @@ export function TopBar() {
icon: <Icon name="flask-conical" size={14} />,
onClick: () => setDialog({ type: "settings", tab: "mock" }),
},
{
label: "自动化测试",
icon: <Icon name="list-checks" size={14} />,
onClick: () => useWorkspace.getState().setView("testing"),
},
{
label: "环境管理…",
icon: <Icon name="arrow-left-right" size={14} />,
@@ -0,0 +1,178 @@
/**
* AI 生成场景用例:选集合 + 要求 → 模型输出步骤编排 → 预览确认保存。
* 需先在「设置 → AI 助手」配置模型供应商。
*/
import type { AiCaseDraft } from "../../ai";
import { aiGenerateTestCase } from "../../ai";
import { useWorkspace } from "../../workspaceStore";
import { Modal } from "../common";
import { Icon } from "../icons";
import { useState } from "react";
export function AiCaseDialog({ onClose, onSaved }: { onClose: () => void; onSaved: (caseId: string) => void }) {
const collections = useWorkspace((s) => s.collections);
const ai = useWorkspace((s) => s.settings.ai);
const setDialog = useWorkspace((s) => s.setDialog);
const addTestCase = useWorkspace((s) => s.addTestCase);
const updateTestCase = useWorkspace((s) => s.updateTestCase);
const [collectionId, setCollectionId] = useState(collections[0]?.id ?? "");
const [instruction, setInstruction] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [draft, setDraft] = useState<AiCaseDraft | null>(null);
const provider = ai.providers.find((p) => p.id === ai.activeProviderId && p.enabled) ?? null;
const generate = async () => {
const collection = collections.find((c) => c.id === collectionId);
if (!provider || !collection) return;
setBusy(true);
setError("");
setDraft(null);
try {
setDraft(await aiGenerateTestCase(provider, collection, instruction));
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const save = () => {
if (!draft) return;
const id = addTestCase();
updateTestCase(id, { name: draft.name, description: draft.description, steps: draft.steps });
onSaved(id);
onClose();
};
return (
<Modal
title="AI 生成场景用例"
onClose={onClose}
width="680px"
footer={
<div className="flex w-full items-center justify-between">
<span className="ops-faint text-[11px]">生成后可再手动调整步骤与断言</span>
<div className="flex gap-2">
<button className="ops-btn ops-btn-secondary" onClick={onClose}>
取消
</button>
{draft ? (
<button className="ops-btn ops-btn-primary" onClick={save}>
<Icon name="circle-check" size={13} /> 保存用例
</button>
) : (
<button
className="ops-btn ops-btn-primary"
disabled={!provider || !collectionId || busy}
onClick={generate}
>
<Icon name="bot" size={13} /> {busy ? "生成中…" : "生成"}
</button>
)}
</div>
</div>
}
>
{!provider ? (
<div className="space-y-3">
<div className="ops-hint">
<Icon name="info" size={14} />
<span>尚未配置 AI 供应商。请先在「设置 → AI 助手」添加 OpenAI 兼容的模型供应商。</span>
</div>
<button className="ops-btn ops-btn-secondary" onClick={() => setDialog({ type: "settings", tab: "ai" })}>
<Icon name="settings" size={13} /> 打开设置
</button>
</div>
) : (
<div className="space-y-3.5">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="ops-label">集合</label>
<select className="ops-input" value={collectionId} onChange={(e) => setCollectionId(e.target.value)}>
{collections.map((c) => (
<option key={c.id} value={c.id}>
{c.name}({countHttp(c.children)} 个 HTTP 接口)
</option>
))}
</select>
</div>
<div>
<label className="ops-label">模型</label>
<div className="ops-faint flex h-[34px] items-center gap-1.5 text-[12px]">
<Icon name="bot" size={13} className="ops-accent" />
{provider.name} · {provider.model || "默认模型"}
</div>
</div>
</div>
<div>
<label className="ops-label">要求(可选)</label>
<textarea
className="ops-input min-h-16 w-full resize-y text-[12px]"
placeholder="例:覆盖用户登录 → 查询个人信息 → 修改昵称的完整流程,重点断言返回码与业务字段"
value={instruction}
onChange={(e) => setInstruction(e.target.value)}
/>
</div>
{error && <div className="ops-danger text-[12px]">生成失败:{error}</div>}
{draft && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Icon name="circle-check" size={14} className="ops-success" />
<span className="text-[13px] font-semibold">{draft.name}</span>
<span className="ops-faint text-[11px]">{draft.steps.length} 个步骤</span>
</div>
{draft.description && <div className="ops-faint text-[11px]">{draft.description}</div>}
{draft.notes.map((n, i) => (
<div key={i} className="ops-faint text-[11px]">
· {n}
</div>
))}
<div className="max-h-64 space-y-1.5 overflow-y-auto">
{draft.steps.map((s, i) => (
<div key={s.id} className="flex items-start gap-2 rounded-lg px-2.5 py-1.5" style={{ border: "1px solid var(--border)" }}>
<span className="ops-faint mt-0.5 text-[11px] font-bold">{i + 1}</span>
<div className="min-w-0 flex-1">
<div className="truncate text-[12px] font-medium">{stepName(s.nodeId)}</div>
<div className="ops-faint text-[10px]">
{s.assertions.length} 条断言
{s.extracts.length > 0 && ` · 提取 ${s.extracts.map((e) => e.varName).join(", ")}`}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</Modal>
);
function stepName(nodeId: string): string {
const { collections: cols } = useWorkspace.getState();
let name = nodeId;
const walk = (nodes: { type: string; name?: string; id: string; children?: unknown[] }[]): void => {
for (const n of nodes) {
if (n.id === nodeId && n.type === "request") name = n.name ?? name;
if (Array.isArray(n.children)) walk(n.children as never[]);
}
};
for (const c of cols) walk(c.children as never[]);
return name;
}
}
function countHttp(nodes: { type: string; kind?: string; children?: unknown[] }[]): number {
let n = 0;
for (const node of nodes) {
if (node.type === "request" && node.kind === "http") n++;
if (Array.isArray(node.children)) n += countHttp(node.children as never[]);
}
return n;
}
@@ -0,0 +1,96 @@
/**
* 定时任务管理:为每个用例配置启用状态与运行间隔(应用运行期间生效)。
*/
import { nextRunAt } from "../../scheduler";
import { useWorkspace } from "../../workspaceStore";
import { Modal, cx } from "../common";
import { Icon } from "../icons";
const INTERVALS = [5, 15, 30, 60, 360, 1440];
function intervalLabel(m: number): string {
if (m < 60) return `每 ${m} 分钟`;
if (m < 1440) return `每 ${m / 60} 小时`;
return "每天";
}
export function ScheduleDialog({ onClose }: { onClose: () => void }) {
const testCases = useWorkspace((s) => s.testCases);
const schedules = useWorkspace((s) => s.schedules);
const testRuns = useWorkspace((s) => s.testRuns);
const upsertSchedule = useWorkspace((s) => s.upsertSchedule);
const removeSchedule = useWorkspace((s) => s.removeSchedule);
const lastRunOf = (caseId: string) => testRuns.find((r) => r.caseId === caseId);
return (
<Modal
title="定时任务"
onClose={onClose}
width="640px"
footer={
<div className="flex w-full items-center justify-between">
<span className="ops-faint text-[11px]">应用保持运行时按间隔自动执行并记录报告;关闭应用后暂停</span>
<button className="ops-btn ops-btn-primary" onClick={onClose}>
完成
</button>
</div>
}
>
{testCases.length === 0 ? (
<div className="ops-faint py-8 text-center text-xs">还没有场景用例,先在左侧新建或用 AI 生成</div>
) : (
<div className="max-h-[52vh] space-y-2 overflow-y-auto">
{testCases.map((tc) => {
const sc = schedules.find((s) => s.caseId === tc.id);
const last = lastRunOf(tc.id);
return (
<div key={tc.id} className="flex flex-wrap items-center gap-2 rounded-xl px-3 py-2.5" style={{ border: "1px solid var(--border)" }}>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium">{tc.name}</div>
<div className="ops-faint text-[11px]">
{sc?.enabled
? `下次约 ${new Date(nextRunAt(sc.intervalMinutes, sc.lastRunAt)).toLocaleTimeString("zh-CN")} 运行`
: "未启用定时"}
{sc?.lastRunAt ? ` · 上次 ${new Date(sc.lastRunAt).toLocaleString("zh-CN")}` : ""}
{last ? ` · ${last.passed ? "通过" : "失败"}` : ""}
</div>
</div>
<select
className="ops-input ops-input-sm !w-28"
value={sc?.intervalMinutes ?? 30}
disabled={!sc}
onChange={(e) => upsertSchedule(tc.id, { intervalMinutes: Number(e.target.value) })}
>
{INTERVALS.map((m) => (
<option key={m} value={m}>
{intervalLabel(m)}
</option>
))}
</select>
{sc ? (
<>
<button
className={cx("ops-btn", sc.enabled ? "ops-btn-secondary" : "ops-btn-primary", "!px-2")}
title={sc.enabled ? "暂停" : "启用"}
onClick={() => upsertSchedule(tc.id, { enabled: !sc.enabled })}
>
<Icon name={sc.enabled ? "circle-x" : "play"} size={13} />
</button>
<button className="ops-btn ops-btn-ghost ops-danger px-1.5" title="删除定时" onClick={() => removeSchedule(tc.id)}>
<Icon name="trash" size={13} />
</button>
</>
) : (
<button className="ops-btn ops-btn-secondary !px-2" title="添加定时" onClick={() => upsertSchedule(tc.id, {})}>
<Icon name="plus" size={13} />
</button>
)}
</div>
);
})}
</div>
)}
</Modal>
);
}
@@ -3,6 +3,7 @@
*/
import type { RequestKind } from "@opsxapi/core";
import { generateMock, KIND_LABELS, MOCK_CHEATSHEET, walk } from "@opsxapi/core";
import { AI_PROVIDER_PRESETS, newAiProvider, testProviderConnection } from "../../ai";
import { useState } from "react";
import { useWorkspace } from "../../workspaceStore";
import { KVEditor, Modal, cx } from "../common";
@@ -367,12 +368,13 @@ export function EnvironmentsDialog() {
/* ---------------- 设置弹窗(左侧导航 + 分区) ---------------- */
type SettingsTab = "appearance" | "network" | "mock" | "data" | "about";
type SettingsTab = "appearance" | "network" | "mock" | "ai" | "data" | "about";
const SETTINGS_TABS: { id: SettingsTab; label: string; desc: string; icon: IconName }[] = [
{ id: "appearance", label: "外观", desc: "主题与界面", icon: "palette" },
{ id: "network", label: "网络", desc: "请求与超时", icon: "network" },
{ id: "mock", label: "Mock 数据", desc: "智能匹配规则", icon: "flask-conical" },
{ id: "ai", label: "AI 助手", desc: "模型供应商", icon: "bot" },
{ id: "data", label: "数据管理", desc: "工作区与备份", icon: "database" },
{ id: "about", label: "关于", desc: "版本与链接", icon: "info" },
];
@@ -430,6 +432,10 @@ export function SettingsDialog({ tab }: { tab?: SettingsTab }) {
const addMockRule = useWorkspace((s) => s.addMockRule);
const removeMockRule = useWorkspace((s) => s.removeMockRule);
const resetMockRules = useWorkspace((s) => s.resetMockRules);
const addAiProvider = useWorkspace((s) => s.addAiProvider);
const updateAiProvider = useWorkspace((s) => s.updateAiProvider);
const removeAiProvider = useWorkspace((s) => s.removeAiProvider);
const setActiveAiProvider = useWorkspace((s) => s.setActiveAiProvider);
const resetWorkspace = useWorkspace((s) => s.resetWorkspace);
const setDialog = useWorkspace((s) => s.setDialog);
@@ -719,6 +725,8 @@ export function SettingsDialog({ tab }: { tab?: SettingsTab }) {
</>
)}
{active === "ai" && <AiSettingsPane />}
{active === "data" && (
<>
<SettingGroup title="工作区数据">
@@ -812,3 +820,158 @@ export function SettingsDialog({ tab }: { tab?: SettingsTab }) {
</Modal>
);
}
/* ---------------- AI 助手设置 ---------------- */
function AiSettingsPane() {
const ai = useWorkspace((s) => s.settings.ai);
const addAiProvider = useWorkspace((s) => s.addAiProvider);
const updateAiProvider = useWorkspace((s) => s.updateAiProvider);
const removeAiProvider = useWorkspace((s) => s.removeAiProvider);
const setActiveAiProvider = useWorkspace((s) => s.setActiveAiProvider);
const [preset, setPreset] = useState(0);
const [testState, setTestState] = useState<Record<string, "idle" | "busy" | "ok" | "err">>({});
const [testMsg, setTestMsg] = useState<Record<string, string>>({});
const runTest = async (id: string) => {
const provider = ai.providers.find((pr) => pr.id === id);
if (!provider) return;
setTestState((m) => ({ ...m, [id]: "busy" }));
setTestMsg((m) => ({ ...m, [id]: "" }));
try {
const reply = await testProviderConnection(provider);
setTestState((m) => ({ ...m, [id]: "ok" }));
setTestMsg((m) => ({ ...m, [id]: reply }));
} catch (e) {
setTestState((m) => ({ ...m, [id]: "err" }));
setTestMsg((m) => ({ ...m, [id]: e instanceof Error ? e.message : String(e) }));
}
};
return (
<>
<SettingGroup title="模型供应商">
<div className="ops-hint">
<Icon name="info" size={14} />
<span>
采用 <b>OpenAI 兼容协议</b>(DeepSeek / 智谱 / Kimi / Ollama 本地…),密钥仅保存在本机。
<a href="/docs/ai" target="_blank" rel="noopener noreferrer" className="ops-link ml-1">
配置说明 →
</a>
</span>
</div>
<div className="flex flex-wrap items-center gap-1.5 px-4 pb-3">
<select
className="ops-input ops-input-sm w-52"
value={preset}
onChange={(e) => setPreset(Number(e.target.value))}
>
{AI_PROVIDER_PRESETS.map((p, i) => (
<option key={p.name} value={i}>
{p.name}
{p.hint ? `(${p.hint})` : ""}
</option>
))}
</select>
<button
className="ops-btn ops-btn-secondary"
onClick={() => addAiProvider(newAiProvider(AI_PROVIDER_PRESETS[preset]))}
>
<Icon name="plus" size={13} /> 添加供应商
</button>
</div>
{ai.providers.length === 0 && (
<div className="ops-faint px-4 pb-4 text-center text-xs leading-5">
请先添加模型供应商,添加后即可在「自动化测试」中使用 AI 生成场景用例
</div>
)}
<div className="space-y-3 px-4 pb-4">
{ai.providers.map((pr) => {
const st = testState[pr.id] ?? "idle";
const isActive = ai.activeProviderId === pr.id;
return (
<div key={pr.id} className="rounded-xl p-3" style={{ border: "1px solid var(--border)" }}>
<div className="mb-2 flex items-center gap-2">
<input
className="ops-input ops-input-sm w-40 font-semibold"
value={pr.name}
onChange={(e) => updateAiProvider(pr.id, { name: e.target.value })}
placeholder="名称"
/>
<span className={cx("ops-chip", isActive && "ops-chip-active")} style={{ padding: "1px 8px", fontSize: 10 }}>
{isActive ? "当前使用" : "未选用"}
</span>
{!isActive && (
<button className="ops-btn ops-btn-ghost !py-0.5 text-[11px]" onClick={() => setActiveAiProvider(pr.id)}>
设为当前
</button>
)}
<button
className="ops-btn ops-btn-ghost !py-0.5 text-[11px]"
disabled={st === "busy"}
onClick={() => runTest(pr.id)}
>
{st === "busy" ? "测试中…" : "测试连接"}
</button>
<button
className="ops-btn ops-btn-ghost ops-danger ml-auto px-1.5"
title="删除供应商"
onClick={() => removeAiProvider(pr.id)}
>
<Icon name="trash" size={13} />
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="ops-faint text-[10px]">Base URL</label>
<input
className="ops-input ops-input-sm w-full font-mono"
value={pr.baseUrl}
onChange={(e) => updateAiProvider(pr.id, { baseUrl: e.target.value })}
placeholder="https://api.deepseek.com/v1"
/>
</div>
<div>
<label className="ops-faint text-[10px]">模型</label>
<input
className="ops-input ops-input-sm w-full font-mono"
value={pr.model}
onChange={(e) => updateAiProvider(pr.id, { model: e.target.value })}
placeholder="deepseek-chat"
/>
</div>
<div className="col-span-2">
<label className="ops-faint text-[10px]">API Key(本机保存,Ollama 可留空)</label>
<input
className="ops-input ops-input-sm w-full font-mono"
type="password"
value={pr.apiKey}
onChange={(e) => updateAiProvider(pr.id, { apiKey: e.target.value })}
placeholder="sk-…"
/>
</div>
</div>
{st === "ok" && <div className="ops-success mt-2 text-[11px]">连接成功:{testMsg[pr.id]}</div>}
{st === "err" && <div className="ops-danger mt-2 break-all text-[11px]">连接失败:{testMsg[pr.id]}</div>}
</div>
);
})}
</div>
</SettingGroup>
<SettingGroup title="AI 能力">
<SettingRow label="AI 生成场景用例" desc="自动化测试 → AI 生成:按集合接口清单编排步骤、断言与变量提取">
<span className={cx("ops-chip", ai.providers.length > 0 && "ops-chip-active")} style={{ padding: "2px 10px" }}>
{ai.providers.length > 0 ? "已就绪" : "待配置"}
</span>
</SettingRow>
<SettingRow label="AI 批量生成测试数据" desc="Body 智能填充已内置离线规则;接入模型后可描述需求生成整段 JSON 测试数据(规划中)">
<span className="ops-faint text-[11px]">规划中</span>
</SettingRow>
</SettingGroup>
</>
);
}
+16
View File
@@ -13,6 +13,14 @@ export type IconName =
| "monitor"
| "external-link"
| "sparkles"
| "circle-check"
| "circle-x"
| "timer"
| "list-checks"
| "calendar-clock"
| "bot"
| "rotate-cw"
| "file-down"
| "plus"
| "download"
| "upload"
@@ -52,6 +60,14 @@ export type IconName =
| "info";
const ICONS: Record<IconName, string> = {
"circle-check": "<circle cx=\"12\" cy=\"12\" r=\"10\" /> <path d=\"m16 9-5.5 5.5L8 12\" />",
"circle-x": "<circle cx=\"12\" cy=\"12\" r=\"10\" /> <path d=\"m15 9-6 6\" /> <path d=\"m9 9 6 6\" />",
"timer": "<line x1=\"10\" x2=\"14\" y1=\"2\" y2=\"2\" /> <line x1=\"12\" x2=\"15\" y1=\"14\" y2=\"11\" /> <circle cx=\"12\" cy=\"14\" r=\"8\" />",
"list-checks": "<path d=\"M13 5h8\" /> <path d=\"M13 12h8\" /> <path d=\"M13 19h8\" /> <path d=\"m3 17 2 2 4-4\" /> <path d=\"m3 7 2 2 4-4\" />",
"calendar-clock": "<path d=\"M16 14v2.2l1.6 1\" /> <path d=\"M16 2v3\" /> <path d=\"M21 7.338V5a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2h2.338\" /> <path d=\"M3 9h5.859\" /> <path d=\"M8 2v3\" /> <circle cx=\"16\" cy=\"16\" r=\"6\" />",
"bot": "<path d=\"M12 8V4H8\" /> <rect width=\"16\" height=\"12\" x=\"4\" y=\"8\" rx=\"2\" /> <path d=\"M2 14h2\" /> <path d=\"M20 14h2\" /> <path d=\"M15 13v2\" /> <path d=\"M9 13v2\" />",
"rotate-cw": "<path d=\"M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8\" /> <path d=\"M21 3v5h-5\" />",
"file-down": "<path d=\"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z\" /> <path d=\"M14 2v5a1 1 0 0 0 1 1h5\" /> <path d=\"M12 18v-6\" /> <path d=\"m9 15 3 3 3-3\" />",
"palette": "<path d=\"M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z\" /> <circle cx=\"13.5\" cy=\"6.5\" r=\".5\" fill=\"currentColor\" /> <circle cx=\"17.5\" cy=\"10.5\" r=\".5\" fill=\"currentColor\" /> <circle cx=\"6.5\" cy=\"12.5\" r=\".5\" fill=\"currentColor\" /> <circle cx=\"8.5\" cy=\"7.5\" r=\".5\" fill=\"currentColor\" />",
"network": "<rect x=\"16\" y=\"16\" width=\"6\" height=\"6\" rx=\"1\" /> <rect x=\"2\" y=\"16\" width=\"6\" height=\"6\" rx=\"1\" /> <rect x=\"9\" y=\"2\" width=\"6\" height=\"6\" rx=\"1\" /> <path d=\"M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3\" /> <path d=\"M12 12V8\" />",
"flask-conical": "<path d=\"M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2\" /> <path d=\"M6.453 15h11.094\" /> <path d=\"M8.5 2h7\" />",