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

@@ -5,12 +5,12 @@ import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { CommandExpectConfig, CommandTargetConfig, ResolvedCommandTarget } from "./types";
import { checkDuration } from "../../expect/duration";
import { checkContentRules } from "../../expect/content";
import { errorFailure } from "../../expect/failure";
import { checkValueMatcher } from "../../expect/matcher";
import { parseSize } from "../../utils";
import { checkExitCode } from "./expect";
import { commandCheckerSchemas } from "./schema";
import { checkTextRules } from "./text";
import { validateCommandConfig } from "./validate";
export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget> {
@@ -118,7 +118,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
};
}
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,
@@ -131,7 +135,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
}
if (t.expect?.stdout && t.expect.stdout.length > 0) {
const stdoutResult = checkTextRules(outputResult.stdout, t.expect.stdout, "stdout");
const stdoutResult = checkContentRules(outputResult.stdout, t.expect.stdout, { path: "stdout", phase: "stdout" });
if (!stdoutResult.matched) {
return {
durationMs,
@@ -145,7 +149,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
}
if (t.expect?.stderr && t.expect.stderr.length > 0) {
const stderrResult = checkTextRules(outputResult.stderr, t.expect.stderr, "stderr");
const stderrResult = checkContentRules(outputResult.stderr, t.expect.stderr, { path: "stderr", phase: "stderr" });
if (!stderrResult.matched) {
return {
durationMs,

View File

@@ -2,7 +2,12 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import { createTextRulesSchema, sizeSchema, stringMapSchema } from "../../schema/fragments";
import {
createContentRulesSchema,
createValueMatcherSchema,
sizeSchema,
stringMapSchema,
} from "../../schema/fragments";
export const commandCheckerSchemas: CheckerSchemas = {
config: Type.Object(
@@ -24,10 +29,10 @@ export const commandCheckerSchemas: CheckerSchemas = {
),
expect: Type.Object(
{
durationMs: Type.Optional(createValueMatcherSchema()),
exitCode: Type.Optional(Type.Array(Type.Integer())),
maxDurationMs: Type.Optional(Type.Number({ minimum: 0 })),
stderr: Type.Optional(createTextRulesSchema()),
stdout: Type.Optional(createTextRulesSchema()),
stderr: Type.Optional(createContentRulesSchema()),
stdout: Type.Optional(createContentRulesSchema()),
},
{ additionalProperties: false },
),

View File

@@ -1,19 +0,0 @@
import type { ExpectResult } from "../../expect/types";
import type { TextRule } from "./types";
import { mismatchFailure } from "../../expect/failure";
import { applyOperator } from "../../expect/operator";
export function checkTextRules(text: string, rules: TextRule[], phase: string): ExpectResult {
for (let i = 0; i < rules.length; i++) {
const rule = rules[i]!;
const path = `${phase}[${i}]`;
if (!applyOperator(text, rule)) {
return {
failure: mismatchFailure(phase, path, rule, text, `${phase} rule at index ${i} mismatch`),
matched: false,
};
}
}
return { failure: null, matched: true };
}

View File

@@ -1,4 +1,5 @@
import type { ExpectOperator, ResolvedTargetBase } from "../../types";
import type { ContentRules, ValueMatcher } from "../../expect/types";
import type { ResolvedTargetBase } from "../../types";
export interface CommandDefaultsConfig {
cwd?: string;
@@ -6,10 +7,10 @@ export interface CommandDefaultsConfig {
}
export interface CommandExpectConfig {
durationMs?: ValueMatcher;
exitCode?: number[];
maxDurationMs?: number;
stderr?: TextRule[];
stdout?: TextRule[];
stderr?: ContentRules;
stdout?: ContentRules;
}
export interface CommandTargetConfig {
@@ -37,5 +38,3 @@ export interface ResolvedCommandTarget extends ResolvedTargetBase {
timeoutMs: number;
type: "cmd";
}
export type TextRule = ExpectOperator;

View File

@@ -1,10 +1,9 @@
import { isNumber, isPlainObject, isString } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types";
import { validateOperatorObject } from "../../expect/validate-operator";
import { validateContentRules, validateValueMatcher } from "../../expect/validate-matcher";
import { issue, joinPath } from "../../schema/issues";
import { parseSize } from "../../utils";
@@ -32,10 +31,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 isSizeInput(value: unknown): value is number | string {
return isNumber(value) || isString(value);
}
@@ -47,13 +42,13 @@ function validateCommandExpect(target: Record<string, unknown>, path: string): C
const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect");
if (expect["stdout"] !== undefined) {
issues.push(...validateTextRules(expect["stdout"], joinPath(expectPath, "stdout"), targetName));
issues.push(...validateContentRules(expect["stdout"], joinPath(expectPath, "stdout"), targetName));
}
if (expect["stderr"] !== undefined) {
issues.push(...validateTextRules(expect["stderr"], joinPath(expectPath, "stderr"), targetName));
issues.push(...validateContentRules(expect["stderr"], joinPath(expectPath, "stderr"), targetName));
}
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));
}
return issues;
}
@@ -87,8 +82,3 @@ function validateSizeValue(value: number | string, path: string, targetName?: st
return [issue("invalid-size", path, error instanceof Error ? error.message : "size 格式不合法", targetName)];
}
}
function validateTextRules(rules: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isArray(rules)) return [issue("invalid-type", path, "必须为数组", targetName)];
return rules.flatMap((rule, index) => validateOperatorObject(rule, `${path}[${index}]`, targetName));
}

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

View File

@@ -1,212 +0,0 @@
import { DOMParser } from "@xmldom/xmldom";
import * as cheerio from "cheerio";
import { isArray } from "es-toolkit/compat";
import * as xpath from "xpath";
import type { ExpectResult } from "../../expect/types";
import type { BodyRule, CssRule, JsonRule, XpathRule } from "./types";
import { errorFailure, mismatchFailure } from "../../expect/failure";
import { applyOperator, evaluateJsonPath } from "../../expect/operator";
type ParsedJsonResult = { error: string; ok: false } | { ok: true; value: unknown };
export function checkBodyExpect(body: string, rules?: BodyRule[]): ExpectResult {
if (!rules || rules.length === 0) return { failure: null, matched: true };
let parsedJson: ParsedJsonResult | undefined;
for (let i = 0; i < rules.length; i++) {
const rule = rules[i]!;
if ("json" in rule && parsedJson === undefined) {
parsedJson = parseJsonBody(body);
}
const result = checkSingleBodyRule(body, rule, i, parsedJson);
if (!result.matched) return result;
}
return { failure: null, matched: true };
}
function checkCssRule(body: string, rule: CssRule, rulePath: string): ExpectResult {
const { attr, selector, ...operators } = rule;
const fullPath = `${rulePath}.css(${selector}${attr ? `@${attr}` : ""})`;
let $: cheerio.CheerioAPI;
try {
$ = cheerio.load(body);
} catch {
return {
failure: errorFailure("body", fullPath, "failed to parse HTML"),
matched: false,
};
}
const el = $(selector);
if (operators.exists === false) {
if (el.length > 0) {
return {
failure: mismatchFailure("body", fullPath, false, true, `selector ${selector} exists`),
matched: false,
};
}
return { failure: null, matched: true };
}
if (el.length === 0) {
const expected = operators.exists === true ? true : "element found";
const actual = operators.exists === true ? false : "no match";
return {
failure: mismatchFailure("body", fullPath, expected, actual, `selector ${selector} not found`),
matched: false,
};
}
if (operators.exists === true) return { failure: null, matched: true };
const actual = attr ? el.attr(attr) : el.text();
const opKeys = Object.keys(operators);
if (opKeys.length === 0) {
if (actual === undefined) {
return {
failure: mismatchFailure("body", fullPath, "attr present", undefined, `attribute ${attr} not found`),
matched: false,
};
}
return { failure: null, matched: true };
}
const matched = applyOperator(actual ?? "", operators);
if (!matched) {
return {
failure: mismatchFailure("body", fullPath, operators, actual, `css selector ${selector} mismatch`),
matched: false,
};
}
return { failure: null, matched: true };
}
function checkJsonRule(body: string, rule: JsonRule, rulePath: string, parsedJson?: ParsedJsonResult): ExpectResult {
const { path, ...operators } = rule;
const fullPath = `${rulePath}.json(${path})`;
const jsonResult = parsedJson ?? parseJsonBody(body);
if (!jsonResult.ok) {
return {
failure: errorFailure("body", fullPath, jsonResult.error),
matched: false,
};
}
const actual = evaluateJsonPath(jsonResult.value, path);
const opKeys = Object.keys(operators);
if (opKeys.length === 0) {
if (actual === undefined) {
return {
failure: mismatchFailure("body", fullPath, "defined", actual, `path ${path} is undefined`),
matched: false,
};
}
return { failure: null, matched: true };
}
const matched = applyOperator(actual, operators);
if (!matched) {
return {
failure: mismatchFailure("body", fullPath, operators, actual, `json path ${path} mismatch`),
matched: false,
};
}
return { failure: null, matched: true };
}
function checkSingleBodyRule(body: string, rule: BodyRule, index: number, parsedJson?: ParsedJsonResult): ExpectResult {
const rulePath = `body[${index}]`;
if ("contains" in rule) {
const matched = body.includes(rule.contains);
if (!matched) {
return {
failure: mismatchFailure("body", rulePath, rule.contains, body, `body does not contain "${rule.contains}"`),
matched: false,
};
}
return { failure: null, matched: true };
}
if ("regex" in rule) {
const matched = new RegExp(rule.regex).test(body);
if (!matched) {
return {
failure: mismatchFailure("body", rulePath, `/${rule.regex}/`, body, `body does not match /${rule.regex}/`),
matched: false,
};
}
return { failure: null, matched: true };
}
if ("json" in rule) {
return checkJsonRule(body, rule.json, rulePath, parsedJson);
}
if ("css" in rule) {
return checkCssRule(body, rule.css, rulePath);
}
if ("xpath" in rule) {
return checkXpathRule(body, rule.xpath, rulePath);
}
return { failure: null, matched: true };
}
function checkXpathRule(body: string, rule: XpathRule, rulePath: string): ExpectResult {
const { path, ...operators } = rule;
const fullPath = `${rulePath}.xpath(${path})`;
let doc: ReturnType<DOMParser["parseFromString"]>;
try {
doc = new DOMParser().parseFromString(body, "text/xml");
} catch {
return {
failure: errorFailure("body", fullPath, "failed to parse XML/HTML"),
matched: false,
};
}
const nodes = xpath.select(path, doc as unknown as Node);
if (!nodes || !isArray(nodes) || nodes.length === 0) {
return {
failure: mismatchFailure("body", fullPath, "node found", "no match", `xpath ${path} not found`),
matched: false,
};
}
const node = nodes[0]!;
const actual = node.nodeValue ?? (node as unknown as Element).textContent ?? "";
const opKeys = Object.keys(operators);
if (opKeys.length === 0) {
return { failure: null, matched: true };
}
const matched = applyOperator(actual, operators);
if (!matched) {
return {
failure: mismatchFailure("body", fullPath, operators, actual, `xpath ${path} mismatch`),
matched: false,
};
}
return { failure: null, matched: true };
}
function parseJsonBody(body: string): ParsedJsonResult {
try {
return { ok: true, value: JSON.parse(body) as unknown };
} catch {
return { error: "body is not valid JSON", ok: false };
}
}

View File

@@ -5,10 +5,10 @@ import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { HttpExpectConfig, HttpTargetConfig, ResolvedHttpTarget } from "./types";
import { checkDuration } from "../../expect/duration";
import { checkContentRules } from "../../expect/content";
import { errorFailure, mismatchFailure } from "../../expect/failure";
import { checkValueMatcher } from "../../expect/matcher";
import { parseSize } from "../../utils";
import { checkBodyExpect } from "./body";
import { checkHeaders, checkStatus } from "./expect";
import { httpCheckerSchemas } from "./schema";
import { validateHttpConfig } from "./validate";
@@ -54,7 +54,7 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
const hasBodyRules = !!(expect?.body && expect.body.length > 0);
const earlyTimeout = hasBodyRules ? checkEarlyTimeout(start, expect?.maxDurationMs) : null;
const earlyTimeout = hasBodyRules ? checkEarlyTimeout(start, expect?.durationMs) : null;
if (earlyTimeout) {
return makeResult(t, timestamp, earlyTimeout.elapsed, earlyTimeout.failure, statusCode);
}
@@ -70,14 +70,18 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
return makeResult(t, timestamp, performance.now() - start, decodeResult.failure, statusCode);
}
const bodyResult = checkBodyExpect(decodeResult.text, expect.body);
const bodyResult = checkContentRules(decodeResult.text, expect.body, { path: "body", phase: "body" });
if (!bodyResult.matched) {
return makeResult(t, timestamp, performance.now() - start, bodyResult.failure, statusCode);
}
}
const durationMs = Math.round(performance.now() - start);
const durationResult = checkDuration(durationMs, expect?.maxDurationMs);
const durationResult = checkValueMatcher(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
});
if (!durationResult.matched) {
return makeResult(t, timestamp, durationMs, durationResult.failure, statusCode);
}
@@ -190,23 +194,29 @@ function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: strin
function checkEarlyTimeout(
start: number,
maxDurationMs: number | undefined,
durationMatcher: HttpExpectConfig["durationMs"] | undefined,
): null | { elapsed: number; failure: CheckResult["failure"] } {
if (maxDurationMs === undefined) return null;
if (durationMatcher === undefined) return null;
const limit = Math.min(
durationMatcher.lte ?? Number.POSITIVE_INFINITY,
durationMatcher.lt ?? Number.POSITIVE_INFINITY,
);
if (!Number.isFinite(limit)) return null;
const elapsed = performance.now() - start;
if (elapsed <= maxDurationMs) return null;
if (durationMatcher.lt !== undefined ? elapsed < limit : elapsed <= limit) return null;
const durationMs = Math.round(elapsed);
const durationResult = checkValueMatcher(durationMs, durationMatcher, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
});
return {
elapsed,
failure: mismatchFailure(
"duration",
"duration",
`<=${maxDurationMs}ms`,
durationMs,
`duration ${durationMs}ms > ${maxDurationMs}ms`,
),
failure:
durationResult.failure ??
mismatchFailure("duration", "durationMs", durationMatcher, durationMs, "durationMs mismatch"),
};
}

