1
0

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:
2026-05-19 14:24:27 +08:00
parent 349896bd02
commit 7a635a0a9f
85 changed files with 4290 additions and 2028 deletions

View File

@@ -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,

View File

@@ -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 };

View File

@@ -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",
]);

View File

@@ -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 {

View File

@@ -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));