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 与示例配置
This commit is contained in:
302
src/server/checker/expect/body.ts
Normal file
302
src/server/checker/expect/body.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
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 };
|
||||
}
|
||||
91
src/server/checker/expect/command.ts
Normal file
91
src/server/checker/expect/command.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { CheckFailure, CommandExpectConfig, TextRule } from "../types";
|
||||
import { applyOperator } from "./body";
|
||||
import { mismatchFailure } from "./failure";
|
||||
|
||||
export interface CommandObservation {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
function checkExitCode(obs: CommandObservation, allowed: number[]): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (!allowed.includes(obs.exitCode)) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure(
|
||||
"exitCode",
|
||||
"exitCode",
|
||||
allowed,
|
||||
obs.exitCode,
|
||||
`exitCode ${obs.exitCode} not in [${allowed}]`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
function checkDuration(
|
||||
obs: CommandObservation,
|
||||
maxDurationMs?: number,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (maxDurationMs === undefined) return { matched: true, failure: null };
|
||||
if (obs.durationMs > maxDurationMs) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure(
|
||||
"duration",
|
||||
"duration",
|
||||
`<=${maxDurationMs}ms`,
|
||||
obs.durationMs,
|
||||
`duration ${obs.durationMs}ms > ${maxDurationMs}ms`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
function checkTextRules(
|
||||
text: string,
|
||||
rules: TextRule[],
|
||||
phase: "stdout" | "stderr",
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
const rule = rules[i]!;
|
||||
const path = `${phase}[${i}]`;
|
||||
if (!applyOperator(text, rule)) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure(phase, path, rule, text, `${phase} rule at index ${i} mismatch`),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
export function checkCommandExpect(
|
||||
obs: CommandObservation,
|
||||
expect?: CommandExpectConfig,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (!expect) {
|
||||
return checkExitCode(obs, [0]);
|
||||
}
|
||||
|
||||
const exitCodeResult = checkExitCode(obs, expect.exitCode ?? [0]);
|
||||
if (!exitCodeResult.matched) return exitCodeResult;
|
||||
|
||||
const durationResult = checkDuration(obs, expect.maxDurationMs);
|
||||
if (!durationResult.matched) return durationResult;
|
||||
|
||||
if (expect.stdout && expect.stdout.length > 0) {
|
||||
const stdoutResult = checkTextRules(obs.stdout, expect.stdout, "stdout");
|
||||
if (!stdoutResult.matched) return stdoutResult;
|
||||
}
|
||||
|
||||
if (expect.stderr && expect.stderr.length > 0) {
|
||||
const stderrResult = checkTextRules(obs.stderr, expect.stderr, "stderr");
|
||||
if (!stderrResult.matched) return stderrResult;
|
||||
}
|
||||
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
34
src/server/checker/expect/failure.ts
Normal file
34
src/server/checker/expect/failure.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { CheckFailure } from "../types";
|
||||
|
||||
export function truncateActual(value: unknown, maxLen = 200): unknown {
|
||||
if (value === undefined || value === null) return value;
|
||||
const str = String(value);
|
||||
if (str.length <= maxLen) return value;
|
||||
return str.slice(0, maxLen) + "...";
|
||||
}
|
||||
|
||||
export function mismatchFailure(
|
||||
phase: CheckFailure["phase"],
|
||||
path: string,
|
||||
expected: unknown,
|
||||
actual: unknown,
|
||||
message: string,
|
||||
): CheckFailure {
|
||||
return {
|
||||
kind: "mismatch",
|
||||
phase,
|
||||
path,
|
||||
expected,
|
||||
actual: truncateActual(actual),
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
export function errorFailure(phase: CheckFailure["phase"], path: string, message: string): CheckFailure {
|
||||
return {
|
||||
kind: "error",
|
||||
phase,
|
||||
path,
|
||||
message,
|
||||
};
|
||||
}
|
||||
122
src/server/checker/expect/http.ts
Normal file
122
src/server/checker/expect/http.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { BodyRule, CheckFailure, HeaderExpect, HttpExpectConfig } from "../types";
|
||||
import { checkBodyExpect } from "./body";
|
||||
import { applyOperator } from "./body";
|
||||
import { mismatchFailure, errorFailure } from "./failure";
|
||||
|
||||
export interface HttpObservation {
|
||||
statusCode: number;
|
||||
headers: Record<string, string>;
|
||||
body: string | null;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
function checkStatus(obs: HttpObservation, allowed: number[]): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (!allowed.includes(obs.statusCode)) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure(
|
||||
"status",
|
||||
"status",
|
||||
allowed,
|
||||
obs.statusCode,
|
||||
`status ${obs.statusCode} not in [${allowed}]`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
function checkDuration(
|
||||
obs: HttpObservation,
|
||||
maxDurationMs?: number,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (maxDurationMs === undefined) return { matched: true, failure: null };
|
||||
if (obs.durationMs > maxDurationMs) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure(
|
||||
"duration",
|
||||
"duration",
|
||||
`<=${maxDurationMs}ms`,
|
||||
obs.durationMs,
|
||||
`duration ${obs.durationMs}ms > ${maxDurationMs}ms`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
function checkHeaders(
|
||||
obs: HttpObservation,
|
||||
headerExpects?: Record<string, HeaderExpect>,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (!headerExpects) return { matched: true, failure: null };
|
||||
|
||||
for (const [key, expected] of Object.entries(headerExpects)) {
|
||||
const actualValue = obs.headers[key.toLowerCase()];
|
||||
const path = `headers.${key}`;
|
||||
|
||||
if (typeof expected === "string") {
|
||||
if (actualValue !== expected) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("headers", path, expected, actualValue, `header ${key} mismatch`),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
if (actualValue === undefined) {
|
||||
if (expected.exists !== false) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("headers", path, "defined", undefined, `header ${key} not found`),
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!applyOperator(actualValue, expected)) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("headers", path, expected, actualValue, `header ${key} mismatch`),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
function checkBody(obs: HttpObservation, bodyRules?: BodyRule[]): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (!bodyRules || bodyRules.length === 0) return { matched: true, failure: null };
|
||||
|
||||
if (obs.body === null) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: errorFailure("body", "body", "body is null but body rules are configured"),
|
||||
};
|
||||
}
|
||||
|
||||
return checkBodyExpect(obs.body, bodyRules);
|
||||
}
|
||||
|
||||
export function checkHttpExpect(
|
||||
obs: HttpObservation,
|
||||
expect?: HttpExpectConfig,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (!expect) {
|
||||
return checkStatus(obs, [200]);
|
||||
}
|
||||
|
||||
const statusResult = checkStatus(obs, expect.status ?? [200]);
|
||||
if (!statusResult.matched) return statusResult;
|
||||
|
||||
const durationResult = checkDuration(obs, expect.maxDurationMs);
|
||||
if (!durationResult.matched) return durationResult;
|
||||
|
||||
const headersResult = checkHeaders(obs, expect.headers);
|
||||
if (!headersResult.matched) return headersResult;
|
||||
|
||||
const bodyResult = checkBody(obs, expect.body);
|
||||
if (!bodyResult.matched) return bodyResult;
|
||||
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
Reference in New Issue
Block a user