View File

@@ -1,48 +1,16 @@
import { isNumber, isString } from "es-toolkit";
import { isNumber } from "es-toolkit";
import type { ExpectResult } from "../../expect/types";
import type { HeaderExpect } from "./types";
import type { ExpectResult, KeyValueExpect } from "../../expect/types";
import { mismatchFailure } from "../../expect/failure";
import { applyOperator } from "../../expect/operator";
import { checkKeyValueExpect } from "../../expect/key-value";
export function checkHeaders(
headers: Record<string, string>,
headerExpects?: Record<string, HeaderExpect>,
): ExpectResult {
if (!headerExpects) return { failure: null, matched: true };
for (const [key, expected] of Object.entries(headerExpects)) {
const actualValue = headers[key.toLowerCase()];
const path = `headers.${key}`;
if (isString(expected)) {
if (actualValue !== expected) {
return {
failure: mismatchFailure("headers", path, expected, actualValue, `header ${key} mismatch`),
matched: false,
};
}
} else {
if (actualValue === undefined) {
if (expected.exists !== false) {
return {
failure: mismatchFailure("headers", path, "defined", undefined, `header ${key} not found`),
matched: false,
};
}
continue;
}
if (!applyOperator(actualValue, expected)) {
return {
failure: mismatchFailure("headers", path, expected, actualValue, `header ${key} mismatch`),
matched: false,
};
}
}
}
return { failure: null, matched: true };
export function checkHeaders(headers: Record<string, string>, headerExpects?: KeyValueExpect): ExpectResult {
return checkKeyValueExpect(headers, headerExpects, {
normalizeKey: (key) => key.toLowerCase(),
path: "headers",
phase: "headers",
});
}
export function checkStatus(statusCode: number, allowed: Array<number | string>): ExpectResult {

View File

@@ -3,8 +3,9 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import {
createBodyRulesSchema,
createHeaderExpectSchema,
createContentRulesSchema,
createKeyValueExpectSchema,
createValueMatcherSchema,
httpMethodSchema,
sizeSchema,
statusCodePatternSchema,
@@ -33,9 +34,9 @@ export const httpCheckerSchemas: CheckerSchemas = {
),
expect: Type.Object(
{
body: Type.Optional(createBodyRulesSchema()),
headers: Type.Optional(createHeaderExpectSchema()),
maxDurationMs: Type.Optional(Type.Number({ minimum: 0 })),
body: Type.Optional(createContentRulesSchema()),
durationMs: Type.Optional(createValueMatcherSchema()),
headers: Type.Optional(createKeyValueExpectSchema()),
status: Type.Optional(Type.Array(statusCodePatternSchema)),
},
{ additionalProperties: false },

View File

@@ -1,15 +1,5 @@
import type { ExpectOperator, ResolvedTargetBase } from "../../types";
export type BodyRule =
| { contains: string }
| { css: CssRule }
| { json: JsonRule }
| { regex: string }
| { xpath: XpathRule };
export type CssRule = ExpectOperator & { attr?: string; selector: string };
export type HeaderExpect = ExpectOperator | string;
import type { ContentRules, KeyValueExpect, ValueMatcher } from "../../expect/types";
import type { ResolvedTargetBase } from "../../types";
export interface HttpDefaultsConfig {
headers?: Record<string, string>;
@@ -18,9 +8,9 @@ export interface HttpDefaultsConfig {
}
export interface HttpExpectConfig {
body?: BodyRule[];
headers?: Record<string, HeaderExpect>;
maxDurationMs?: number;
body?: ContentRules;
durationMs?: ValueMatcher;
headers?: KeyValueExpect;
status?: Array<number | string>;
}
@@ -34,8 +24,6 @@ export interface HttpTargetConfig {
url: string;
}
export type JsonRule = ExpectOperator & { path: string };
export interface ResolvedHttpConfig {
body?: string;
headers: Record<string, string>;
@@ -55,5 +43,3 @@ export interface ResolvedHttpTarget extends ResolvedTargetBase {
timeoutMs: number;
type: "http";
}
export type XpathRule = ExpectOperator & { path: string };

View File

@@ -1,24 +1,19 @@
import { DOMParser } from "@xmldom/xmldom";
import { isNumber, isString } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import * as xpath from "xpath";
import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types";
import { isUnsafeRegex } from "../../expect/redos";
import { isPlainRecord, validateOperatorObject } from "../../expect/validate-operator";
import { BodyRuleTypeKeys, OperatorKeys } from "../../schema/fragments";
import {
isPlainRecord,
validateContentRules,
validateKeyValueExpect,
validateValueMatcher,
} from "../../expect/validate-matcher";
import { issue, joinPath } from "../../schema/issues";
import { parseSize } from "../../utils";
const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
const OPERATOR_KEY_SET = new Set<string>(OperatorKeys);
export function validateBodyRules(body: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isArray(body)) return [issue("invalid-type", path, "必须为数组", targetName)];
return body.flatMap((rule, index) => validateSingleBodyRule(rule, `${path}[${index}]`, targetName));
}
export function validateHttpConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
@@ -57,55 +52,15 @@ export function validateJsonPath(path: string, rulePath: string, targetName?: st
return issues;
}
function collectOperatorObject(
object: Record<string, unknown>,
allowedKeys: Set<string>,
path: string,
targetName?: string,
): { issues: ConfigValidationIssue[]; operators: Record<string, unknown> } {
const issues: ConfigValidationIssue[] = [];
const operators: Record<string, unknown> = {};
for (const [key, value] of Object.entries(object)) {
if (allowedKeys.has(key)) continue;
if (OPERATOR_KEY_SET.has(key)) {
operators[key] = value;
} else {
issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName));
}
}
return { issues, operators };
}
function getTargetName(target: Record<string, unknown>): string | undefined {
if (isString(target["name"])) return target["name"];
return isString(target["id"]) ? target["id"] : undefined;
}
function isNonNegativeFiniteNumber(value: unknown): boolean {
return isNumber(value) && Number.isFinite(value) && value >= 0;
}
function isSizeInput(value: unknown): value is number | string {
return isNumber(value) || isString(value);
}
function validateCssRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (!isString(rule["selector"]) || rule["selector"].trim() === "") {
issues.push(issue("invalid-type", joinPath(path, "selector"), "必须为非空字符串", targetName));
}
if ("attr" in rule && !isString(rule["attr"])) {
issues.push(issue("invalid-type", joinPath(path, "attr"), "必须为字符串", targetName));
}
const result = collectOperatorObject(rule, new Set(["attr", "selector"]), path, targetName);
issues.push(
...result.issues,
...validateOperatorObject(result.operators, path, targetName, { requireAtLeastOne: false }),
);
return issues;
}
function validateHttpExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const targetName = getTargetName(target);
const expect = target["expect"];
@@ -114,22 +69,19 @@ function validateHttpExpect(target: Record<string, unknown>, path: string): Conf
const expectPath = joinPath(path, "expect");
if (isPlainRecord(expect["headers"])) {
for (const [key, value] of Object.entries(expect["headers"])) {
if (isString(value)) continue;
issues.push(...validateOperatorObject(value, joinPath(joinPath(expectPath, "headers"), key), targetName));
}
issues.push(...validateKeyValueExpect(expect["headers"], joinPath(expectPath, "headers"), targetName));
}
if (expect["body"] !== undefined) {
issues.push(...validateBodyRules(expect["body"], joinPath(expectPath, "body"), targetName));
issues.push(...validateContentRules(expect["body"], joinPath(expectPath, "body"), targetName));
}
if (isArray(expect["status"])) {
issues.push(...validateStatusValues(expect["status"], joinPath(expectPath, "status"), targetName));
}
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));
}
return issues;
@@ -172,61 +124,6 @@ function validateHttpTarget(target: Record<string, unknown>, path: string): Conf
return issues;
}
function validateJsonRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (!isString(rule["path"])) {
issues.push(issue("invalid-type", joinPath(path, "path"), "必须为字符串", targetName));
} else {
issues.push(...validateJsonPath(rule["path"], path, targetName));
}
const result = collectOperatorObject(rule, new Set(["path"]), path, targetName);
issues.push(
...result.issues,
...validateOperatorObject(result.operators, path, targetName, { requireAtLeastOne: false }),
);
return issues;
}
function validateRegexRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isString(rule)) return [issue("invalid-type", path, "必须为字符串", targetName)];
try {
new RegExp(rule);
} catch {
return [issue("invalid-regex", path, "正则不合法", targetName)];
}
return isUnsafeRegex(rule) ? [issue("unsafe-regex", path, "正则存在 ReDoS 风险", targetName)] : [];
}
function validateSingleBodyRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const found = BodyRuleTypeKeys.filter((type) => type in rule);
if (found.length === 0) return [issue("missing-body-rule", path, "缺少支持的规则类型", targetName)];
if (found.length > 1) return [issue("multiple-body-rules", path, "只能配置一种规则类型", targetName)];
const ruleType = found[0]!;
const issues: ConfigValidationIssue[] = [];
for (const key of Object.keys(rule)) {
if (key !== ruleType) issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName));
}
if (issues.length > 0) return issues;
switch (ruleType) {
case "contains":
return isString(rule["contains"])
? []
: [issue("invalid-type", joinPath(path, "contains"), "必须为字符串", targetName)];
case "css":
return validateCssRule(rule["css"], joinPath(path, "css"), targetName);
case "json":
return validateJsonRule(rule["json"], joinPath(path, "json"), targetName);
case "regex":
return validateRegexRule(rule["regex"], joinPath(path, "regex"), targetName);
case "xpath":
return validateXpathRule(rule["xpath"], joinPath(path, "xpath"), targetName);
}
}
function validateSizeValue(value: number | string, path: string, targetName?: string): ConfigValidationIssue[] {
try {
parseSize(value);
@@ -257,24 +154,3 @@ function validateStatusValues(values: unknown[], path: string, targetName?: stri
}
return issues;
}
function validateXpathRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (!isString(rule["path"]) || rule["path"].trim() === "") {
issues.push(issue("invalid-type", joinPath(path, "path"), "必须为非空字符串", targetName));
} else {
try {
const doc = new DOMParser().parseFromString("<x/>", "text/xml");
xpath.select(rule["path"], doc as unknown as Node);
} catch {
issues.push(issue("invalid-xpath", joinPath(path, "path"), "xpath 不合法", targetName));
}
}
const result = collectOperatorObject(rule, new Set(["path"]), path, targetName);
issues.push(
...result.issues,
...validateOperatorObject(result.operators, path, targetName, { requireAtLeastOne: false }),
);
return issues;
}

