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

+228
View File
@@ -0,0 +1,228 @@
/**
* AI 助手:OpenAI 兼容 Chat Completions 客户端 + 用例生成。
* 供应商配置保存在工作区设置(本地 localStorage),不经任何第三方中转。
*/
import type {
AiProvider,
Collection,
HttpRequestData,
RequestNode,
TestAssertion,
TestCase,
TestStep,
} from "@opsxapi/core";
import { newTestCase, suggestAssertions } from "@opsxapi/core";
/** 常见供应商预设(均为 OpenAI 兼容协议) */
export const AI_PROVIDER_PRESETS: { name: string; baseUrl: string; model: string; hint: string }[] = [
{ name: "DeepSeek", baseUrl: "https://api.deepseek.com/v1", model: "deepseek-chat", hint: "性价比高,推荐" },
{ name: "智谱 GLM", baseUrl: "https://open.bigmodel.cn/api/paas/v4", model: "glm-4-flash", hint: "新用户有免费额度" },
{ name: "Moonshot Kimi", baseUrl: "https://api.moonshot.cn/v1", model: "moonshot-v1-8k", hint: "" },
{ name: "OpenAI", baseUrl: "https://api.openai.com/v1", model: "gpt-4o-mini", hint: "" },
{ name: "Ollama 本地", baseUrl: "http://127.0.0.1:11434/v1", model: "qwen2.5:7b", hint: "无需 API Key" },
];
export function newAiProvider(preset?: (typeof AI_PROVIDER_PRESETS)[number]): AiProvider {
return {
id: `ai-${Math.random().toString(36).slice(2, 10)}`,
name: preset?.name ?? "自定义供应商",
baseUrl: preset?.baseUrl ?? "",
apiKey: "",
model: preset?.model ?? "",
enabled: true,
};
}
type ChatMessage = { role: "system" | "user" | "assistant"; content: string };
/** 调用 Chat Completions(非流式),失败抛 Error */
export async function chatComplete(provider: AiProvider, messages: ChatMessage[], timeoutMs = 60000): Promise<string> {
if (!provider.baseUrl.trim()) throw new Error("未配置 Base URL");
const url = `${provider.baseUrl.trim().replace(/\/+$/, "")}/chat/completions`;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(provider.apiKey.trim() ? { Authorization: `Bearer ${provider.apiKey.trim()}` } : {}),
},
body: JSON.stringify({
model: provider.model.trim() || "gpt-4o-mini",
messages,
temperature: 0.2,
stream: false,
}),
signal: ctrl.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status}${text ? `:${text.slice(0, 200)}` : ""}`);
}
const data = (await res.json()) as {
choices?: { message?: { content?: string } }[];
};
const content = data.choices?.[0]?.message?.content;
if (!content) throw new Error("模型未返回内容");
return content;
} catch (e) {
if (e instanceof DOMException && e.name === "AbortError") throw new Error("请求超时");
throw e instanceof Error ? e : new Error(String(e));
}
}
/** 连接测试:问一句并要求简答 */
export async function testProviderConnection(provider: AiProvider): Promise<string> {
const reply = await chatComplete(
provider,
[
{ role: "system", content: "你是连通性测试助手,只输出:OK" },
{ role: "user", content: "ping" },
],
20000,
);
return reply.trim().slice(0, 60) || "OK";
}
/* ---------------- AI 生成场景用例 ---------------- */
/** 汇总集合中的 HTTP 接口给模型看 */
function summarizeNodes(nodes: { node: RequestNode; path: string }[]): string {
return nodes
.map(({ node, path }) => {
if (node.kind !== "http") return null;
const d = node.data as HttpRequestData;
const lines = [
`- id: ${node.id} | ${d.method} ${d.url} | 名称: ${node.name}${path ? ` | 目录: ${path}` : ""}`,
];
if (d.bodyMode === "json" && d.bodyRaw.trim()) {
lines.push(` body 示例: ${d.bodyRaw.replace(/\s+/g, " ").slice(0, 160)}`);
}
return lines.join("\n");
})
.filter(Boolean)
.join("\n");
}
const GEN_SYSTEM_PROMPT = `你是 API 自动化测试专家。根据给定的接口清单设计一个可执行的"场景用例"(按业务顺序编排多个接口步骤)。
只输出一个 JSON 代码块(\`\`\`json ... \`\`\`),不要输出其他解释文字。JSON 结构:
{
"name": "用例名称(中文,简短)",
"description": "一句话说明场景",
"steps": [
{
"nodeId": "必须使用清单中的接口 id",
"reason": "为什么这一步",
"assertions": [
{ "target": "status|timeMs|header|body", "locator": "header名或JSONPath(其余为空)", "op": "eq|neq|lt|gt|contains|not_contains|exists|not_exists|jsonpath_eq|jsonpath_contains", "expected": "期望值" }
],
"extracts": [ { "jsonpath": "$.data.id", "varName": "petId" } ]
}
]
}
要求:步骤顺序符合业务依赖(如 先创建 → 再查询 → 再删除);每步至少 1 条断言;跨步骤传值用 extracts 提取变量并在后续 URL/Body 用 {{变量名}}(如果原接口里已有 {{xxx}} 占位则沿用);断言 4-8 条为宜,聚焦关键业务字段。`;
export type AiCaseDraft = { name: string; description?: string; steps: TestStep[]; notes: string[] };
/** 从模型回复中提取 JSON(容错:代码块 / 首尾杂文) */
export function extractJsonBlock(text: string): unknown {
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
const raw = fenced ? fenced[1] : text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1);
return JSON.parse(raw.trim());
}
export async function aiGenerateTestCase(
provider: AiProvider,
collection: Collection,
instruction?: string,
): Promise<AiCaseDraft> {
const nodes: { node: RequestNode; path: string }[] = [];
const walk = (children: Collection["children"], path: string): void => {
for (const n of children) {
if (n.type === "folder") walk(n.children, path ? `${path}/${n.name}` : n.name);
else if (n.kind === "http") nodes.push({ node: n, path });
}
};
walk(collection.children, "");
if (nodes.length === 0) throw new Error("集合中没有 HTTP 接口可供编排");
const user = [
`接口清单(共 ${nodes.length} 个):`,
summarizeNodes(nodes),
instruction?.trim() ? `\n用户要求:${instruction.trim()}` : "",
"\n请输出场景用例 JSON。",
]
.filter(Boolean)
.join("\n");
const reply = await chatComplete(provider, [
{ role: "system", content: GEN_SYSTEM_PROMPT },
{ role: "user", content: user },
]);
const parsed = extractJsonBlock(reply) as {
name?: string;
description?: string;
steps?: {
nodeId?: string;
reason?: string;
assertions?: { target?: string; locator?: string; op?: string; expected?: string }[];
extracts?: { jsonpath?: string; varName?: string }[];
}[];
};
const notes: string[] = [];
const steps: TestStep[] = [];
const validIds = new Set(nodes.map((n) => n.node.id));
const nodeById = new Map(nodes.map((n) => [n.node.id, n.node]));
for (const s of parsed.steps ?? []) {
if (!s.nodeId) continue;
if (!validIds.has(s.nodeId)) {
notes.push(`已跳过未知接口:${s.nodeId}`);
continue;
}
const node = nodeById.get(s.nodeId)!;
const assertions: TestAssertion[] = (s.assertions ?? []).map((a) => ({
id: `as-${Math.random().toString(36).slice(2, 10)}`,
enabled: true,
target: (["status", "timeMs", "header", "body"].includes(a.target ?? "") ? a.target : "status") as TestAssertion["target"],
locator: a.locator ?? "",
op: ([
"eq", "neq", "lt", "gt", "contains", "not_contains", "exists", "not_exists", "jsonpath_eq", "jsonpath_contains",
].includes(a.op ?? "") ? a.op : "eq") as TestAssertion["op"],
expected: a.expected ?? "",
}));
if (assertions.length === 0) {
assertions.push(...suggestAssertions(node.data as HttpRequestData));
if (s.reason) notes.push(`「${node.name}」未给断言,已补默认断言`);
}
steps.push({
id: `st-${Math.random().toString(36).slice(2, 10)}`,
enabled: true,
nodeId: s.nodeId,
assertions,
extracts: (s.extracts ?? [])
.filter((e) => e.jsonpath && e.varName)
.map((e) => ({ id: `ex-${Math.random().toString(36).slice(2, 10)}`, jsonpath: e.jsonpath!, varName: e.varName! })),
});
}
if (steps.length === 0) throw new Error("模型未生成有效步骤,请重试或补充要求");
return {
name: parsed.name?.trim() || `${collection.name} · AI 用例`,
description: parsed.description?.trim(),
steps,
notes,
};
}
/** 把 AI 草稿转成正式用例 */
export function draftToTestCase(draft: AiCaseDraft): TestCase {
const tc = newTestCase(draft.name);
tc.description = draft.description;
tc.steps = draft.steps;
return tc;
}