- app.ts 单体路由拆分为 routes/ + helpers + middleware + static 独立模块 - 类型去重:CheckFailure/CheckResult 以 shared/api.ts 为唯一源头,收紧 phase 联合类型 - es-toolkit 替换:isPlainObject/isNil/isEmptyObject/isEqual/isError/Semaphore/groupBy - Bun 内置 API:Object.fromEntries 替代手写 headersToRecord - bun:sqlite 规范:prepare() → query() 利用内置缓存,避免 N+1 查询 - 新增 getLatestChecksMap/allGetTargetStats 批量查询方法 - 新增 backend-code-quality/api-route-separation/batch-data-queries 规范 - 补充 openspec/config.yaml 后端开发规范与 DEVELOPMENT.md 后端开发指引
298 lines
8.4 KiB
TypeScript
298 lines
8.4 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";
|
|
import { isNil, isEmptyObject, isEqual, isPlainObject } from "es-toolkit";
|
|
|
|
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 (!isEqual(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 =
|
|
isNil(actual) || actual === "" || (Array.isArray(actual) && actual.length === 0) || isEmptyObject(actual);
|
|
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 (isPlainObject(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 };
|
|
}
|