View File

@@ -4,8 +4,8 @@ import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { PingExpectConfig, PingStats, PingTargetConfig, ResolvedPingTarget } from "./types";
import { checkDuration } from "../../expect/duration";
import { errorFailure } from "../../expect/failure";
import { checkValueMatcher } from "../../expect/matcher";
import { buildPingCommand } from "./command";
import { checkAlive, checkAvgLatency, checkMaxLatency, checkPacketLoss } from "./expect";
import { parsePingOutput } from "./parse";
@@ -140,13 +140,17 @@ function buildStatusDetail(stats: PingStats): string {
function checkStats(stats: PingStats, expect: PingExpectConfig | undefined, durationMs: number) {
const aliveResult = checkAlive(stats.alive, expect?.alive ?? true);
if (!aliveResult.matched) return aliveResult;
const packetLossResult = checkPacketLoss(stats.packetLoss, expect?.maxPacketLoss);
const packetLossResult = checkPacketLoss(stats.packetLoss, expect?.packetLossPercent);
if (!packetLossResult.matched) return packetLossResult;
const avgLatencyResult = checkAvgLatency(stats.avgLatencyMs, expect?.maxAvgLatencyMs);
const avgLatencyResult = checkAvgLatency(stats.avgLatencyMs, expect?.avgLatencyMs);
if (!avgLatencyResult.matched) return avgLatencyResult;
const maxLatencyResult = checkMaxLatency(stats.maxLatencyMs, expect?.maxMaxLatencyMs);
const maxLatencyResult = checkMaxLatency(stats.maxLatencyMs, expect?.maxLatencyMs);
if (!maxLatencyResult.matched) return maxLatencyResult;
return checkDuration(durationMs, expect?.maxDurationMs);
return checkValueMatcher(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
});
}
function formatNumber(value: number): string {

View File

@@ -1,6 +1,7 @@
import type { ExpectResult } from "../../expect/types";
import type { ExpectResult, ValueMatcher } from "../../expect/types";
import { mismatchFailure } from "../../expect/failure";
import { checkValueMatcher } from "../../expect/matcher";
export function checkAlive(actual: boolean, expected: boolean): ExpectResult {
if (actual === expected) return { failure: null, matched: true };
@@ -16,29 +17,26 @@ export function checkAlive(actual: boolean, expected: boolean): ExpectResult {
};
}
export function checkAvgLatency(actual: null | number, max: number | undefined): ExpectResult {
if (max === undefined) return { failure: null, matched: true };
if (actual !== null && actual <= max) return { failure: null, matched: true };
return {
failure: mismatchFailure("avgLatency", "avgLatencyMs", `<=${max}ms`, actual, `平均延迟超过 ${max}ms`),
matched: false,
};
export function checkAvgLatency(actual: null | number, matcher: undefined | ValueMatcher): ExpectResult {
return checkValueMatcher(actual, matcher, {
message: "平均延迟不满足条件",
path: "avgLatencyMs",
phase: "avgLatency",
});
}
export function checkMaxLatency(actual: null | number, max: number | undefined): ExpectResult {
if (max === undefined) return { failure: null, matched: true };
if (actual !== null && actual <= max) return { failure: null, matched: true };
return {
failure: mismatchFailure("maxLatency", "maxLatencyMs", `<=${max}ms`, actual, `最大延迟超过 ${max}ms`),
matched: false,
};
export function checkMaxLatency(actual: null | number, matcher: undefined | ValueMatcher): ExpectResult {
return checkValueMatcher(actual, matcher, {
message: "最大延迟不满足条件",
path: "maxLatencyMs",
phase: "maxLatency",
});
}
export function checkPacketLoss(actual: number, max: number | undefined): ExpectResult {
if (max === undefined) return { failure: null, matched: true };
if (actual <= max) return { failure: null, matched: true };
return {
failure: mismatchFailure("packetLoss", "packetLoss", `<=${max}%`, actual, `丢包率 ${actual}% > ${max}%`),
matched: false,
};
export function checkPacketLoss(actual: number, matcher: undefined | ValueMatcher): ExpectResult {
return checkValueMatcher(actual, matcher, {
message: "丢包率不满足条件",
path: "packetLossPercent",
phase: "packetLoss",
});
}

View File

@@ -2,6 +2,8 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import { createValueMatcherSchema } from "../../schema/fragments";
export const icmpCheckerSchemas: CheckerSchemas = {
config: Type.Object(
{
@@ -15,10 +17,10 @@ export const icmpCheckerSchemas: CheckerSchemas = {
expect: Type.Object(
{
alive: Type.Optional(Type.Boolean()),
maxAvgLatencyMs: Type.Optional(Type.Number({ minimum: 0 })),
maxDurationMs: Type.Optional(Type.Number({ minimum: 0 })),
maxMaxLatencyMs: Type.Optional(Type.Number({ minimum: 0 })),
maxPacketLoss: Type.Optional(Type.Number({ maximum: 100, minimum: 0 })),
avgLatencyMs: Type.Optional(createValueMatcherSchema()),
durationMs: Type.Optional(createValueMatcherSchema()),
maxLatencyMs: Type.Optional(createValueMatcherSchema()),
packetLossPercent: Type.Optional(createValueMatcherSchema()),
},
{ additionalProperties: false },
),

View File

@@ -1,11 +1,12 @@
import type { ValueMatcher } from "../../expect/types";
import type { ResolvedTargetBase } from "../../types";
export interface PingExpectConfig {
alive?: boolean;
maxAvgLatencyMs?: number;
maxDurationMs?: number;
maxMaxLatencyMs?: number;
maxPacketLoss?: number;
avgLatencyMs?: ValueMatcher;
durationMs?: ValueMatcher;
maxLatencyMs?: ValueMatcher;
packetLossPercent?: ValueMatcher;
}
export interface PingStats {

View File

@@ -3,6 +3,7 @@ import { isNumber, isPlainObject, isString } from "es-toolkit";
import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types";
import { validateValueMatcher } from "../../expect/validate-matcher";
import { issue, joinPath } from "../../schema/issues";
export function validatePingConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
@@ -37,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 validatePingExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const rawExpect = target["expect"];
if (rawExpect === undefined || rawExpect === null || !isPlainObject(rawExpect)) return [];
@@ -52,19 +49,13 @@ function validatePingExpect(target: Record<string, unknown>, path: string): Conf
if (expect["alive"] !== undefined && typeof expect["alive"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(expectPath, "alive"), "必须为布尔值", targetName));
}
if (expect["maxPacketLoss"] !== undefined) {
const value = expect["maxPacketLoss"];
if (!isNumber(value) || !Number.isFinite(value) || value < 0 || value > 100) {
issues.push(issue("invalid-value", joinPath(expectPath, "maxPacketLoss"), "必须为 0-100 的数字", targetName));
}
}
for (const key of ["maxAvgLatencyMs", "maxMaxLatencyMs", "maxDurationMs"]) {
if (expect[key] !== undefined && !isNonNegativeFiniteNumber(expect[key])) {
issues.push(issue("invalid-type", joinPath(expectPath, key), "必须为非负有限数字", targetName));
for (const key of ["packetLossPercent", "avgLatencyMs", "maxLatencyMs", "durationMs"]) {
if (expect[key] !== undefined) {
issues.push(...validateValueMatcher(expect[key], joinPath(expectPath, key), targetName));
}
}
const allowedKeys = new Set(["alive", "maxAvgLatencyMs", "maxDurationMs", "maxMaxLatencyMs", "maxPacketLoss"]);
const allowedKeys = new Set(["alive", "avgLatencyMs", "durationMs", "maxLatencyMs", "packetLossPercent"]);
for (const key of Object.keys(expect)) {
if (!allowedKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));

View File

@@ -7,8 +7,8 @@ import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { LlmCheckObservation, LlmExpectConfig, LlmTargetConfig, ResolvedLlmTarget } from "./types";
import { checkDuration } from "../../expect/duration";
import { errorFailure } from "../../expect/failure";
import { checkValueMatcher } from "../../expect/matcher";
import { runExpects } from "./expect";
import {
buildObservationFromApiCallError,
@@ -54,7 +54,11 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
};
}
const durationResult = checkDuration(durationMs, expect?.maxDurationMs);
const durationResult = checkValueMatcher(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
});
const expectResult = runExpects(observation, expect);
const failure = expectResult.failure ?? durationResult.failure;
@@ -209,7 +213,11 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
);
const durationMs = Math.round(performance.now() - start);
const durationResult = checkDuration(durationMs, expect?.maxDurationMs);
const durationResult = checkValueMatcher(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
});
const expectResult = runExpects(observation, expect);
const failure = expectResult.failure ?? durationResult.failure;
@@ -251,7 +259,11 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
);
const durationMs = Math.round(performance.now() - start);
const durationResult = checkDuration(durationMs, expect?.maxDurationMs);
const durationResult = checkValueMatcher(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
});
const expectResult = runExpects(observation, expect);
const failure = expectResult.failure ?? durationResult.failure;

View File

@@ -1,11 +1,10 @@
import type { ExpectResult } from "../../expect/types";
import type { LlmCheckObservation, LlmExpectConfig } from "./types";
import type { LlmCheckObservation, LlmExpectConfig, LlmUsageExpect } from "./types";
import { checkDuration } from "../../expect/duration";
import { checkContentRules } from "../../expect/content";
import { mismatchFailure } from "../../expect/failure";
import { applyOperator } from "../../expect/operator";
import { checkValueMatcher } from "../../expect/matcher";
import { checkHeaders, checkStatus } from "../http/expect";
import { checkOutputRules } from "./output";
export function checkStreamExpect(observation: LlmCheckObservation, expect: LlmExpectConfig): ExpectResult {
if (!observation.stream || !expect.stream) return { failure: null, matched: true };
@@ -25,18 +24,11 @@ export function checkStreamExpect(observation: LlmCheckObservation, expect: LlmE
}
if (expect.stream.firstTokenMs && observation.stream.firstTokenMs !== null) {
if (!applyOperator(observation.stream.firstTokenMs, expect.stream.firstTokenMs)) {
return {
failure: mismatchFailure(
"stream",
"stream.firstTokenMs",
expect.stream.firstTokenMs,
observation.stream.firstTokenMs,
"stream.firstTokenMs mismatch",
),
matched: false,
};
}
return checkValueMatcher(observation.stream.firstTokenMs, expect.stream.firstTokenMs, {
message: "stream.firstTokenMs mismatch",
path: "stream.firstTokenMs",
phase: "stream",
});
} else if (expect.stream.firstTokenMs && observation.stream.firstTokenMs === null) {
return {
failure: mismatchFailure(
@@ -75,37 +67,25 @@ export function runExpects(observation: LlmCheckObservation, expect: LlmExpectCo
if (!streamResult.matched) return streamResult;
}
const outputResult = checkOutputRules(observation.outputText, expect.output);
const outputResult = checkContentRules(observation.outputText, expect.output, { path: "output", phase: "output" });
if (!outputResult.matched) return outputResult;
if (expect.finishReason !== undefined) {
if (observation.finishReason !== expect.finishReason) {
return {
failure: mismatchFailure(
"finishReason",
"finishReason",
expect.finishReason,
observation.finishReason,
"finishReason mismatch",
),
matched: false,
};
}
const result = checkValueMatcher(observation.finishReason, expect.finishReason, {
message: "finishReason mismatch",
path: "finishReason",
phase: "finishReason",
});
if (!result.matched) return result;
}
if (expect.rawFinishReason !== undefined) {
if (observation.rawFinishReason !== expect.rawFinishReason) {
return {
failure: mismatchFailure(
"rawFinishReason",
"rawFinishReason",
expect.rawFinishReason,
observation.rawFinishReason,
"rawFinishReason mismatch",
),
matched: false,
};
}
const result = checkValueMatcher(observation.rawFinishReason, expect.rawFinishReason, {
message: "rawFinishReason mismatch",
path: "rawFinishReason",
phase: "rawFinishReason",
});
if (!result.matched) return result;
}
if (expect.usage && observation.usage) {
@@ -118,51 +98,31 @@ export function runExpects(observation: LlmCheckObservation, expect: LlmExpectCo
function checkUsageExpect(
usage: { inputTokens: number; outputTokens: number; totalTokens: number },
expectUsage: { inputTokens?: unknown; outputTokens?: unknown; totalTokens?: unknown },
expectUsage: LlmUsageExpect,
): ExpectResult {
if (expectUsage.inputTokens !== undefined) {
if (!applyOperator(usage.inputTokens, expectUsage.inputTokens as Parameters<typeof applyOperator>[1])) {
return {
failure: mismatchFailure(
"usage",
"usage.inputTokens",
expectUsage.inputTokens,
usage.inputTokens,
"usage.inputTokens mismatch",
),
matched: false,
};
}
const result = checkValueMatcher(usage.inputTokens, expectUsage.inputTokens, {
message: "usage.inputTokens mismatch",
path: "usage.inputTokens",
phase: "usage",
});
if (!result.matched) return result;
}
if (expectUsage.outputTokens !== undefined) {
if (!applyOperator(usage.outputTokens, expectUsage.outputTokens as Parameters<typeof applyOperator>[1])) {
return {
failure: mismatchFailure(
"usage",
"usage.outputTokens",
expectUsage.outputTokens,
usage.outputTokens,
"usage.outputTokens mismatch",
),
matched: false,
};
}
const result = checkValueMatcher(usage.outputTokens, expectUsage.outputTokens, {
message: "usage.outputTokens mismatch",
path: "usage.outputTokens",
phase: "usage",
});
if (!result.matched) return result;
}
if (expectUsage.totalTokens !== undefined) {
if (!applyOperator(usage.totalTokens, expectUsage.totalTokens as Parameters<typeof applyOperator>[1])) {
return {
failure: mismatchFailure(
"usage",
"usage.totalTokens",
expectUsage.totalTokens,
usage.totalTokens,
"usage.totalTokens mismatch",
),
matched: false,
};
}
const result = checkValueMatcher(usage.totalTokens, expectUsage.totalTokens, {
message: "usage.totalTokens mismatch",
path: "usage.totalTokens",
phase: "usage",
});
if (!result.matched) return result;
}
return { failure: null, matched: true };
}
export { checkDuration };

View File

@@ -1,83 +0,0 @@
import type { ExpectResult } from "../../expect/types";
import type { OutputRule } from "./types";
import { mismatchFailure } from "../../expect/failure";
import { applyOperator, evaluateJsonPath } from "../../expect/operator";
export function checkOutputRules(outputText: null | string, rules: OutputRule[] | undefined): ExpectResult {
if (!rules || rules.length === 0) return { failure: null, matched: true };
for (const rule of rules) {
const result = checkSingleOutputRule(outputText, rule);
if (!result.matched) return result;
}
return { failure: null, matched: true };
}
function checkSingleOutputRule(outputText: null | string, rule: OutputRule): ExpectResult {
if ("equals" in rule) {
if (outputText === null || outputText !== rule.equals) {
return {
failure: mismatchFailure("output", "output", rule.equals, outputText, "output equals mismatch"),
matched: false,
};
}
return { failure: null, matched: true };
}
if ("contains" in rule) {
if (!outputText?.includes(rule.contains)) {
return {
failure: mismatchFailure(
"output",
"output",
`contains: ${rule.contains}`,
outputText,
"output contains mismatch",
),
matched: false,
};
}
return { failure: null, matched: true };
}
if ("regex" in rule) {
if (outputText === null || !new RegExp(rule.regex).test(outputText)) {
return {
failure: mismatchFailure("output", "output", `match: ${rule.regex}`, outputText, "output regex mismatch"),
matched: false,
};
}
return { failure: null, matched: true };
}
if ("json" in rule) {
if (outputText === null) {
return {
failure: mismatchFailure("output", "output", "valid JSON", null, "output is null, cannot parse JSON"),
matched: false,
};
}
let parsed: unknown;
try {
parsed = JSON.parse(outputText);
} catch {
return {
failure: mismatchFailure("output", "output", "valid JSON", outputText, "output is not valid JSON"),
matched: false,
};
}
const value = evaluateJsonPath(parsed, rule.json.path);
if (!applyOperator(value, rule.json)) {
return {
failure: mismatchFailure("output", "output", rule.json, value, "output json mismatch"),
matched: false,
};
}
return { failure: null, matched: true };
}
return { failure: null, matched: true };
}

View File

@@ -3,8 +3,9 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import {
createHeaderExpectSchema,
createPureOperatorSchema,
createContentRulesSchema,
createKeyValueExpectSchema,
createValueMatcherSchema,
statusCodePatternSchema,
stringMapSchema,
} from "../../schema/fragments";
@@ -25,36 +26,6 @@ function createLlmOptionsSchema() {
);
}
function createLlmOutputRulesSchema() {
return Type.Array(
Type.Object(
{
contains: Type.Optional(Type.String()),
equals: Type.Optional(Type.String()),
json: Type.Optional(
Type.Object({ path: Type.String(), ...operatorProperties() }, { additionalProperties: false }),
),
regex: Type.Optional(Type.String()),
},
{ additionalProperties: false },
),
);
}
function operatorProperties() {
return {
contains: Type.Optional(Type.String()),
empty: Type.Optional(Type.Boolean()),
equals: Type.Optional(Type.Number()),
exists: Type.Optional(Type.Boolean()),
gt: Type.Optional(Type.Number()),
gte: Type.Optional(Type.Number()),
lt: Type.Optional(Type.Number()),
lte: Type.Optional(Type.Number()),
match: Type.Optional(Type.String()),
};
}
export const llmCheckerSchemas: CheckerSchemas = {
config: Type.Object(
{
@@ -84,17 +55,17 @@ export const llmCheckerSchemas: CheckerSchemas = {
),
expect: Type.Object(
{
finishReason: Type.Optional(Type.String()),
headers: Type.Optional(createHeaderExpectSchema()),
maxDurationMs: Type.Optional(Type.Number({ minimum: 0 })),
output: Type.Optional(createLlmOutputRulesSchema()),
rawFinishReason: Type.Optional(Type.String()),
durationMs: Type.Optional(createValueMatcherSchema()),
finishReason: Type.Optional(createValueMatcherSchema()),
headers: Type.Optional(createKeyValueExpectSchema()),
output: Type.Optional(createContentRulesSchema()),
rawFinishReason: Type.Optional(createValueMatcherSchema()),
status: Type.Optional(Type.Array(statusCodePatternSchema)),
stream: Type.Optional(
Type.Object(
{
completed: Type.Optional(Type.Boolean()),
firstTokenMs: Type.Optional(createPureOperatorSchema()),
firstTokenMs: Type.Optional(createValueMatcherSchema()),
},
{ additionalProperties: false },
),
@@ -102,9 +73,9 @@ export const llmCheckerSchemas: CheckerSchemas = {
usage: Type.Optional(
Type.Object(
{
inputTokens: Type.Optional(createPureOperatorSchema()),
outputTokens: Type.Optional(createPureOperatorSchema()),
totalTokens: Type.Optional(createPureOperatorSchema()),
inputTokens: Type.Optional(createValueMatcherSchema()),
outputTokens: Type.Optional(createValueMatcherSchema()),
totalTokens: Type.Optional(createValueMatcherSchema()),
},
{ additionalProperties: false },
),

View File

@@ -1,6 +1,7 @@
import type { JSONObject } from "@ai-sdk/provider";
import type { ExpectOperator, ResolvedTargetBase } from "../../types";
import type { ContentRules, KeyValueExpect, ValueMatcher } from "../../expect/types";
import type { ResolvedTargetBase } from "../../types";
export interface LlmCheckObservation {
finishReason: null | string;
@@ -23,11 +24,11 @@ export interface LlmDefaultsConfig {
}
export interface LlmExpectConfig {
finishReason?: string;
headers?: Record<string, ExpectOperator | string>;
maxDurationMs?: number;
output?: OutputRule[];
rawFinishReason?: string;
durationMs?: ValueMatcher;
finishReason?: ValueMatcher;
headers?: KeyValueExpect;
output?: ContentRules;
rawFinishReason?: ValueMatcher;
status?: Array<number | string>;
stream?: LlmStreamExpect;
usage?: LlmUsageExpect;
@@ -56,7 +57,7 @@ export type LlmProvider = "anthropic" | "openai" | "openai-responses";
export interface LlmStreamExpect {
completed?: boolean;
firstTokenMs?: ExpectOperator;
firstTokenMs?: ValueMatcher;
}
export interface LlmStreamObservation {
@@ -79,9 +80,9 @@ export interface LlmTargetConfig {
}
export interface LlmUsageExpect {
inputTokens?: ExpectOperator;
outputTokens?: ExpectOperator;
totalTokens?: ExpectOperator;
inputTokens?: ValueMatcher;
outputTokens?: ValueMatcher;
totalTokens?: ValueMatcher;
}
export interface LlmUsageObservation {
@@ -90,12 +91,6 @@ export interface LlmUsageObservation {
totalTokens: number;
}
export interface OutputJsonRule extends ExpectOperator {
path: string;
}
export type OutputRule = { contains: string } | { equals: string } | { json: OutputJsonRule } | { regex: string };
export interface ResolvedLlmConfig {
authToken?: string;
headers: Record<string, string>;

View File

@@ -1,17 +1,20 @@
import { isBoolean, isNumber, isPlainObject, isString } from "es-toolkit";
import { isBoolean, isNumber, 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 { isPlainRecord, validateOperatorObject } from "../../expect/validate-operator";
import {
isPlainRecord,
validateContentRules,
validateKeyValueExpect,
validateValueMatcher,
} from "../../expect/validate-matcher";
import { issue, joinPath } from "../../schema/issues";
const ALLOWED_PROVIDERS = new Set(["anthropic", "openai", "openai-responses"]);
const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
const ALLOWED_MODES = new Set(["http", "stream"]);
const OUTPUT_RULE_KEYS = ["contains", "equals", "json", "regex"] as const;
const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
const ALLOWED_PROVIDERS = new Set(["anthropic", "openai", "openai-responses"]);
export function validateLlmConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
@@ -37,10 +40,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 validateLlmDefaults(defaults: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
@@ -77,30 +76,23 @@ function validateLlmExpect(
if (isArray(expect["status"])) {
issues.push(...validateStatusValues(expect["status"], joinPath(expectPath, "status"), targetName));
}
if (isPlainRecord(expect["headers"])) {
for (const [key, value] of Object.entries(expect["headers"])) {
if (isString(value)) continue;
issues.push(...validateOperatorObject(value, joinPath(joinPath(expectPath, "headers"), key), targetName));
}
if (expect["headers"] !== undefined) {
issues.push(...validateKeyValueExpect(expect["headers"], joinPath(expectPath, "headers"), targetName));
}
if (expect["output"] !== undefined) {
issues.push(...validateOutputRules(expect["output"], joinPath(expectPath, "output"), targetName));
issues.push(...validateContentRules(expect["output"], joinPath(expectPath, "output"), targetName));
}
if (expect["finishReason"] !== undefined && !isString(expect["finishReason"])) {
issues.push(issue("invalid-type", joinPath(expectPath, "finishReason"), "必须为字符串", targetName));
if (expect["finishReason"] !== undefined) {
issues.push(...validateValueMatcher(expect["finishReason"], joinPath(expectPath, "finishReason"), targetName));
}
if (expect["rawFinishReason"] !== undefined && !isString(expect["rawFinishReason"])) {
issues.push(issue("invalid-type", joinPath(expectPath, "rawFinishReason"), "必须为字符串", targetName));
if (expect["rawFinishReason"] !== undefined) {
issues.push(
...validateValueMatcher(expect["rawFinishReason"], joinPath(expectPath, "rawFinishReason"), targetName),
);
}
if (expect["usage"] !== undefined) {
issues.push(...validateUsageExpect(expect["usage"], joinPath(expectPath, "usage"), targetName));
}
if (expect["stream"] !== undefined) {
if (mode === "http") {
issues.push(
@@ -110,9 +102,22 @@ function validateLlmExpect(
issues.push(...validateStreamExpect(expect["stream"], joinPath(expectPath, "stream"), targetName));
}
}
if (expect["durationMs"] !== undefined) {
issues.push(...validateValueMatcher(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
}
if (expect["maxDurationMs"] !== undefined && !isNonNegativeFiniteNumber(expect["maxDurationMs"])) {
issues.push(issue("invalid-type", joinPath(expectPath, "maxDurationMs"), "必须为非负有限数字", targetName));
const allowedKeys = new Set([
"durationMs",
"finishReason",
"headers",
"output",
"rawFinishReason",
"status",
"stream",
"usage",
]);
for (const key of Object.keys(expect)) {
if (!allowedKeys.has(key)) issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
}
return issues;
@@ -197,38 +202,30 @@ function validateLlmTarget(target: Record<string, unknown>, path: string): Confi
if (!isString(llm["model"]) || llm["model"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "llm"), "model"), "必须为非空字符串", targetName));
}
if (!isString(llm["prompt"]) || llm["prompt"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "llm"), "prompt"), "必须为非空字符串", targetName));
}
if (llm["mode"] !== undefined && !ALLOWED_MODES.has(llm["mode"] as string)) {
issues.push(issue("invalid-type", joinPath(joinPath(path, "llm"), "mode"), "必须为 http 或 stream", targetName));
}
if (llm["headers"] !== undefined) {
issues.push(...validateStringMap(llm["headers"], joinPath(joinPath(path, "llm"), "headers"), targetName));
}
if (llm["ignoreSSL"] !== undefined && !isBoolean(llm["ignoreSSL"])) {
issues.push(issue("invalid-type", joinPath(joinPath(path, "llm"), "ignoreSSL"), "必须为布尔值", targetName));
}
const provider = llm["provider"] as string | undefined;
if (llm["authToken"] !== undefined) {
if (provider !== "anthropic") {
issues.push(
issue(
"invalid-auth",
joinPath(joinPath(path, "llm"), "authToken"),
"authToken 仅支持 anthropic provider",
targetName,
),
);
}
if (llm["authToken"] !== undefined && provider !== "anthropic") {
issues.push(
issue(
"invalid-auth",
joinPath(joinPath(path, "llm"), "authToken"),
"authToken 仅支持 anthropic provider",
targetName,
),
);
}
if (
provider === "anthropic" &&
isString(llm["key"]) &&
@@ -240,11 +237,9 @@ function validateLlmTarget(target: Record<string, unknown>, path: string): Confi
issue("auth-conflict", joinPath(joinPath(path, "llm"), "key"), "key 与 authToken 不能同时配置", targetName),
);
}
if (llm["options"] !== undefined) {
issues.push(...validateLlmOptions(llm["options"], joinPath(joinPath(path, "llm"), "options"), targetName));
}
if (llm["providerOptions"] !== undefined) {
issues.push(
...validateProviderOptions(
@@ -261,76 +256,11 @@ function validateLlmTarget(target: Record<string, unknown>, path: string): Confi
return issues;
}
function validateOutputJsonRule(value: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(value)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (!isString(value["path"]) || !value["path"].startsWith("$.") || value["path"].length <= 2) {
issues.push(issue("invalid-jsonpath", joinPath(path, "path"), '必须为以 "$." 开头的有效 JSONPath', targetName));
}
const operatorKeys = new Set(["path"]);
const operators: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
if (operatorKeys.has(key)) continue;
operators[key] = val;
}
issues.push(...validateOperatorObject(operators, path, targetName, { requireAtLeastOne: false }));
return issues;
}
function validateOutputRegex(value: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isString(value)) return [issue("invalid-type", path, "必须为字符串", targetName)];
try {
new RegExp(value);
} catch {
return [issue("invalid-regex", path, "正则不合法", targetName)];
}
return isUnsafeRegex(value) ? [issue("unsafe-regex", path, "正则存在 ReDoS 风险", targetName)] : [];
}
function validateOutputRules(rules: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isArray(rules)) return [issue("invalid-type", path, "必须为数组", targetName)];
return rules.flatMap((rule, index) => validateSingleOutputRule(rule, `${path}[${index}]`, targetName));
}
function validateProviderOptions(value: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainObject(value)) return [issue("invalid-type", path, "必须为 JSON object", targetName)];
if (!isPlainRecord(value)) return [issue("invalid-type", path, "必须为 JSON object", targetName)];
return [];
}
function validateSingleOutputRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const found = OUTPUT_RULE_KEYS.filter((type) => type in rule);
if (found.length === 0) return [issue("missing-body-rule", path, "缺少支持的规则类型", targetName)];
if (found.length > 1) return [issue("multiple-body-rules", path, "只能配置一种规则类型", targetName)];
const ruleType = found[0]!;
const issues: ConfigValidationIssue[] = [];
for (const key of Object.keys(rule)) {
if (key !== ruleType) issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName));
}
if (issues.length > 0) return issues;
switch (ruleType) {
case "contains":
return isString(rule["contains"])
? []
: [issue("invalid-type", joinPath(path, "contains"), "必须为字符串", targetName)];
case "equals":
return isString(rule["equals"])
? []
: [issue("invalid-type", joinPath(path, "equals"), "必须为字符串", targetName)];
case "json":
return validateOutputJsonRule(rule["json"], joinPath(path, "json"), targetName);
case "regex":
return validateOutputRegex(rule["regex"], joinPath(path, "regex"), targetName);
}
}
function validateStatusValues(values: unknown[], path: string, targetName?: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
for (let i = 0; i < values.length; i++) {
@@ -360,18 +290,22 @@ function validateStreamExpect(stream: unknown, path: string, targetName?: string
if (stream["completed"] !== undefined && !isBoolean(stream["completed"])) {
issues.push(issue("invalid-type", joinPath(path, "completed"), "必须为布尔值", targetName));
}
if (stream["firstTokenMs"] !== undefined) {
issues.push(...validateOperatorObject(stream["firstTokenMs"], joinPath(path, "firstTokenMs"), targetName));
issues.push(...validateValueMatcher(stream["firstTokenMs"], joinPath(path, "firstTokenMs"), targetName));
}
const allowedKeys = new Set(["completed", "firstTokenMs"]);
for (const key of Object.keys(stream)) {
if (!allowedKeys.has(key)) issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName));
}
return issues;
}
function validateStringMap(value: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainObject(value)) return [issue("invalid-type", path, "必须为对象", targetName)];
if (!isPlainRecord(value)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
for (const [key, val] of Object.entries(value)) {
if (!isString(val)) {
issues.push(issue("invalid-type", joinPath(path, key), "必须为字符串", targetName));
}
@@ -383,14 +317,15 @@ function validateUsageExpect(usage: unknown, path: string, targetName?: string):
if (!isPlainRecord(usage)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (usage["inputTokens"] !== undefined) {
issues.push(...validateOperatorObject(usage["inputTokens"], joinPath(path, "inputTokens"), targetName));
for (const key of ["inputTokens", "outputTokens", "totalTokens"]) {
if (usage[key] !== undefined) {
issues.push(...validateValueMatcher(usage[key], joinPath(path, key), targetName));
}
}
if (usage["outputTokens"] !== undefined) {
issues.push(...validateOperatorObject(usage["outputTokens"], joinPath(path, "outputTokens"), targetName));
}
if (usage["totalTokens"] !== undefined) {
issues.push(...validateOperatorObject(usage["totalTokens"], joinPath(path, "totalTokens"), targetName));
const allowedKeys = new Set(["inputTokens", "outputTokens", "totalTokens"]);
for (const key of Object.keys(usage)) {
if (!allowedKeys.has(key)) issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName));
}
return issues;

View File

@@ -4,8 +4,8 @@ import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { ResolvedTcpTarget, TcpExpectConfig, TcpTargetConfig } from "./types";
import { checkDuration } from "../../expect/duration";
import { errorFailure } from "../../expect/failure";
import { checkValueMatcher } from "../../expect/matcher";
import { parseSize } from "../../utils";
import { checkBanner, checkConnected } from "./expect";
import { tcpCheckerSchemas } from "./schema";
@@ -124,7 +124,11 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
}
const durationMs = Math.round(performance.now() - start);
const durationResult = checkDuration(durationMs, expect?.maxDurationMs);
const durationResult = checkValueMatcher(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
});
if (!durationResult.matched) {
return {
durationMs,

View File

@@ -1,18 +1,10 @@
import type { ExpectResult } from "../../expect/types";
import type { ExpectOperator } from "../../types";
import type { ContentRules, ExpectResult } from "../../expect/types";
import { checkContentRules } from "../../expect/content";
import { mismatchFailure } from "../../expect/failure";
import { applyOperator } from "../../expect/operator";
export function checkBanner(banner: string, op: ExpectOperator): ExpectResult {
const matched = applyOperator(banner, op);
if (!matched) {
return {
failure: mismatchFailure("banner", "banner", op, banner, `banner 不满足条件`),
matched: false,
};
}
return { failure: null, matched: true };
export function checkBanner(banner: string, rules: ContentRules): ExpectResult {
return checkContentRules(banner, rules, { path: "banner", phase: "banner" });
}
export function checkConnected(connected: boolean, expected: boolean): ExpectResult {

View File

@@ -2,7 +2,7 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import { createPureOperatorSchema, sizeSchema } from "../../schema/fragments";
import { createContentRulesSchema, createValueMatcherSchema, sizeSchema } from "../../schema/fragments";
export const tcpCheckerSchemas: CheckerSchemas = {
config: Type.Object(
@@ -24,9 +24,9 @@ export const tcpCheckerSchemas: CheckerSchemas = {
),
expect: Type.Object(
{
banner: Type.Optional(createPureOperatorSchema()),
banner: Type.Optional(createContentRulesSchema()),
connected: Type.Optional(Type.Boolean()),
maxDurationMs: Type.Optional(Type.Number({ minimum: 0 })),
durationMs: Type.Optional(createValueMatcherSchema()),
},
{ additionalProperties: false },
),

View File

@@ -1,4 +1,5 @@
import type { ExpectOperator, ResolvedTargetBase } from "../../types";
import type { ContentRules, ValueMatcher } from "../../expect/types";
import type { ResolvedTargetBase } from "../../types";
export interface ResolvedTcpConfig {
bannerReadTimeout: number;
@@ -24,9 +25,9 @@ export interface TcpDefaultsConfig {
}
export interface TcpExpectConfig {
banner?: ExpectOperator;
banner?: ContentRules;
connected?: boolean;
maxDurationMs?: number;
durationMs?: ValueMatcher;
}
export interface TcpTargetConfig {

View File

@@ -3,7 +3,7 @@ import { isNumber, isPlainObject, isString } from "es-toolkit";
import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types";
import { validateOperatorObject } from "../../expect/validate-operator";
import { validateContentRules, validateValueMatcher } from "../../expect/validate-matcher";
import { issue, joinPath } from "../../schema/issues";
export function validateTcpConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
@@ -79,8 +79,8 @@ function validateTcpExpect(
issues.push(issue("invalid-type", joinPath(expectPath, "connected"), "必须为布尔值", targetName));
}
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["banner"] !== undefined) {
@@ -89,11 +89,11 @@ function validateTcpExpect(
issue("invalid-value", joinPath(expectPath, "banner"), "banner 断言需要启用 tcp.readBanner", targetName),
);
} else {
issues.push(...validateOperatorObject(expect["banner"], joinPath(expectPath, "banner"), targetName));
issues.push(...validateContentRules(expect["banner"], joinPath(expectPath, "banner"), targetName));
}
}
const allowedKeys = new Set(["banner", "connected", "maxDurationMs"]);
const allowedKeys = new Set(["banner", "connected", "durationMs"]);
for (const key of Object.keys(expect)) {
if (!allowedKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));

View File

@@ -4,8 +4,8 @@ import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { ResolvedUdpTarget, UdpDefaultsConfig, UdpExpectConfig, UdpTargetConfig } from "./types";
import { checkDuration } from "../../expect/duration";
import { errorFailure } from "../../expect/failure";
import { checkValueMatcher } from "../../expect/matcher";
import { parseSize } from "../../utils";
import { decodePayload, encodeResponse } from "./encoding";
import { checkResponded, checkResponseSize, checkResponseText, checkSourceHost, checkSourcePort } from "./expect";
@@ -83,7 +83,11 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
if (!exchangeResult.responded) {
const durationMs = Math.round(performance.now() - start);
const durationResult = checkDuration(durationMs, expect?.maxDurationMs);
const durationResult = checkValueMatcher(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
});
if (!durationResult.matched) {
return {
durationMs,
@@ -194,7 +198,11 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
}
const durationMs = Math.round(performance.now() - start);
const durationResult = checkDuration(durationMs, expect?.maxDurationMs);
const durationResult = checkValueMatcher(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
});
if (!durationResult.matched) {
return {
durationMs,

View File

@@ -1,8 +1,8 @@
import type { ExpectResult } from "../../expect/types";
import type { ExpectOperator } from "../../types";
import type { ContentRules, ExpectResult, ValueMatcher } from "../../expect/types";
import { checkContentRules } from "../../expect/content";
import { mismatchFailure } from "../../expect/failure";
import { applyOperator } from "../../expect/operator";
import { checkValueMatcher } from "../../expect/matcher";
export function checkResponded(responded: boolean, expected: boolean): ExpectResult {
if (responded === expected) return { failure: null, matched: true };
@@ -18,49 +18,30 @@ export function checkResponded(responded: boolean, expected: boolean): ExpectRes
};
}
export function checkResponseSize(size: number, op: ExpectOperator): ExpectResult {
const matched = applyOperator(size, op);
if (!matched) {
return {
failure: mismatchFailure("responseSize", "responseSize", op, size, "响应大小不满足条件"),
matched: false,
};
}
return { failure: null, matched: true };
export function checkResponseSize(size: number, matcher: ValueMatcher): ExpectResult {
return checkValueMatcher(size, matcher, {
message: "响应大小不满足条件",
path: "responseSize",
phase: "responseSize",
});
}
export function checkResponseText(text: string, rules: ExpectOperator[]): ExpectResult {
for (let i = 0; i < rules.length; i++) {
const rule = rules[i]!;
const path = `response[${i}]`;
if (!applyOperator(text, rule)) {
return {
failure: mismatchFailure("response", path, rule, text, `response rule at index ${i} mismatch`),
matched: false,
};
}
}
return { failure: null, matched: true };
export function checkResponseText(text: string, rules: ContentRules): ExpectResult {
return checkContentRules(text, rules, { path: "response", phase: "response" });
}
export function checkSourceHost(actual: string, op: ExpectOperator): ExpectResult {
const matched = applyOperator(actual, op);
if (!matched) {
return {
failure: mismatchFailure("sourceHost", "sourceHost", op, actual, "响应来源地址不满足条件"),
matched: false,
};
}
return { failure: null, matched: true };
export function checkSourceHost(actual: string, matcher: ValueMatcher): ExpectResult {
return checkValueMatcher(actual, matcher, {
message: "响应来源地址不满足条件",
path: "sourceHost",
phase: "sourceHost",
});
}
export function checkSourcePort(actual: number, op: ExpectOperator): ExpectResult {
const matched = applyOperator(actual, op);
if (!matched) {
return {
failure: mismatchFailure("sourcePort", "sourcePort", op, actual, "响应来源端口不满足条件"),
matched: false,
};
}
return { failure: null, matched: true };
export function checkSourcePort(actual: number, matcher: ValueMatcher): ExpectResult {
return checkValueMatcher(actual, matcher, {
message: "响应来源端口不满足条件",
path: "sourcePort",
phase: "sourcePort",
});
}

View File

@@ -2,7 +2,7 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import { createPureOperatorSchema, createTextRulesSchema, sizeSchema } from "../../schema/fragments";
import { createContentRulesSchema, createValueMatcherSchema, sizeSchema } from "../../schema/fragments";
export const udpCheckerSchemas: CheckerSchemas = {
config: Type.Object(
@@ -26,12 +26,12 @@ export const udpCheckerSchemas: CheckerSchemas = {
),
expect: Type.Object(
{
maxDurationMs: Type.Optional(Type.Number({ minimum: 0 })),
durationMs: Type.Optional(createValueMatcherSchema()),
responded: Type.Optional(Type.Boolean()),
response: Type.Optional(createTextRulesSchema()),
responseSize: Type.Optional(createPureOperatorSchema()),
sourceHost: Type.Optional(createPureOperatorSchema()),
sourcePort: Type.Optional(createPureOperatorSchema()),
response: Type.Optional(createContentRulesSchema()),
responseSize: Type.Optional(createValueMatcherSchema()),
sourceHost: Type.Optional(createValueMatcherSchema()),
sourcePort: Type.Optional(createValueMatcherSchema()),
},
{ additionalProperties: false },
),

View File

@@ -1,4 +1,5 @@
import type { ExpectOperator, ResolvedTargetBase } from "../../types";
import type { ContentRules, ValueMatcher } from "../../expect/types";
import type { ResolvedTargetBase } from "../../types";
export interface ResolvedUdpConfig {
encoding: UdpEncoding;
@@ -28,12 +29,12 @@ export interface UdpDefaultsConfig {
export type UdpEncoding = "base64" | "hex" | "text";
export interface UdpExpectConfig {
maxDurationMs?: number;
durationMs?: ValueMatcher;
responded?: boolean;
response?: ExpectOperator[];
responseSize?: ExpectOperator;
sourceHost?: ExpectOperator;
sourcePort?: ExpectOperator;
response?: ContentRules;
responseSize?: ValueMatcher;
sourceHost?: ValueMatcher;
sourcePort?: ValueMatcher;
}
export interface UdpTargetConfig {

View File

@@ -3,7 +3,7 @@ import { isNumber, isPlainObject, isString } from "es-toolkit";
import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types";
import { validateOperatorObject } from "../../expect/validate-operator";
import { validateContentRules, validateValueMatcher } from "../../expect/validate-matcher";
import { issue, joinPath } from "../../schema/issues";
const VALID_ENCODINGS = new Set(["base64", "hex", "text"]);
@@ -28,10 +28,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 validateEncoding(value: unknown, path: string, targetName: string | undefined): ConfigValidationIssue[] {
if (value === undefined) return [];
if (!isString(value) || !VALID_ENCODINGS.has(value)) {
@@ -48,22 +44,6 @@ function validateSize(value: unknown, path: string, targetName: string | undefin
return [];
}
function validateTextRulesArray(value: unknown, path: string, targetName: string | undefined): ConfigValidationIssue[] {
if (!Array.isArray(value)) {
return [issue("invalid-type", path, "必须为数组", targetName)];
}
const issues: ConfigValidationIssue[] = [];
for (let i = 0; i < value.length; i++) {
const rule: unknown = value[i];
if (!isPlainObject(rule)) {
issues.push(issue("invalid-type", joinPath(path, `[${i}]`), "必须为 operator 对象", targetName));
continue;
}
issues.push(...validateOperatorObject(rule, joinPath(path, `[${i}]`), targetName));
}
return issues;
}
function validateUdpDefaults(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const defaults = input.defaults["udp"];
@@ -99,24 +79,24 @@ function validateUdpExpect(target: Record<string, unknown>, path: string): Confi
issues.push(issue("invalid-type", joinPath(expectPath, "responded"), "必须为布尔值", targetName));
}
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["response"] !== undefined) {
issues.push(...validateTextRulesArray(expect["response"], joinPath(expectPath, "response"), targetName));
issues.push(...validateContentRules(expect["response"], joinPath(expectPath, "response"), targetName));
}
if (expect["responseSize"] !== undefined) {
issues.push(...validateOperatorObject(expect["responseSize"], joinPath(expectPath, "responseSize"), targetName));
issues.push(...validateValueMatcher(expect["responseSize"], joinPath(expectPath, "responseSize"), targetName));
}
if (expect["sourceHost"] !== undefined) {
issues.push(...validateOperatorObject(expect["sourceHost"], joinPath(expectPath, "sourceHost"), targetName));
issues.push(...validateValueMatcher(expect["sourceHost"], joinPath(expectPath, "sourceHost"), targetName));
}
if (expect["sourcePort"] !== undefined) {
issues.push(...validateOperatorObject(expect["sourcePort"], joinPath(expectPath, "sourcePort"), targetName));
issues.push(...validateValueMatcher(expect["sourcePort"], joinPath(expectPath, "sourcePort"), targetName));
}
const respondedFalse = responded === false;
@@ -143,7 +123,7 @@ function validateUdpExpect(target: Record<string, unknown>, path: string): Confi
}
}
const allowedKeys = new Set(["maxDurationMs", "responded", "response", "responseSize", "sourceHost", "sourcePort"]);
const allowedKeys = new Set(["durationMs", "responded", "response", "responseSize", "sourceHost", "sourcePort"]);
for (const key of Object.keys(expect)) {
if (!allowedKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));