Files
opsxapi/src/core/testing.ts
T
logan 2bb4ef7a95 桌面 GUI 外链修复 + 主题顺序调整(含结构扁平化进行中的工作区基线)
- 集成 tauri-plugin-opener:Cargo/lib.rs/capabilities/npm 四处接线,新增 src/openExternal.ts(桌面走 opener,浏览器回退 window.open,相对路径绝对化)
- 关于页 4 个链接、活动栏官网菜单、代理错误横幅 2 处健康检查链接全部改走 openExternal,桌面端可正常跳转
- 设置→外观 主题顺序调整为 深色/浅色/跟随系统
- release 二进制重编并验证(浏览器实测关于页链接可开文档页)
- 附带工作区中未提交的扁平化重构现状(另一会话进行中)
- homepage 落地页与文档终检通过(8787 / 与 /docs 双 200)
2026-10-16 03:03:36 +08:00

338 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 自动化测试引擎(纯函数):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");
}
/* ---------------- 用例构造 ---------------- */
/** JSON 报告(便于 CI / 二次处理) */
export function exportRunJson(run: {
id?: string;
caseId?: string;
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 {
return JSON.stringify(
{
schema: "opsxapi.test-run.v1",
id: run.id ?? null,
caseId: run.caseId ?? null,
caseName: run.caseName,
at: run.at,
trigger: run.trigger,
totalMs: run.totalMs,
passed: run.passed,
stats: runStats(run),
stepResults: run.stepResults,
},
null,
2,
);
}
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 };
}