/** * 自动化测试引擎(纯函数):JSONPath 求值 / 断言评估 / 变量提取 / 报告导出。 */ import type { AssertOp, AssertionResult, HttpRequestData, TestAssertion, TestCase, TestStep, VariableExtract, } from "./model"; export type StepHttpResponse = { status: number; headers: Record; 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)[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 = { 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 { const out: Record = {}; 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 }; }