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
@@ -263,6 +263,9 @@ export function createSeedWorkspace(): WorkspaceData {
|
||||
environments: envs,
|
||||
activeEnvironmentId: envs[0]?.id ?? null,
|
||||
history: [],
|
||||
settings: { theme: "dark", timeoutMs: 30000, mock: normalizeMockSettings(null) },
|
||||
settings: { theme: "dark", timeoutMs: 30000, mock: normalizeMockSettings(null), ai: { providers: [], activeProviderId: null } },
|
||||
testCases: [],
|
||||
testRuns: [],
|
||||
schedules: [],
|
||||
};
|
||||
}
|
||||
@@ -46,6 +46,19 @@ export type {
|
||||
MockSettings,
|
||||
MockFieldType,
|
||||
MockMatchMode,
|
||||
AssertTarget,
|
||||
AssertOp,
|
||||
TestAssertion,
|
||||
VariableExtract,
|
||||
TestStep,
|
||||
TestCase,
|
||||
AssertionResult,
|
||||
TestStepResult,
|
||||
TestRun,
|
||||
TestRunTrigger,
|
||||
TestSchedule,
|
||||
AiProvider,
|
||||
AiSettings,
|
||||
} from "./model";
|
||||
export {
|
||||
REQUEST_METHODS,
|
||||
@@ -103,6 +116,22 @@ export {
|
||||
MOCK_CHEATSHEET,
|
||||
} from "./mock";
|
||||
|
||||
// 自动化测试
|
||||
export { ASSERT_OP_LABELS } from "./model";
|
||||
export {
|
||||
queryJsonPath,
|
||||
describeAssertion,
|
||||
evaluateAssertion,
|
||||
suggestAssertions,
|
||||
extractVariables,
|
||||
exportRunMarkdown,
|
||||
newAssertion,
|
||||
newExtract,
|
||||
newStep,
|
||||
newTestCase,
|
||||
runStats,
|
||||
} from "./testing";
|
||||
|
||||
// 导入
|
||||
export {
|
||||
detectImportFormat,
|
||||
|
||||
@@ -261,6 +261,7 @@ export type WorkspaceSettings = {
|
||||
/** 请求超时(毫秒) */
|
||||
timeoutMs: number;
|
||||
mock: MockSettings;
|
||||
ai: AiSettings;
|
||||
};
|
||||
|
||||
export type WorkspaceData = {
|
||||
@@ -270,6 +271,143 @@ export type WorkspaceData = {
|
||||
activeEnvironmentId: string | null;
|
||||
history: HistoryEntry[];
|
||||
settings: WorkspaceSettings;
|
||||
/** 自动化测试:用例 / 运行报告 / 定时任务 */
|
||||
testCases: TestCase[];
|
||||
testRuns: TestRun[];
|
||||
schedules: TestSchedule[];
|
||||
};
|
||||
|
||||
/* ---------------- 自动化测试 ---------------- */
|
||||
|
||||
/** 断言目标 */
|
||||
export type AssertTarget = "status" | "timeMs" | "header" | "body";
|
||||
|
||||
/** 断言操作 */
|
||||
export type AssertOp =
|
||||
| "eq"
|
||||
| "neq"
|
||||
| "lt"
|
||||
| "gt"
|
||||
| "contains"
|
||||
| "not_contains"
|
||||
| "exists"
|
||||
| "not_exists"
|
||||
| "jsonpath_eq"
|
||||
| "jsonpath_contains";
|
||||
|
||||
export const ASSERT_OP_LABELS: Record<AssertOp, string> = {
|
||||
eq: "等于",
|
||||
neq: "不等于",
|
||||
lt: "小于",
|
||||
gt: "大于",
|
||||
contains: "包含文本",
|
||||
not_contains: "不包含文本",
|
||||
exists: "存在",
|
||||
not_exists: "不存在",
|
||||
jsonpath_eq: "JSONPath 等于",
|
||||
jsonpath_contains: "JSONPath 包含",
|
||||
};
|
||||
|
||||
/** 单条断言 */
|
||||
export type TestAssertion = {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
target: AssertTarget;
|
||||
/** target=header 时为 Header 名;jsonpath_* 时为路径 */
|
||||
locator: string;
|
||||
op: AssertOp;
|
||||
/** 期望值(exists/not_exists 忽略) */
|
||||
expected: string;
|
||||
};
|
||||
|
||||
/** 响应 → 环境变量提取 */
|
||||
export type VariableExtract = {
|
||||
id: string;
|
||||
/** JSONPath,如 $.data.token */
|
||||
jsonpath: string;
|
||||
varName: string;
|
||||
};
|
||||
|
||||
/** 测试步骤:引用一个请求节点 + 断言 + 提取 */
|
||||
export type TestStep = {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
/** 引用的请求节点 id(HTTP) */
|
||||
nodeId: string;
|
||||
assertions: TestAssertion[];
|
||||
extracts: VariableExtract[];
|
||||
};
|
||||
|
||||
/** 场景用例 */
|
||||
export type TestCase = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
/** 失败后是否继续执行后续步骤 */
|
||||
continueOnFailure: boolean;
|
||||
steps: TestStep[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type AssertionResult = {
|
||||
label: string;
|
||||
passed: boolean;
|
||||
/** 实际值(截断展示) */
|
||||
actual: string;
|
||||
expected: string;
|
||||
};
|
||||
|
||||
export type TestStepResult = {
|
||||
stepId: string;
|
||||
name: string;
|
||||
method: string;
|
||||
url: string;
|
||||
ok: boolean;
|
||||
httpStatus: number | null;
|
||||
durationMs: number | null;
|
||||
error: string | null;
|
||||
assertionResults: AssertionResult[];
|
||||
};
|
||||
|
||||
export type TestRunTrigger = "manual" | "ai" | "schedule";
|
||||
|
||||
/** 一次运行报告 */
|
||||
export type TestRun = {
|
||||
id: string;
|
||||
caseId: string;
|
||||
caseName: string;
|
||||
at: number;
|
||||
trigger: TestRunTrigger;
|
||||
totalMs: number;
|
||||
passed: boolean;
|
||||
stepResults: TestStepResult[];
|
||||
};
|
||||
|
||||
/** 定时任务(应用运行期间生效) */
|
||||
export type TestSchedule = {
|
||||
caseId: string;
|
||||
enabled: boolean;
|
||||
/** 间隔分钟 */
|
||||
intervalMinutes: number;
|
||||
lastRunAt: number | null;
|
||||
};
|
||||
|
||||
/* ---------------- AI 助手 ---------------- */
|
||||
|
||||
/** OpenAI 兼容供应商配置 */
|
||||
export type AiProvider = {
|
||||
id: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type AiSettings = {
|
||||
providers: AiProvider[];
|
||||
activeProviderId: string | null;
|
||||
};
|
||||
|
||||
/** 导入结果:集合 + 建议写入环境的前缀变量(如 baseUrl) */
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
describeAssertion,
|
||||
evaluateAssertion,
|
||||
exportRunMarkdown,
|
||||
extractVariables,
|
||||
newTestCase,
|
||||
queryJsonPath,
|
||||
runStats,
|
||||
suggestAssertions,
|
||||
type StepHttpResponse,
|
||||
} from "./testing";
|
||||
import type { TestAssertion } from "./model";
|
||||
|
||||
const res: StepHttpResponse = {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json", "X-Trace-Id": "abc123" },
|
||||
body: JSON.stringify({ code: 0, data: { id: 42, name: "opsx", tags: ["a", "b"] }, msg: "ok" }),
|
||||
timeMs: 123,
|
||||
};
|
||||
|
||||
const as = (over: Partial<TestAssertion>): TestAssertion => ({
|
||||
id: "a1",
|
||||
enabled: true,
|
||||
target: "status",
|
||||
locator: "",
|
||||
op: "eq",
|
||||
expected: "",
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("queryJsonPath", () => {
|
||||
it("基本路径", () => {
|
||||
const obj = JSON.parse(res.body);
|
||||
expect(queryJsonPath(obj, "$.code")).toBe(0);
|
||||
expect(queryJsonPath(obj, "$.data.id")).toBe(42);
|
||||
expect(queryJsonPath(obj, "$.data.tags[1]")).toBe("b");
|
||||
expect(queryJsonPath(obj, "$['data']['name']")).toBe("opsx");
|
||||
expect(queryJsonPath(obj, "$.data.missing")).toBeUndefined();
|
||||
expect(queryJsonPath(obj, "$")).toEqual(obj);
|
||||
});
|
||||
});
|
||||
|
||||
describe("断言评估", () => {
|
||||
it("状态码 / 耗时(数字比较)", () => {
|
||||
expect(evaluateAssertion(res, as({ target: "status", op: "eq", expected: "200" })).passed).toBe(true);
|
||||
expect(evaluateAssertion(res, as({ target: "status", op: "lt", expected: "300" })).passed).toBe(true);
|
||||
expect(evaluateAssertion(res, as({ target: "timeMs", op: "gt", expected: "100" })).passed).toBe(true);
|
||||
expect(evaluateAssertion(res, as({ target: "timeMs", op: "lt", expected: "50" })).passed).toBe(false);
|
||||
});
|
||||
|
||||
it("Header 大小写不敏感", () => {
|
||||
expect(evaluateAssertion(res, as({ target: "header", locator: "x-trace-id", op: "contains", expected: "abc" })).passed).toBe(true);
|
||||
expect(evaluateAssertion(res, as({ target: "header", locator: "X-Missing", op: "exists" })).passed).toBe(false);
|
||||
});
|
||||
|
||||
it("JSONPath 断言", () => {
|
||||
expect(evaluateAssertion(res, as({ target: "body", locator: "$.data.name", op: "jsonpath_eq", expected: "opsx" })).passed).toBe(true);
|
||||
expect(evaluateAssertion(res, as({ target: "body", locator: "$.data.id", op: "jsonpath_eq", expected: "43" })).passed).toBe(false);
|
||||
expect(evaluateAssertion(res, as({ target: "body", locator: "$.msg", op: "jsonpath_contains", expected: "o" })).passed).toBe(true);
|
||||
expect(evaluateAssertion(res, as({ target: "body", locator: "$.data.name", op: "exists" })).passed).toBe(true);
|
||||
expect(evaluateAssertion(res, as({ target: "body", locator: "$.none", op: "not_exists" })).passed).toBe(true);
|
||||
});
|
||||
|
||||
it("非 JSON 响应的 jsonpath 断言失败但不抛错", () => {
|
||||
const plain = { ...res, body: "hello world" };
|
||||
const r = evaluateAssertion(plain, as({ target: "body", locator: "$.a", op: "jsonpath_eq", expected: "1" }));
|
||||
expect(r.passed).toBe(false);
|
||||
expect(r.actual).toContain("非 JSON");
|
||||
});
|
||||
|
||||
it("断言文案", () => {
|
||||
expect(describeAssertion(as({ target: "status", op: "eq", expected: "200" }))).toContain("状态码");
|
||||
expect(describeAssertion(as({ target: "body", locator: "$.a", op: "jsonpath_contains", expected: "x" }))).toContain("$.a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("变量提取", () => {
|
||||
it("提取到平面对象", () => {
|
||||
const out = extractVariables(res, [
|
||||
{ id: "e1", jsonpath: "$.data.id", varName: "petId" },
|
||||
{ id: "e2", jsonpath: "$.data.name", varName: "petName" },
|
||||
{ id: "e3", jsonpath: "$.none", varName: "missing" },
|
||||
{ id: "e4", jsonpath: "", varName: "bad" },
|
||||
]);
|
||||
expect(out).toEqual({ petId: "42", petName: "opsx" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("默认断言与统计", () => {
|
||||
it("suggestAssertions 至少含状态码与耗时", () => {
|
||||
const list = suggestAssertions({ method: "GET", url: "https://x" } as never);
|
||||
expect(list.length).toBeGreaterThanOrEqual(2);
|
||||
expect(list[0].target).toBe("status");
|
||||
});
|
||||
it("runStats", () => {
|
||||
expect(runStats({ stepResults: [{ ok: true }, { ok: false }, { ok: true }] })).toEqual({ ok: 2, fail: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("报告导出", () => {
|
||||
it("Markdown 结构", () => {
|
||||
const md = exportRunMarkdown({
|
||||
caseName: "下单流程",
|
||||
at: Date.now(),
|
||||
trigger: "manual",
|
||||
totalMs: 500,
|
||||
passed: false,
|
||||
stepResults: [
|
||||
{
|
||||
name: "创建订单",
|
||||
method: "POST",
|
||||
url: "https://api.test/orders",
|
||||
ok: true,
|
||||
httpStatus: 200,
|
||||
durationMs: 200,
|
||||
error: null,
|
||||
assertionResults: [{ label: "状态码 = 200", passed: true, actual: "200", expected: "200" }],
|
||||
},
|
||||
{
|
||||
name: "查询订单",
|
||||
method: "GET",
|
||||
url: "https://api.test/orders/1",
|
||||
ok: false,
|
||||
httpStatus: 404,
|
||||
durationMs: 300,
|
||||
error: null,
|
||||
assertionResults: [{ label: "状态码 = 200", passed: false, actual: "404", expected: "200" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(md).toContain("# 测试报告 · 下单流程");
|
||||
expect(md).toContain("1/2 步骤通过");
|
||||
expect(md).toContain("POST https://api.test/orders");
|
||||
expect(md).toContain("| 状态码 = 200 | 200 | 404 | ❌ |");
|
||||
});
|
||||
});
|
||||
|
||||
describe("用例构造", () => {
|
||||
it("newTestCase 默认值", () => {
|
||||
const tc = newTestCase("冒烟");
|
||||
expect(tc.steps).toEqual([]);
|
||||
expect(tc.continueOnFailure).toBe(false);
|
||||
expect(tc.id).toMatch(/^tc-/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* 自动化测试引擎(纯函数):JSONPath 求值 / 断言评估 / 变量提取 / 报告导出。
|
||||
*/
|
||||
import type {
|
||||
AssertOp,
|
||||
AssertionResult,
|
||||
HttpRequestData,
|
||||
TestAssertion,
|
||||
TestCase,
|
||||
TestStep,
|
||||
VariableExtract,
|
||||
} from "./model";
|
||||
|
||||
export type StepHttpResponse = {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
timeMs: number;
|
||||
};
|
||||
|
||||
/* ---------------- JSONPath($.a.b[0].c / $['k'] 子集) ---------------- */
|
||||
|
||||
/** 返回命中的第一个值;未命中返回 undefined */
|
||||
export function queryJsonPath(data: unknown, path: string): unknown {
|
||||
const normalized = path.trim().replace(/^\$\.?/, "");
|
||||
if (normalized === "" || normalized === "$") return data;
|
||||
|
||||
const tokens: string[] = [];
|
||||
const re = /([^[\].]+)|\['([^']+)'\]|\["([^"]+)"\]|\[(\d+)\]/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(normalized)) !== null) {
|
||||
if (m[1] !== undefined) tokens.push(m[1]);
|
||||
else if (m[2] !== undefined) tokens.push(m[2]);
|
||||
else if (m[3] !== undefined) tokens.push(m[3]);
|
||||
else if (m[4] !== undefined) tokens.push(m[4]);
|
||||
}
|
||||
|
||||
let cur: unknown = data;
|
||||
for (const t of tokens) {
|
||||
if (cur === null || cur === undefined) return undefined;
|
||||
if (Array.isArray(cur)) {
|
||||
const idx = Number(t);
|
||||
if (Number.isNaN(idx)) return undefined;
|
||||
cur = cur[idx];
|
||||
} else if (typeof cur === "object") {
|
||||
cur = (cur as Record<string, unknown>)[t];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
function stringifyValue(v: unknown): string {
|
||||
if (v === undefined) return "";
|
||||
if (typeof v === "object") {
|
||||
try {
|
||||
return JSON.stringify(v);
|
||||
} catch {
|
||||
return String(v);
|
||||
}
|
||||
}
|
||||
return String(v);
|
||||
}
|
||||
|
||||
/* ---------------- 断言评估 ---------------- */
|
||||
|
||||
export function describeAssertion(a: TestAssertion): string {
|
||||
const where =
|
||||
a.target === "status"
|
||||
? "状态码"
|
||||
: a.target === "timeMs"
|
||||
? "耗时(ms)"
|
||||
: a.target === "header"
|
||||
? `Header ${a.locator}`
|
||||
: a.op.startsWith("jsonpath")
|
||||
? `Body ${a.locator}`
|
||||
: "Body";
|
||||
const OPS: Record<AssertOp, string> = {
|
||||
eq: "=",
|
||||
neq: "≠",
|
||||
lt: "<",
|
||||
gt: ">",
|
||||
contains: "包含",
|
||||
not_contains: "不包含",
|
||||
exists: "存在",
|
||||
not_exists: "不存在",
|
||||
jsonpath_eq: "=",
|
||||
jsonpath_contains: "包含",
|
||||
};
|
||||
const needExpected = !(a.op === "exists" || a.op === "not_exists");
|
||||
return `${where} ${OPS[a.op]}${needExpected ? ` ${a.expected}` : ""}`;
|
||||
}
|
||||
|
||||
export function evaluateAssertion(
|
||||
res: StepHttpResponse,
|
||||
a: TestAssertion,
|
||||
): AssertionResult {
|
||||
const base = { label: describeAssertion(a), expected: a.op === "exists" || a.op === "not_exists" ? "—" : a.expected };
|
||||
|
||||
let actual: string;
|
||||
let num: number | null = null;
|
||||
switch (a.target) {
|
||||
case "status":
|
||||
actual = String(res.status);
|
||||
num = res.status;
|
||||
break;
|
||||
case "timeMs":
|
||||
actual = String(res.timeMs);
|
||||
num = res.timeMs;
|
||||
break;
|
||||
case "header": {
|
||||
const key = Object.keys(res.headers).find((h) => h.toLowerCase() === a.locator.trim().toLowerCase());
|
||||
actual = key ? res.headers[key] : "";
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// body:jsonpath_* 与 exists(locator 以 $. 开头时)先解析再取值;contains 系直接对原文
|
||||
if (a.op.startsWith("jsonpath") || (a.locator.trim().startsWith("$.") && (a.op === "exists" || a.op === "not_exists"))) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(res.body);
|
||||
} catch {
|
||||
return { ...base, passed: false, actual: "(非 JSON 响应)" };
|
||||
}
|
||||
actual = stringifyValue(queryJsonPath(parsed, a.locator));
|
||||
} else {
|
||||
actual = res.body;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const expectedNum = Number(a.expected);
|
||||
const numeric = num !== null && a.expected.trim() !== "" && !Number.isNaN(expectedNum);
|
||||
|
||||
let passed = false;
|
||||
switch (a.op) {
|
||||
case "eq":
|
||||
passed = numeric ? num === expectedNum : actual.trim() === a.expected.trim();
|
||||
break;
|
||||
case "neq":
|
||||
passed = numeric ? num !== expectedNum : actual.trim() !== a.expected.trim();
|
||||
break;
|
||||
case "lt":
|
||||
passed = numeric && num !== null && num < expectedNum;
|
||||
break;
|
||||
case "gt":
|
||||
passed = numeric && num !== null && num > expectedNum;
|
||||
break;
|
||||
case "contains":
|
||||
passed = actual.includes(a.expected);
|
||||
break;
|
||||
case "not_contains":
|
||||
passed = !actual.includes(a.expected);
|
||||
break;
|
||||
case "exists":
|
||||
passed = actual !== "";
|
||||
break;
|
||||
case "not_exists":
|
||||
passed = actual === "";
|
||||
break;
|
||||
case "jsonpath_eq":
|
||||
passed = actual.trim() === a.expected.trim();
|
||||
break;
|
||||
case "jsonpath_contains":
|
||||
passed = actual.includes(a.expected);
|
||||
break;
|
||||
}
|
||||
return { ...base, passed, actual: actual.length > 300 ? `${actual.slice(0, 300)}…` : actual };
|
||||
}
|
||||
|
||||
/** 按 HTTP 节点推断默认断言 */
|
||||
export function suggestAssertions(data: HttpRequestData): TestAssertion[] {
|
||||
const mk = (target: TestAssertion["target"], locator: string, op: AssertOp, expected: string): TestAssertion => ({
|
||||
id: `as-${Math.random().toString(36).slice(2, 10)}`,
|
||||
enabled: true,
|
||||
target,
|
||||
locator,
|
||||
op,
|
||||
expected,
|
||||
});
|
||||
const out = [mk("status", "", "eq", "200"), mk("timeMs", "", "lt", "5000")];
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 执行变量提取:返回 { 变量名: 值 } */
|
||||
export function extractVariables(res: StepHttpResponse, extracts: VariableExtract[]): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
let parsed: unknown;
|
||||
let parsedOnce = false;
|
||||
for (const e of extracts) {
|
||||
if (!e.varName.trim() || !e.jsonpath.trim()) continue;
|
||||
if (!parsedOnce) {
|
||||
try {
|
||||
parsed = JSON.parse(res.body);
|
||||
} catch {
|
||||
parsed = undefined;
|
||||
}
|
||||
parsedOnce = true;
|
||||
}
|
||||
const v = queryJsonPath(parsed, e.jsonpath);
|
||||
if (v !== undefined) out[e.varName.trim()] = stringifyValue(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ---------------- 报告导出 ---------------- */
|
||||
|
||||
export function exportRunMarkdown(run: {
|
||||
caseName: string;
|
||||
at: number;
|
||||
trigger: string;
|
||||
totalMs: number;
|
||||
passed: boolean;
|
||||
stepResults: {
|
||||
name: string;
|
||||
method: string;
|
||||
url: string;
|
||||
ok: boolean;
|
||||
httpStatus: number | null;
|
||||
durationMs: number | null;
|
||||
error: string | null;
|
||||
assertionResults: AssertionResult[];
|
||||
}[];
|
||||
}): string {
|
||||
const dt = new Date(run.at).toLocaleString("zh-CN");
|
||||
const total = run.stepResults.length;
|
||||
const okCount = run.stepResults.filter((s) => s.ok).length;
|
||||
const lines: string[] = [
|
||||
`# 测试报告 · ${run.caseName}`,
|
||||
"",
|
||||
`- 时间:${dt}`,
|
||||
`- 触发:${run.trigger === "schedule" ? "定时任务" : run.trigger === "ai" ? "AI 流程" : "手动运行"}`,
|
||||
`- 结果:**${run.passed ? "✅ 全部通过" : "❌ 存在失败"}**(${okCount}/${total} 步骤通过)`,
|
||||
`- 总耗时:${run.totalMs} ms`,
|
||||
"",
|
||||
];
|
||||
for (const [i, s] of run.stepResults.entries()) {
|
||||
lines.push(`## ${i + 1}. ${s.ok ? "✅" : "❌"} ${s.name}`);
|
||||
lines.push("");
|
||||
lines.push(`- 请求:\`${s.method} ${s.url}\``);
|
||||
if (s.httpStatus !== null) lines.push(`- 状态码:${s.httpStatus}`);
|
||||
if (s.durationMs !== null) lines.push(`- 耗时:${s.durationMs} ms`);
|
||||
if (s.error) lines.push(`- 错误:${s.error}`);
|
||||
if (s.assertionResults.length) {
|
||||
lines.push("", "| 断言 | 期望 | 实际 | 结果 |", "| --- | --- | --- | --- |");
|
||||
for (const a of s.assertionResults) {
|
||||
lines.push(`| ${a.label} | ${a.expected} | ${a.actual || "—"} | ${a.passed ? "✅" : "❌"} |`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/* ---------------- 用例构造 ---------------- */
|
||||
|
||||
export function newAssertion(): TestAssertion {
|
||||
return {
|
||||
id: `as-${Math.random().toString(36).slice(2, 10)}`,
|
||||
enabled: true,
|
||||
target: "status",
|
||||
locator: "",
|
||||
op: "eq",
|
||||
expected: "200",
|
||||
};
|
||||
}
|
||||
|
||||
export function newExtract(): VariableExtract {
|
||||
return { id: `ex-${Math.random().toString(36).slice(2, 10)}`, jsonpath: "$.", varName: "" };
|
||||
}
|
||||
|
||||
export function newStep(nodeId: string, data?: HttpRequestData): TestStep {
|
||||
return {
|
||||
id: `st-${Math.random().toString(36).slice(2, 10)}`,
|
||||
enabled: true,
|
||||
nodeId,
|
||||
assertions: data ? suggestAssertions(data) : [newAssertion()],
|
||||
extracts: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function newTestCase(name = "未命名用例"): TestCase {
|
||||
return {
|
||||
id: `tc-${Math.random().toString(36).slice(2, 10)}`,
|
||||
name,
|
||||
continueOnFailure: false,
|
||||
steps: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 统计:通过/失败步骤数 */
|
||||
export function runStats(run: { stepResults: { ok: boolean }[] }): { ok: number; fail: number } {
|
||||
const ok = run.stepResults.filter((s) => s.ok).length;
|
||||
return { ok, fail: run.stepResults.length - ok };
|
||||
}
|
||||
Reference in new issue
Block a user