- 集成 tauri-plugin-opener:Cargo/lib.rs/capabilities/npm 四处接线,新增 src/openExternal.ts(桌面走 opener,浏览器回退 window.open,相对路径绝对化) - 关于页 4 个链接、活动栏官网菜单、代理错误横幅 2 处健康检查链接全部改走 openExternal,桌面端可正常跳转 - 设置→外观 主题顺序调整为 深色/浅色/跟随系统 - release 二进制重编并验证(浏览器实测关于页链接可开文档页) - 附带工作区中未提交的扁平化重构现状(另一会话进行中) - homepage 落地页与文档终检通过(8787 / 与 /docs 双 200)
338 lines
9.5 KiB
TypeScript
338 lines
9.5 KiB
TypeScript
/**
|
||
* 自动化测试引擎(纯函数):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 };
|
||
}
|