1
0
Files
DiAL/src/server/checker/expect/body.ts
lanyuanxiaoyao b8810f1182 feat: 重构为多类型 checker 通用框架,支持 HTTP 与命令检查
- 引入 typed target 判别联合,支持 http 与 command 两种 checker
- expect 重构为有序规则数组,按配置顺序快速失败并生成结构化 failure
- 新增 command runner,支持 exec + args 本地命令执行
- 引入全局并发限制 maxConcurrentChecks 和 size 解析 (KB/MB/GB)
- HTTP/command 各自独立 expect pipeline,应用领域默认成功语义
- SQLite schema、API、Dashboard 全链路调整为 checker 通用契约
- 补充完整测试覆盖(192 tests),更新 README 与示例配置
2026-05-10 22:25:21 +08:00

303 lines
8.6 KiB
TypeScript

import type { BodyRule, CheckFailure, CssRule, ExpectOperator, ExpectValue, JsonRule, XpathRule } from "../types";
import * as cheerio from "cheerio";
import * as xpath from "xpath";
import { DOMParser } from "@xmldom/xmldom";
import { mismatchFailure, errorFailure } from "./failure";
const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
export function evaluateJsonPath(json: unknown, path: string): unknown {
if (!path.startsWith("$.")) return undefined;
const segments = path.slice(2).split(".");
let current: unknown = json;
for (const seg of segments) {
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
if (bracketMatch) {
current = (current as Record<string, unknown>)?.[bracketMatch[1]!];
const idx = parseInt(bracketMatch[2]!, 10);
if (!Array.isArray(current) || idx >= current.length) return undefined;
current = current[idx];
} else {
if (current === null || current === undefined) return undefined;
current = (current as Record<string, unknown>)[seg];
}
}
return current;
}
export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
for (const [key, expected] of Object.entries(op)) {
if (expected === undefined) continue;
switch (key) {
case "equals":
if (actual !== expected) return false;
break;
case "contains":
if (!String(actual).includes(expected as string)) return false;
break;
case "match":
if (!new RegExp(expected as string).test(String(actual))) return false;
break;
case "empty": {
const isEmpty =
actual === null ||
actual === undefined ||
actual === "" ||
(Array.isArray(actual) && actual.length === 0) ||
(typeof actual === "object" && Object.keys(actual as object).length === 0);
if (expected !== isEmpty) return false;
break;
}
case "exists":
if (expected) {
if (actual === undefined) return false;
} else {
if (actual !== undefined) return false;
}
break;
case "gte":
if (!(Number(actual) >= (expected as number))) return false;
break;
case "lte":
if (!(Number(actual) <= (expected as number))) return false;
break;
case "gt":
if (!(Number(actual) > (expected as number))) return false;
break;
case "lt":
if (!(Number(actual) < (expected as number))) return false;
break;
}
}
return true;
}
export function checkExpectValue(actual: unknown, expected: ExpectValue): boolean {
if (isObject(expected)) {
return applyOperator(actual, expected as ExpectOperator);
}
return applyOperator(actual, { equals: expected as string | number | boolean | null });
}
function checkJsonRule(
body: string,
rule: JsonRule,
rulePath: string,
): { matched: boolean; failure: CheckFailure | null } {
const { path, ...operators } = rule;
const fullPath = `${rulePath}.json(${path})`;
let json: unknown;
try {
json = JSON.parse(body);
} catch {
return {
matched: false,
failure: errorFailure("body", fullPath, "body is not valid JSON"),
};
}
const actual = evaluateJsonPath(json, path);
const opKeys = Object.keys(operators);
if (opKeys.length === 0) {
if (actual === undefined) {
return {
matched: false,
failure: mismatchFailure("body", fullPath, "defined", actual, `path ${path} is undefined`),
};
}
return { matched: true, failure: null };
}
const matched = applyOperator(actual, operators);
if (!matched) {
return {
matched: false,
failure: mismatchFailure("body", fullPath, operators, actual, `json path ${path} mismatch`),
};
}
return { matched: true, failure: null };
}
function checkCssRule(
body: string,
rule: CssRule,
rulePath: string,
): { matched: boolean; failure: CheckFailure | null } {
const { selector, attr, ...operators } = rule;
const fullPath = `${rulePath}.css(${selector}${attr ? `@${attr}` : ""})`;
let $: cheerio.CheerioAPI;
try {
$ = cheerio.load(body);
} catch {
return {
matched: false,
failure: errorFailure("body", fullPath, "failed to parse HTML"),
};
}
const el = $(selector);
const opKeys = Object.keys(operators);
if (opKeys.length === 0) {
if (attr !== undefined) {
if (el.attr(attr) === undefined) {
return {
matched: false,
failure: mismatchFailure("body", fullPath, "attr present", undefined, `attribute ${attr} not found`),
};
}
return { matched: true, failure: null };
}
if (el.length === 0) {
return {
matched: false,
failure: mismatchFailure("body", fullPath, "element found", "no match", `selector ${selector} not found`),
};
}
return { matched: true, failure: null };
}
if (operators.exists === true) {
if (el.length === 0) {
return {
matched: false,
failure: mismatchFailure("body", fullPath, true, false, `selector ${selector} not found`),
};
}
return { matched: true, failure: null };
}
if (operators.exists === false) {
if (el.length > 0) {
return {
matched: false,
failure: mismatchFailure("body", fullPath, false, true, `selector ${selector} exists`),
};
}
return { matched: true, failure: null };
}
if (el.length === 0) {
return {
matched: false,
failure: mismatchFailure("body", fullPath, "element found", "no match", `selector ${selector} not found`),
};
}
const actual = attr ? el.attr(attr) : el.text();
const matched = applyOperator(actual ?? "", operators);
if (!matched) {
return {
matched: false,
failure: mismatchFailure("body", fullPath, operators, actual, `css selector ${selector} mismatch`),
};
}
return { matched: true, failure: null };
}
function checkXpathRule(
body: string,
rule: XpathRule,
rulePath: string,
): { matched: boolean; failure: CheckFailure | null } {
const { path, ...operators } = rule;
const fullPath = `${rulePath}.xpath(${path})`;
let doc: ReturnType<DOMParser["parseFromString"]>;
try {
doc = new DOMParser().parseFromString(body, "text/xml");
} catch {
return {
matched: false,
failure: errorFailure("body", fullPath, "failed to parse XML/HTML"),
};
}
const nodes = xpath.select(path, doc as unknown as Node);
if (!nodes || !Array.isArray(nodes) || nodes.length === 0) {
return {
matched: false,
failure: mismatchFailure("body", fullPath, "node found", "no match", `xpath ${path} not found`),
};
}
const node = nodes[0]!;
const actual = node.nodeValue ?? (node as unknown as Element).textContent ?? "";
const opKeys = Object.keys(operators);
if (opKeys.length === 0) {
return { matched: true, failure: null };
}
const matched = applyOperator(actual, operators);
if (!matched) {
return {
matched: false,
failure: mismatchFailure("body", fullPath, operators, actual, `xpath ${path} mismatch`),
};
}
return { matched: true, failure: null };
}
function checkSingleBodyRule(
body: string,
rule: BodyRule,
index: number,
): { matched: boolean; failure: CheckFailure | null } {
const rulePath = `body[${index}]`;
if ("contains" in rule) {
const matched = body.includes(rule.contains);
if (!matched) {
return {
matched: false,
failure: mismatchFailure("body", rulePath, rule.contains, body, `body does not contain "${rule.contains}"`),
};
}
return { matched: true, failure: null };
}
if ("regex" in rule) {
const matched = new RegExp(rule.regex).test(body);
if (!matched) {
return {
matched: false,
failure: mismatchFailure("body", rulePath, `/${rule.regex}/`, body, `body does not match /${rule.regex}/`),
};
}
return { matched: true, failure: null };
}
if ("json" in rule) {
return checkJsonRule(body, rule.json, rulePath);
}
if ("css" in rule) {
return checkCssRule(body, rule.css, rulePath);
}
if ("xpath" in rule) {
return checkXpathRule(body, rule.xpath, rulePath);
}
return { matched: true, failure: null };
}
export function checkBodyExpect(body: string, rules?: BodyRule[]): { matched: boolean; failure: CheckFailure | null } {
if (!rules || rules.length === 0) return { matched: true, failure: null };
for (let i = 0; i < rules.length; i++) {
const result = checkSingleBodyRule(body, rules[i]!, i);
if (!result.matched) return result;
}
return { matched: true, failure: null };
}