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:
1 parent
9a019408ab
commit
1bcd7be2ac
23 files changed
+2538
-7
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user