refactor: 统一 expect 断言体系,引入共享 ValueMatcher/ContentRules/KeyValueExpect 模型
- 引入共享 ValueMatcher(equals/contains/regex/exists/empty/gt/gte/lt/lte) - 引入共享 ContentRules 数组(direct/json/css/xpath 提取器) - 引入共享 KeyValueExpect(动态键值断言,字面量等价 equals) - maxDurationMs → durationMs: ValueMatcher(所有 checker) - match → regex(固定无 flags) - Ping max* → packetLossPercent/avgLatencyMs/maxLatencyMs(ValueMatcher) - LLM finishReason/rawFinishReason → ValueMatcher - DB 新增 result: ContentRules - TCP banner → ContentRules 数组 - 删除旧模块:operator.ts、validate-operator.ts、duration.ts、body.ts、text.ts、output.ts - 更新全部 checker schema/validate/expect/execute - 更新 probe-config.schema.json、probes.example.yaml - 更新 README.md、DEVELOPMENT.md(含 expect 字段选择规范) - 同步 10 个 delta specs 到主 specs,归档 change
This commit is contained in:
@@ -6,8 +6,9 @@ import type { CheckResult, RawTargetConfig } from "../../types";
|
||||
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type { DbExpectConfig, DbTargetConfig, ResolvedDbTarget } from "./types";
|
||||
|
||||
import { checkDuration } from "../../expect/duration";
|
||||
import { checkContentRules } from "../../expect/content";
|
||||
import { errorFailure } from "../../expect/failure";
|
||||
import { checkValueMatcher } from "../../expect/matcher";
|
||||
import { checkRowCount, checkRows } from "./expect";
|
||||
import { dbCheckerSchemas } from "./schema";
|
||||
import { validateDbConfig } from "./validate";
|
||||
@@ -59,7 +60,11 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
// 无 query 时仅测试连接
|
||||
if (!t.db.query) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const durationResult = checkDuration(durationMs, t.expect?.maxDurationMs);
|
||||
const durationResult = checkValueMatcher(durationMs, t.expect?.durationMs, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
phase: "duration",
|
||||
});
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
@@ -111,7 +116,11 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
}
|
||||
|
||||
// duration 断言
|
||||
const durationResult = checkDuration(durationMs, t.expect?.maxDurationMs);
|
||||
const durationResult = checkValueMatcher(durationMs, t.expect?.durationMs, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
phase: "duration",
|
||||
});
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
@@ -125,7 +134,7 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
|
||||
// rowCount 断言
|
||||
if (t.expect?.rowCount) {
|
||||
const rowCountResult = checkRowCount(rows, t.expect.rowCount);
|
||||
const rowCountResult = checkRowCount(isArray(rows) ? rows.length : 0, t.expect.rowCount);
|
||||
if (!rowCountResult.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
@@ -153,6 +162,21 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
}
|
||||
}
|
||||
|
||||
if (t.expect?.result && t.expect.result.length > 0) {
|
||||
const rowCount = isArray(rows) ? rows.length : 0;
|
||||
const resultCheck = checkContentRules({ rowCount, rows }, t.expect.result, { path: "result", phase: "result" });
|
||||
if (!resultCheck.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: resultCheck.failure,
|
||||
matched: false,
|
||||
statusDetail: `${rowCount} rows`,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
durationMs,
|
||||
failure: null,
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import { isPlainObject } from "es-toolkit";
|
||||
import { isArray } from "es-toolkit/compat";
|
||||
|
||||
import type { ExpectResult } from "../../expect/types";
|
||||
import type { ExpectOperator, ExpectValue } from "../../types";
|
||||
import type { ExpectResult, KeyValueExpect, ValueMatcher } from "../../expect/types";
|
||||
|
||||
import { mismatchFailure } from "../../expect/failure";
|
||||
import { checkExpectValue } from "../../expect/operator";
|
||||
import { checkKeyValueExpect } from "../../expect/key-value";
|
||||
import { checkValueMatcher } from "../../expect/matcher";
|
||||
|
||||
export function checkRowCount(rows: unknown, op: ExpectOperator): ExpectResult {
|
||||
const actual = isArray(rows) ? rows.length : 0;
|
||||
const matched = checkExpectValue(actual, op);
|
||||
if (!matched) {
|
||||
return {
|
||||
failure: mismatchFailure("rowCount", "rowCount", op, actual, `rowCount ${actual} 不满足条件`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { failure: null, matched: true };
|
||||
export function checkRowCount(actual: number, matcher: ValueMatcher): ExpectResult {
|
||||
return checkValueMatcher(actual, matcher, {
|
||||
message: `rowCount ${actual} 不满足条件`,
|
||||
path: "rowCount",
|
||||
phase: "rowCount",
|
||||
});
|
||||
}
|
||||
|
||||
export function checkRows(rows: unknown, rules: Array<Record<string, ExpectValue>>): ExpectResult {
|
||||
export function checkRows(rows: unknown, rules: KeyValueExpect[]): ExpectResult {
|
||||
if (!isArray(rows)) {
|
||||
return {
|
||||
failure: mismatchFailure("row", "rows", rules, rows, "查询结果不是数组"),
|
||||
@@ -44,16 +40,8 @@ export function checkRows(rows: unknown, rules: Array<Record<string, ExpectValue
|
||||
};
|
||||
}
|
||||
|
||||
for (const [col, expected] of Object.entries(rule)) {
|
||||
const actual = row[col];
|
||||
const matched = checkExpectValue(actual, expected);
|
||||
if (!matched) {
|
||||
return {
|
||||
failure: mismatchFailure("row", `rows[${i}].${col}`, expected, actual, `rows[${i}].${col} mismatch`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
const result = checkKeyValueExpect(row, rule, { path: `rows[${i}]`, phase: "row" });
|
||||
if (!result.matched) return result;
|
||||
}
|
||||
|
||||
return { failure: null, matched: true };
|
||||
|
||||
@@ -2,10 +2,7 @@ import { Type } from "@sinclair/typebox";
|
||||
|
||||
import type { CheckerSchemas } from "../types";
|
||||
|
||||
import { createPureOperatorSchema, jsonValueSchema, operatorProperties } from "../../schema/fragments";
|
||||
|
||||
// Db expect 允许行对象中的列值为字面量或 operator
|
||||
const dbRowValueSchema = Type.Union([jsonValueSchema, createPureOperatorSchema()]);
|
||||
import { createContentRulesSchema, createKeyValueExpectSchema, createValueMatcherSchema } from "../../schema/fragments";
|
||||
|
||||
export const dbCheckerSchemas: CheckerSchemas = {
|
||||
config: Type.Object(
|
||||
@@ -22,31 +19,11 @@ export const dbCheckerSchemas: CheckerSchemas = {
|
||||
defaults: Type.Object({}, { additionalProperties: false }),
|
||||
expect: Type.Object(
|
||||
{
|
||||
maxDurationMs: Type.Optional(Type.Number({ minimum: 0 })),
|
||||
rowCount: Type.Optional(createPureOperatorSchema()),
|
||||
rows: Type.Optional(
|
||||
Type.Array(
|
||||
Type.Record(Type.String(), dbRowValueSchema, {
|
||||
additionalProperties: false,
|
||||
minProperties: 1,
|
||||
}),
|
||||
),
|
||||
),
|
||||
durationMs: Type.Optional(createValueMatcherSchema()),
|
||||
result: Type.Optional(createContentRulesSchema()),
|
||||
rowCount: Type.Optional(createValueMatcherSchema()),
|
||||
rows: Type.Optional(Type.Array(createKeyValueExpectSchema())),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
};
|
||||
|
||||
// 导出用于 validate 的辅助类型
|
||||
export const DbOperatorKeys = new Set<string>([
|
||||
...Object.keys(operatorProperties()),
|
||||
"contains",
|
||||
"empty",
|
||||
"equals",
|
||||
"exists",
|
||||
"gt",
|
||||
"gte",
|
||||
"lt",
|
||||
"lte",
|
||||
"match",
|
||||
]);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { ExpectOperator, ExpectValue, ResolvedTargetBase } from "../../types";
|
||||
import type { ContentRules, KeyValueExpect, ValueMatcher } from "../../expect/types";
|
||||
import type { ResolvedTargetBase } from "../../types";
|
||||
|
||||
export interface DbExpectConfig {
|
||||
maxDurationMs?: number;
|
||||
rowCount?: ExpectOperator;
|
||||
rows?: Array<Record<string, ExpectValue>>;
|
||||
durationMs?: ValueMatcher;
|
||||
result?: ContentRules;
|
||||
rowCount?: ValueMatcher;
|
||||
rows?: KeyValueExpect[];
|
||||
}
|
||||
|
||||
export interface DbTargetConfig {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { isNumber, isPlainObject, isString } from "es-toolkit";
|
||||
import { isPlainObject, isString } from "es-toolkit";
|
||||
import { isArray } from "es-toolkit/compat";
|
||||
|
||||
import type { ConfigValidationIssue } from "../../schema/issues";
|
||||
import type { CheckerValidationInput } from "../types";
|
||||
|
||||
import { isUnsafeRegex } from "../../expect/redos";
|
||||
import { validateOperatorObject } from "../../expect/validate-operator";
|
||||
import { validateContentRules, validateKeyValueExpect, validateValueMatcher } from "../../expect/validate-matcher";
|
||||
import { issue, joinPath } from "../../schema/issues";
|
||||
|
||||
export function validateDbConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
|
||||
@@ -21,7 +20,7 @@ export function validateDbConfig(input: CheckerValidationInput): ConfigValidatio
|
||||
return issues;
|
||||
}
|
||||
|
||||
function collectRowOperators(rows: unknown[], path: string, targetName?: string): ConfigValidationIssue[] {
|
||||
function collectRowExpects(rows: unknown[], path: string, targetName?: string): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]!;
|
||||
@@ -29,28 +28,7 @@ function collectRowOperators(rows: unknown[], path: string, targetName?: string)
|
||||
issues.push(issue("invalid-type", `${path}[${i}]`, "必须为对象", targetName));
|
||||
continue;
|
||||
}
|
||||
for (const [col, value] of Object.entries(row)) {
|
||||
const colPath = `${path}[${i}].${col}`;
|
||||
if (isPlainObject(value) && Object.keys(value).some((k) => k === "match")) {
|
||||
// 检查 match 正则
|
||||
const valueRecord = value as Record<string, unknown>;
|
||||
const match: unknown = valueRecord["match"];
|
||||
if (isString(match)) {
|
||||
try {
|
||||
new RegExp(match);
|
||||
} catch {
|
||||
issues.push(issue("invalid-regex", colPath, "正则不合法", targetName));
|
||||
}
|
||||
if (isUnsafeRegex(match)) {
|
||||
issues.push(issue("unsafe-regex", colPath, "正则存在 ReDoS 风险", targetName));
|
||||
}
|
||||
}
|
||||
}
|
||||
// 校验 operator 对象
|
||||
if (isPlainObject(value)) {
|
||||
issues.push(...validateOperatorObject(value, colPath, targetName, { requireAtLeastOne: false }));
|
||||
}
|
||||
}
|
||||
issues.push(...validateKeyValueExpect(row, `${path}[${i}]`, targetName));
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
@@ -60,10 +38,6 @@ function getTargetName(target: Record<string, unknown>): string | undefined {
|
||||
return isString(target["id"]) ? target["id"] : undefined;
|
||||
}
|
||||
|
||||
function isNonNegativeFiniteNumber(value: unknown): boolean {
|
||||
return isNumber(value) && Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
function validateDbExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||
const targetName = getTargetName(target);
|
||||
const expect = target["expect"];
|
||||
@@ -71,24 +45,28 @@ function validateDbExpect(target: Record<string, unknown>, path: string): Config
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const expectPath = joinPath(path, "expect");
|
||||
|
||||
if (expect["maxDurationMs"] !== undefined && !isNonNegativeFiniteNumber(expect["maxDurationMs"])) {
|
||||
issues.push(issue("invalid-type", joinPath(expectPath, "maxDurationMs"), "必须为非负有限数字", targetName));
|
||||
if (expect["durationMs"] !== undefined) {
|
||||
issues.push(...validateValueMatcher(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
|
||||
}
|
||||
|
||||
if (expect["rowCount"] !== undefined) {
|
||||
issues.push(...validateOperatorObject(expect["rowCount"], joinPath(expectPath, "rowCount"), targetName));
|
||||
issues.push(...validateValueMatcher(expect["rowCount"], joinPath(expectPath, "rowCount"), targetName));
|
||||
}
|
||||
|
||||
if (expect["rows"] !== undefined) {
|
||||
if (!isArray(expect["rows"])) {
|
||||
issues.push(issue("invalid-type", joinPath(expectPath, "rows"), "必须为数组", targetName));
|
||||
} else {
|
||||
issues.push(...collectRowOperators(expect["rows"], joinPath(expectPath, "rows"), targetName));
|
||||
issues.push(...collectRowExpects(expect["rows"], joinPath(expectPath, "rows"), targetName));
|
||||
}
|
||||
}
|
||||
|
||||
if (expect["result"] !== undefined) {
|
||||
issues.push(...validateContentRules(expect["result"], joinPath(expectPath, "result"), targetName));
|
||||
}
|
||||
|
||||
// 检查未知字段
|
||||
const allowedKeys = new Set(["maxDurationMs", "rowCount", "rows"]);
|
||||
const allowedKeys = new Set(["durationMs", "result", "rowCount", "rows"]);
|
||||
for (const key of Object.keys(expect)) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
|
||||
|
||||
Reference in New Issue
Block a user