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:
175
src/server/checker/expect/content.ts
Normal file
175
src/server/checker/expect/content.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { DOMParser } from "@xmldom/xmldom";
|
||||
import * as cheerio from "cheerio";
|
||||
import { isArray } from "es-toolkit/compat";
|
||||
import * as xpath from "xpath";
|
||||
|
||||
import type { CheckFailure } from "../types";
|
||||
import type {
|
||||
ContentCssRule,
|
||||
ContentJsonRule,
|
||||
ContentRule,
|
||||
ContentRules,
|
||||
ContentXpathRule,
|
||||
ExpectResult,
|
||||
} from "./types";
|
||||
|
||||
import { errorFailure, mismatchFailure } from "./failure";
|
||||
import { applyMatcher, evaluateJsonPath } from "./matcher";
|
||||
|
||||
type ParsedJsonResult = { error: string; ok: false } | { ok: true; value: unknown };
|
||||
|
||||
export function checkContentRules(
|
||||
source: unknown,
|
||||
rules: ContentRules | undefined,
|
||||
options: { path?: string; phase: CheckFailure["phase"] },
|
||||
): ExpectResult {
|
||||
if (!rules || rules.length === 0) return { failure: null, matched: true };
|
||||
|
||||
const basePath = options.path ?? options.phase;
|
||||
let parsedJson: ParsedJsonResult | undefined;
|
||||
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
const rule = rules[i]!;
|
||||
if ("json" in rule && parsedJson === undefined) {
|
||||
parsedJson = parseJsonSource(source);
|
||||
}
|
||||
|
||||
const result = checkSingleContentRule(source, rule, `${basePath}[${i}]`, options.phase, parsedJson);
|
||||
if (!result.matched) return result;
|
||||
}
|
||||
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
function checkCssRule(
|
||||
source: unknown,
|
||||
rule: ContentCssRule,
|
||||
rulePath: string,
|
||||
phase: CheckFailure["phase"],
|
||||
): ExpectResult {
|
||||
const { attr, selector, ...matcher } = rule;
|
||||
const fullPath = `${rulePath}.css(${selector}${attr ? `@${attr}` : ""})`;
|
||||
|
||||
let $: cheerio.CheerioAPI;
|
||||
try {
|
||||
$ = cheerio.load(contentText(source));
|
||||
} catch {
|
||||
return { failure: errorFailure(phase, fullPath, "failed to parse HTML"), matched: false };
|
||||
}
|
||||
|
||||
const el = $(selector).first();
|
||||
const actual = el.length === 0 ? undefined : attr ? el.attr(attr) : el.text();
|
||||
const effectiveMatcher = Object.keys(matcher).length === 0 ? { exists: true } : matcher;
|
||||
|
||||
if (!applyMatcher(actual, effectiveMatcher)) {
|
||||
return {
|
||||
failure: mismatchFailure(phase, fullPath, effectiveMatcher, actual, `css selector ${selector} mismatch`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
function checkJsonRule(
|
||||
rule: ContentJsonRule,
|
||||
rulePath: string,
|
||||
phase: CheckFailure["phase"],
|
||||
parsedJson?: ParsedJsonResult,
|
||||
): ExpectResult {
|
||||
const { path, ...matcher } = rule;
|
||||
const fullPath = `${rulePath}.json(${path})`;
|
||||
|
||||
if (!parsedJson?.ok) {
|
||||
return { failure: errorFailure(phase, fullPath, parsedJson?.error ?? "content is not valid JSON"), matched: false };
|
||||
}
|
||||
|
||||
const actual = evaluateJsonPath(parsedJson.value, path);
|
||||
const effectiveMatcher = Object.keys(matcher).length === 0 ? { exists: true } : matcher;
|
||||
if (!applyMatcher(actual, effectiveMatcher)) {
|
||||
return {
|
||||
failure: mismatchFailure(phase, fullPath, effectiveMatcher, actual, `json path ${path} mismatch`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
function checkSingleContentRule(
|
||||
source: unknown,
|
||||
rule: ContentRule,
|
||||
rulePath: string,
|
||||
phase: CheckFailure["phase"],
|
||||
parsedJson?: ParsedJsonResult,
|
||||
): ExpectResult {
|
||||
if ("json" in rule) return checkJsonRule(rule.json, rulePath, phase, parsedJson);
|
||||
if ("css" in rule) return checkCssRule(source, rule.css, rulePath, phase);
|
||||
if ("xpath" in rule) return checkXpathRule(source, rule.xpath, rulePath, phase);
|
||||
|
||||
if (!applyMatcher(source, rule, { stringifyNonString: true })) {
|
||||
return {
|
||||
failure: mismatchFailure(phase, rulePath, rule, source, `${phase} rule mismatch`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
function checkXpathRule(
|
||||
source: unknown,
|
||||
rule: ContentXpathRule,
|
||||
rulePath: string,
|
||||
phase: CheckFailure["phase"],
|
||||
): ExpectResult {
|
||||
const { path, ...matcher } = rule;
|
||||
const fullPath = `${rulePath}.xpath(${path})`;
|
||||
|
||||
let doc: ReturnType<DOMParser["parseFromString"]>;
|
||||
try {
|
||||
doc = new DOMParser().parseFromString(contentText(source), "text/xml");
|
||||
} catch {
|
||||
return { failure: errorFailure(phase, fullPath, "failed to parse XML/HTML"), matched: false };
|
||||
}
|
||||
|
||||
const result = xpath.select(path, doc as unknown as Node);
|
||||
const actual = xpathValue(result);
|
||||
const effectiveMatcher = Object.keys(matcher).length === 0 ? { exists: true } : matcher;
|
||||
|
||||
if (!applyMatcher(actual, effectiveMatcher)) {
|
||||
return {
|
||||
failure: mismatchFailure(phase, fullPath, effectiveMatcher, actual, `xpath ${path} mismatch`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
function contentText(source: unknown): string {
|
||||
if (source === null || source === undefined) return "";
|
||||
if (typeof source === "string") return source;
|
||||
if (typeof source === "number" || typeof source === "boolean" || typeof source === "bigint") return String(source);
|
||||
if (typeof source === "symbol") return source.description ?? "";
|
||||
if (typeof source === "function") return source.name;
|
||||
return JSON.stringify(source) ?? "";
|
||||
}
|
||||
|
||||
function parseJsonSource(source: unknown): ParsedJsonResult {
|
||||
if (typeof source !== "string") return { ok: true, value: source };
|
||||
try {
|
||||
return { ok: true, value: JSON.parse(source) as unknown };
|
||||
} catch {
|
||||
return { error: "content is not valid JSON", ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
function xpathValue(result: unknown): unknown {
|
||||
if (!isArray(result)) return result;
|
||||
if (result.length === 0) return undefined;
|
||||
|
||||
const node = (result as unknown[])[0]!;
|
||||
if (typeof node !== "object" || node === null) return node;
|
||||
const asNode = node as Node;
|
||||
return asNode.nodeValue ?? (asNode as unknown as Element).textContent ?? "";
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { ExpectResult } from "./types";
|
||||
|
||||
import { mismatchFailure } from "./failure";
|
||||
|
||||
export function checkDuration(durationMs: number, maxDurationMs?: number): ExpectResult {
|
||||
if (maxDurationMs === undefined) return { failure: null, matched: true };
|
||||
if (durationMs > maxDurationMs) {
|
||||
return {
|
||||
failure: mismatchFailure(
|
||||
"duration",
|
||||
"duration",
|
||||
`<=${maxDurationMs}ms`,
|
||||
durationMs,
|
||||
`duration ${durationMs}ms > ${maxDurationMs}ms`,
|
||||
),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
32
src/server/checker/expect/key-value.ts
Normal file
32
src/server/checker/expect/key-value.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { CheckFailure } from "../types";
|
||||
import type { ExpectResult, KeyValueExpect } from "./types";
|
||||
|
||||
import { mismatchFailure } from "./failure";
|
||||
import { checkExpectValue } from "./matcher";
|
||||
|
||||
export function checkKeyValueExpect(
|
||||
actual: Record<string, unknown>,
|
||||
expected: KeyValueExpect | undefined,
|
||||
options: { normalizeKey?: (key: string) => string; path?: string; phase: CheckFailure["phase"] },
|
||||
): ExpectResult {
|
||||
if (!expected) return { failure: null, matched: true };
|
||||
|
||||
const normalizeKey = options.normalizeKey ?? ((key: string) => key);
|
||||
const basePath = options.path ?? options.phase;
|
||||
const actualMap = new Map<string, unknown>();
|
||||
for (const [key, value] of Object.entries(actual)) {
|
||||
actualMap.set(normalizeKey(key), value);
|
||||
}
|
||||
|
||||
for (const [key, expectedValue] of Object.entries(expected)) {
|
||||
const actualValue = actualMap.get(normalizeKey(key));
|
||||
if (!checkExpectValue(actualValue, expectedValue)) {
|
||||
return {
|
||||
failure: mismatchFailure(options.phase, `${basePath}.${key}`, expectedValue, actualValue, `${key} mismatch`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
133
src/server/checker/expect/matcher.ts
Normal file
133
src/server/checker/expect/matcher.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { isEmptyObject, isEqual, isNil, isPlainObject } from "es-toolkit";
|
||||
import { isArray } from "es-toolkit/compat";
|
||||
|
||||
import type { CheckFailure, JsonValue } from "../types";
|
||||
import type { ExpectResult, ValueMatcher } from "./types";
|
||||
|
||||
import { mismatchFailure } from "./failure";
|
||||
|
||||
export const MatcherKeys = ["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "regex"] as const;
|
||||
|
||||
const MATCHER_KEY_SET = new Set<string>(MatcherKeys);
|
||||
|
||||
export function applyMatcher(
|
||||
actual: unknown,
|
||||
matcher: ValueMatcher,
|
||||
options: { stringifyNonString?: boolean } = {},
|
||||
): boolean {
|
||||
for (const [key, expected] of Object.entries(matcher)) {
|
||||
if (expected === undefined) continue;
|
||||
|
||||
switch (key) {
|
||||
case "contains":
|
||||
if (!stringValue(actual, options).includes(expected as string)) return false;
|
||||
break;
|
||||
case "empty": {
|
||||
const empty = isEmptyValue(actual);
|
||||
if (expected !== empty) return false;
|
||||
break;
|
||||
}
|
||||
case "equals":
|
||||
if (!isEqual(actual, expected)) return false;
|
||||
break;
|
||||
case "exists":
|
||||
if (expected) {
|
||||
if (actual === undefined) return false;
|
||||
} else {
|
||||
if (actual !== undefined) return false;
|
||||
}
|
||||
break;
|
||||
case "gt":
|
||||
if (!compareNumber(actual, expected as number, (left, right) => left > right)) return false;
|
||||
break;
|
||||
case "gte":
|
||||
if (!compareNumber(actual, expected as number, (left, right) => left >= right)) return false;
|
||||
break;
|
||||
case "lt":
|
||||
if (!compareNumber(actual, expected as number, (left, right) => left < right)) return false;
|
||||
break;
|
||||
case "lte":
|
||||
if (!compareNumber(actual, expected as number, (left, right) => left <= right)) return false;
|
||||
break;
|
||||
case "regex":
|
||||
if (!new RegExp(expected as string).test(stringValue(actual, options))) return false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function checkExpectValue(actual: unknown, expected: JsonValue | ValueMatcher): boolean {
|
||||
if (isValueMatcherObject(expected)) {
|
||||
return applyMatcher(actual, expected);
|
||||
}
|
||||
return applyMatcher(actual, { equals: expected });
|
||||
}
|
||||
|
||||
export function checkValueMatcher(
|
||||
actual: unknown,
|
||||
matcher: undefined | ValueMatcher,
|
||||
options: { message?: string; path: string; phase: CheckFailure["phase"]; stringifyNonString?: boolean },
|
||||
): ExpectResult {
|
||||
if (matcher === undefined) return { failure: null, matched: true };
|
||||
if (applyMatcher(actual, matcher, { stringifyNonString: options.stringifyNonString })) {
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
return {
|
||||
failure: mismatchFailure(
|
||||
options.phase,
|
||||
options.path,
|
||||
matcher,
|
||||
actual,
|
||||
options.message ?? `${options.path} mismatch`,
|
||||
),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function evaluateJsonPath(json: unknown, path: string): unknown {
|
||||
if (!path.startsWith("$.")) return undefined;
|
||||
|
||||
const segments = path.slice(2).split(".");
|
||||
let current: unknown = json;
|
||||
|
||||
for (const seg of segments) {
|
||||
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
|
||||
if (bracketMatch) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
current = (current as Record<string, unknown>)[bracketMatch[1]!];
|
||||
const idx = parseInt(bracketMatch[2]!, 10);
|
||||
if (!isArray(current) || idx >= current.length) return undefined;
|
||||
current = current[idx];
|
||||
} else {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
current = (current as Record<string, unknown>)[seg];
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
export function isValueMatcherObject(value: unknown): value is ValueMatcher {
|
||||
return isPlainObject(value) && Object.keys(value).some((key) => MATCHER_KEY_SET.has(key));
|
||||
}
|
||||
|
||||
function compareNumber(
|
||||
actual: unknown,
|
||||
expected: number,
|
||||
compare: (actual: number, expected: number) => boolean,
|
||||
): boolean {
|
||||
const value = Number(actual);
|
||||
return Number.isFinite(value) && compare(value, expected);
|
||||
}
|
||||
|
||||
function isEmptyValue(value: unknown): boolean {
|
||||
return isNil(value) || value === "" || (isArray(value) && value.length === 0) || isEmptyObject(value);
|
||||
}
|
||||
|
||||
function stringValue(actual: unknown, options: { stringifyNonString?: boolean }): string {
|
||||
if (!options.stringifyNonString || typeof actual === "string") return String(actual);
|
||||
if (actual !== null && typeof actual === "object") return JSON.stringify(actual);
|
||||
return String(actual);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { isEmptyObject, isEqual, isNil, isPlainObject } from "es-toolkit";
|
||||
import { isArray } from "es-toolkit/compat";
|
||||
|
||||
import type { ExpectOperator, ExpectValue } from "../types";
|
||||
|
||||
const OPERATOR_KEYS = new Set(["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "match"]);
|
||||
|
||||
export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
|
||||
for (const [key, expected] of Object.entries(op)) {
|
||||
if (expected === undefined) continue;
|
||||
|
||||
switch (key) {
|
||||
case "contains":
|
||||
if (!String(actual).includes(expected as string)) return false;
|
||||
break;
|
||||
case "empty": {
|
||||
const isEmpty =
|
||||
isNil(actual) || actual === "" || (isArray(actual) && actual.length === 0) || isEmptyObject(actual);
|
||||
if (expected !== isEmpty) return false;
|
||||
break;
|
||||
}
|
||||
case "equals":
|
||||
if (!isEqual(actual, expected)) return false;
|
||||
break;
|
||||
case "exists":
|
||||
if (expected) {
|
||||
if (actual === undefined) return false;
|
||||
} else {
|
||||
if (actual !== undefined) return false;
|
||||
}
|
||||
break;
|
||||
case "gt":
|
||||
if (!(Number(actual) > (expected as number))) return false;
|
||||
break;
|
||||
case "gte":
|
||||
if (!(Number(actual) >= (expected as number))) return false;
|
||||
break;
|
||||
case "lt":
|
||||
if (!(Number(actual) < (expected as number))) return false;
|
||||
break;
|
||||
case "lte":
|
||||
if (!(Number(actual) <= (expected as number))) return false;
|
||||
break;
|
||||
case "match":
|
||||
if (!new RegExp(expected as string).test(String(actual))) return false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function checkExpectValue(actual: unknown, expected: ExpectValue): boolean {
|
||||
if (isPlainObject(expected) && Object.keys(expected).some((key) => OPERATOR_KEYS.has(key))) {
|
||||
return applyOperator(actual, expected as ExpectOperator);
|
||||
}
|
||||
return applyOperator(actual, { equals: expected as Exclude<ExpectValue, ExpectOperator> });
|
||||
}
|
||||
|
||||
export function evaluateJsonPath(json: unknown, path: string): unknown {
|
||||
if (!path.startsWith("$.")) return undefined;
|
||||
|
||||
const segments = path.slice(2).split(".");
|
||||
let current: unknown = json;
|
||||
|
||||
for (const seg of segments) {
|
||||
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
|
||||
if (bracketMatch) {
|
||||
current = (current as Record<string, unknown>)?.[bracketMatch[1]!];
|
||||
const idx = parseInt(bracketMatch[2]!, 10);
|
||||
if (!isArray(current) || idx >= current.length) return undefined;
|
||||
current = current[idx];
|
||||
} else {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
current = (current as Record<string, unknown>)[seg];
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
@@ -1,6 +1,41 @@
|
||||
import type { CheckFailure } from "../types";
|
||||
import type { CheckFailure, JsonValue } from "../types";
|
||||
|
||||
export interface ContentCssRule extends ValueMatcher {
|
||||
attr?: string;
|
||||
selector: string;
|
||||
}
|
||||
|
||||
export interface ContentJsonRule extends ValueMatcher {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export type ContentRule =
|
||||
| ValueMatcher
|
||||
| { css: ContentCssRule }
|
||||
| { json: ContentJsonRule }
|
||||
| { xpath: ContentXpathRule };
|
||||
|
||||
export type ContentRules = ContentRule[];
|
||||
|
||||
export interface ContentXpathRule extends ValueMatcher {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ExpectResult {
|
||||
failure: CheckFailure | null;
|
||||
matched: boolean;
|
||||
}
|
||||
|
||||
export type KeyValueExpect = Record<string, JsonValue | ValueMatcher>;
|
||||
|
||||
export interface ValueMatcher {
|
||||
contains?: string;
|
||||
empty?: boolean;
|
||||
equals?: JsonValue;
|
||||
exists?: boolean;
|
||||
gt?: number;
|
||||
gte?: number;
|
||||
lt?: number;
|
||||
lte?: number;
|
||||
regex?: string;
|
||||
}
|
||||
|
||||
225
src/server/checker/expect/validate-matcher.ts
Normal file
225
src/server/checker/expect/validate-matcher.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { DOMParser } from "@xmldom/xmldom";
|
||||
import { isBoolean, isNumber, isPlainObject, isString } from "es-toolkit";
|
||||
import { isArray } from "es-toolkit/compat";
|
||||
import * as xpath from "xpath";
|
||||
|
||||
import type { ConfigValidationIssue } from "../schema/issues";
|
||||
import type { JsonValue } from "../types";
|
||||
|
||||
import { issue, joinPath } from "../schema/issues";
|
||||
import { isUnsafeRegex } from "./redos";
|
||||
|
||||
export const MatcherKeys = ["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "regex"] as const;
|
||||
|
||||
const MATCHER_KEY_SET = new Set<string>(MatcherKeys);
|
||||
const EXTRACTOR_KEYS = ["css", "json", "xpath"] as const;
|
||||
const EXTRACTOR_KEY_SET = new Set<string>(EXTRACTOR_KEYS);
|
||||
|
||||
export function isJsonValue(value: unknown): value is JsonValue {
|
||||
if (value === null) return true;
|
||||
if (isString(value) || isBoolean(value)) return true;
|
||||
if (isNumber(value)) return Number.isFinite(value);
|
||||
if (isArray(value)) return value.every(isJsonValue);
|
||||
if (isPlainObject(value)) return Object.values(value).every(isJsonValue);
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return isPlainObject(value);
|
||||
}
|
||||
|
||||
export function validateContentRules(rules: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
|
||||
if (!isArray(rules)) return [issue("invalid-type", path, "必须为数组", targetName)];
|
||||
return rules.flatMap((rule, index) => validateContentRule(rule, `${path}[${index}]`, targetName));
|
||||
}
|
||||
|
||||
export function validateJsonPath(path: string, rulePath: string, targetName?: string): ConfigValidationIssue[] {
|
||||
if (!path.startsWith("$.") || path.length <= 2) {
|
||||
return [issue("invalid-jsonpath", joinPath(rulePath, "path"), '必须为以 "$." 开头的有效 JSONPath', targetName)];
|
||||
}
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const segments = path.slice(2).split(".");
|
||||
for (const seg of segments) {
|
||||
if (seg === "") {
|
||||
issues.push(issue("invalid-jsonpath", joinPath(rulePath, "path"), "包含空段", targetName));
|
||||
}
|
||||
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
|
||||
if (bracketMatch?.[1]!.trim() === "") {
|
||||
issues.push(issue("invalid-jsonpath", joinPath(rulePath, "path"), "数组访问缺少属性名", targetName));
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function validateKeyValueExpect(value: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
|
||||
if (!isPlainRecord(value)) return [issue("invalid-type", path, "必须为对象", targetName)];
|
||||
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
const itemPath = joinPath(path, key);
|
||||
if (isPlainRecord(item)) {
|
||||
issues.push(...validateValueMatcher(item, itemPath, targetName));
|
||||
} else if (!isJsonValue(item)) {
|
||||
issues.push(issue("invalid-type", itemPath, "必须为 JSON value 或 matcher 对象", targetName));
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function validateValueMatcher(
|
||||
matcher: unknown,
|
||||
path: string,
|
||||
targetName?: string,
|
||||
options: { requireAtLeastOne?: boolean } = {},
|
||||
): ConfigValidationIssue[] {
|
||||
const requireAtLeastOne = options.requireAtLeastOne ?? true;
|
||||
if (!isPlainRecord(matcher)) return [issue("invalid-type", path, "必须为 matcher 对象", targetName)];
|
||||
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
let found = 0;
|
||||
for (const [key, value] of Object.entries(matcher)) {
|
||||
if (!MATCHER_KEY_SET.has(key)) {
|
||||
issues.push(issue("unknown-matcher", joinPath(path, key), "是未知 matcher", targetName));
|
||||
continue;
|
||||
}
|
||||
if (value === undefined) continue;
|
||||
found++;
|
||||
issues.push(...validateMatcherValue(key, value, joinPath(path, key), targetName));
|
||||
}
|
||||
|
||||
if (requireAtLeastOne && found === 0) {
|
||||
issues.push(issue("empty-matcher", path, "必须包含至少一个合法 matcher", targetName));
|
||||
}
|
||||
|
||||
if (matcher["exists"] === false && found > 1) {
|
||||
issues.push(issue("invalid-value", joinPath(path, "exists"), "exists:false 不能与其他 matcher 组合", targetName));
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function validateContentRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
|
||||
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
|
||||
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const extractors = Object.keys(rule).filter((key) => EXTRACTOR_KEY_SET.has(key));
|
||||
const directMatchers = Object.keys(rule).filter((key) => MATCHER_KEY_SET.has(key));
|
||||
|
||||
for (const key of Object.keys(rule)) {
|
||||
if (!MATCHER_KEY_SET.has(key) && !EXTRACTOR_KEY_SET.has(key)) {
|
||||
issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName));
|
||||
}
|
||||
}
|
||||
|
||||
if (extractors.length > 1) {
|
||||
issues.push(issue("multiple-content-rules", path, "一条规则不能同时包含多个 extractor", targetName));
|
||||
}
|
||||
if (extractors.length === 1 && directMatchers.length > 0) {
|
||||
issues.push(issue("invalid-content-rule", path, "直接 matcher 不能与 extractor 混用", targetName));
|
||||
}
|
||||
if (issues.length > 0) return issues;
|
||||
|
||||
if (extractors.length === 0) return validateValueMatcher(rule, path, targetName);
|
||||
|
||||
const extractor = extractors[0]!;
|
||||
switch (extractor) {
|
||||
case "css":
|
||||
return validateCssRule(rule["css"], joinPath(path, "css"), targetName);
|
||||
case "json":
|
||||
return validateJsonRule(rule["json"], joinPath(path, "json"), targetName);
|
||||
case "xpath":
|
||||
return validateXpathRule(rule["xpath"], joinPath(path, "xpath"), targetName);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
issues.push(...validateExtractorMatcher(rule, new Set(["attr", "selector"]), path, targetName));
|
||||
return issues;
|
||||
}
|
||||
|
||||
function validateExtractorMatcher(
|
||||
rule: Record<string, unknown>,
|
||||
allowedFields: Set<string>,
|
||||
path: string,
|
||||
targetName?: string,
|
||||
): ConfigValidationIssue[] {
|
||||
const matcher: Record<string, unknown> = {};
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
for (const [key, value] of Object.entries(rule)) {
|
||||
if (allowedFields.has(key)) continue;
|
||||
matcher[key] = value;
|
||||
}
|
||||
issues.push(...validateValueMatcher(matcher, path, targetName, { requireAtLeastOne: false }));
|
||||
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));
|
||||
}
|
||||
issues.push(...validateExtractorMatcher(rule, new Set(["path"]), path, targetName));
|
||||
return issues;
|
||||
}
|
||||
|
||||
function validateMatcherValue(key: string, value: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
|
||||
switch (key) {
|
||||
case "contains":
|
||||
return isString(value) ? [] : [issue("invalid-type", path, "必须为字符串", targetName)];
|
||||
case "empty":
|
||||
case "exists":
|
||||
return isBoolean(value) ? [] : [issue("invalid-type", path, "必须为布尔值", targetName)];
|
||||
case "equals":
|
||||
return isJsonValue(value) ? [] : [issue("invalid-type", path, "必须为 JSON value", targetName)];
|
||||
case "gt":
|
||||
case "gte":
|
||||
case "lt":
|
||||
case "lte":
|
||||
return isNumber(value) && Number.isFinite(value)
|
||||
? []
|
||||
: [issue("invalid-type", path, "必须为有限数字", targetName)];
|
||||
case "regex":
|
||||
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)] : [];
|
||||
default:
|
||||
return [issue("unknown-matcher", path, "是未知 matcher", targetName)];
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
issues.push(...validateExtractorMatcher(rule, new Set(["path"]), path, targetName));
|
||||
return issues;
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { isBoolean, isNumber, isPlainObject, isString } from "es-toolkit";
|
||||
import { isArray } from "es-toolkit/compat";
|
||||
|
||||
import type { ConfigValidationIssue } from "../schema/issues";
|
||||
import type { JsonValue } from "../types";
|
||||
|
||||
import { OperatorKeys } from "../schema/fragments";
|
||||
import { issue, joinPath } from "../schema/issues";
|
||||
import { isUnsafeRegex } from "./redos";
|
||||
|
||||
const OPERATOR_KEY_SET = new Set<string>(OperatorKeys);
|
||||
|
||||
export function isJsonValue(value: unknown): value is JsonValue {
|
||||
if (value === null) return true;
|
||||
if (isString(value) || isBoolean(value)) return true;
|
||||
if (isNumber(value)) return Number.isFinite(value);
|
||||
if (isArray(value)) return value.every(isJsonValue);
|
||||
if (isPlainObject(value)) {
|
||||
return Object.values(value).every(isJsonValue);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return isPlainObject(value);
|
||||
}
|
||||
|
||||
export function validateOperatorObject(
|
||||
operators: unknown,
|
||||
path: string,
|
||||
targetName?: string,
|
||||
options: { requireAtLeastOne: boolean } = { requireAtLeastOne: true },
|
||||
): ConfigValidationIssue[] {
|
||||
if (!isPlainRecord(operators)) return [issue("invalid-type", path, "必须为操作符对象", targetName)];
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
let found = 0;
|
||||
for (const [key, value] of Object.entries(operators)) {
|
||||
if (!OPERATOR_KEY_SET.has(key)) {
|
||||
issues.push(issue("unknown-operator", joinPath(path, key), "是未知 operator", targetName));
|
||||
continue;
|
||||
}
|
||||
if (value === undefined) continue;
|
||||
found++;
|
||||
issues.push(...validateOperatorValue(key, value, joinPath(path, key), targetName));
|
||||
}
|
||||
if (options.requireAtLeastOne && found === 0) {
|
||||
issues.push(issue("empty-operator", path, "必须包含至少一个合法 operator", targetName));
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function validateOperatorValue(
|
||||
key: string,
|
||||
value: unknown,
|
||||
path: string,
|
||||
targetName?: string,
|
||||
): ConfigValidationIssue[] {
|
||||
switch (key) {
|
||||
case "contains":
|
||||
return isString(value) ? [] : [issue("invalid-type", path, "必须为字符串", targetName)];
|
||||
case "empty":
|
||||
case "exists":
|
||||
return isBoolean(value) ? [] : [issue("invalid-type", path, "必须为布尔值", targetName)];
|
||||
case "equals":
|
||||
return isJsonValue(value) ? [] : [issue("invalid-type", path, "必须为 JSON value", targetName)];
|
||||
case "gt":
|
||||
case "gte":
|
||||
case "lt":
|
||||
case "lte":
|
||||
return isNumber(value) && Number.isFinite(value)
|
||||
? []
|
||||
: [issue("invalid-type", path, "必须为有限数字", targetName)];
|
||||
case "match":
|
||||
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)] : [];
|
||||
default:
|
||||
return [issue("unknown-operator", path, "是未知 operator", targetName)];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@ import type { CheckResult, RawTargetConfig } from "../../types";
|
||||
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type { DbExpectConfig, DbTargetConfig, ResolvedDbTarget } from "./types";
|
||||
|
||||
import { checkDuration } from "../../expect/duration";
|
||||
import { checkContentRules } from "../../expect/content";
|
||||
import { errorFailure } from "../../expect/failure";
|
||||
import { checkValueMatcher } from "../../expect/matcher";
|
||||
import { checkRowCount, checkRows } from "./expect";
|
||||
import { dbCheckerSchemas } from "./schema";
|
||||
import { validateDbConfig } from "./validate";
|
||||
@@ -59,7 +60,11 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
// 无 query 时仅测试连接
|
||||
if (!t.db.query) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const durationResult = checkDuration(durationMs, t.expect?.maxDurationMs);
|
||||
const durationResult = checkValueMatcher(durationMs, t.expect?.durationMs, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
phase: "duration",
|
||||
});
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
@@ -111,7 +116,11 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
}
|
||||
|
||||
// duration 断言
|
||||
const durationResult = checkDuration(durationMs, t.expect?.maxDurationMs);
|
||||
const durationResult = checkValueMatcher(durationMs, t.expect?.durationMs, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
phase: "duration",
|
||||
});
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
@@ -125,7 +134,7 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
|
||||
// rowCount 断言
|
||||
if (t.expect?.rowCount) {
|
||||
const rowCountResult = checkRowCount(rows, t.expect.rowCount);
|
||||
const rowCountResult = checkRowCount(isArray(rows) ? rows.length : 0, t.expect.rowCount);
|
||||
if (!rowCountResult.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
@@ -153,6 +162,21 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
}
|
||||
}
|
||||
|
||||
if (t.expect?.result && t.expect.result.length > 0) {
|
||||
const rowCount = isArray(rows) ? rows.length : 0;
|
||||
const resultCheck = checkContentRules({ rowCount, rows }, t.expect.result, { path: "result", phase: "result" });
|
||||
if (!resultCheck.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: resultCheck.failure,
|
||||
matched: false,
|
||||
statusDetail: `${rowCount} rows`,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
durationMs,
|
||||
failure: null,
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import { isPlainObject } from "es-toolkit";
|
||||
import { isArray } from "es-toolkit/compat";
|
||||
|
||||
import type { ExpectResult } from "../../expect/types";
|
||||
import type { ExpectOperator, ExpectValue } from "../../types";
|
||||
import type { ExpectResult, KeyValueExpect, ValueMatcher } from "../../expect/types";
|
||||
|
||||
import { mismatchFailure } from "../../expect/failure";
|
||||
import { checkExpectValue } from "../../expect/operator";
|
||||
import { checkKeyValueExpect } from "../../expect/key-value";
|
||||
import { checkValueMatcher } from "../../expect/matcher";
|
||||
|
||||
export function checkRowCount(rows: unknown, op: ExpectOperator): ExpectResult {
|
||||
const actual = isArray(rows) ? rows.length : 0;
|
||||
const matched = checkExpectValue(actual, op);
|
||||
if (!matched) {
|
||||
return {
|
||||
failure: mismatchFailure("rowCount", "rowCount", op, actual, `rowCount ${actual} 不满足条件`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { failure: null, matched: true };
|
||||
export function checkRowCount(actual: number, matcher: ValueMatcher): ExpectResult {
|
||||
return checkValueMatcher(actual, matcher, {
|
||||
message: `rowCount ${actual} 不满足条件`,
|
||||
path: "rowCount",
|
||||
phase: "rowCount",
|
||||
});
|
||||
}
|
||||
|
||||
export function checkRows(rows: unknown, rules: Array<Record<string, ExpectValue>>): ExpectResult {
|
||||
export function checkRows(rows: unknown, rules: KeyValueExpect[]): ExpectResult {
|
||||
if (!isArray(rows)) {
|
||||
return {
|
||||
failure: mismatchFailure("row", "rows", rules, rows, "查询结果不是数组"),
|
||||
@@ -44,16 +40,8 @@ export function checkRows(rows: unknown, rules: Array<Record<string, ExpectValue
|
||||
};
|
||||
}
|
||||
|
||||
for (const [col, expected] of Object.entries(rule)) {
|
||||
const actual = row[col];
|
||||
const matched = checkExpectValue(actual, expected);
|
||||
if (!matched) {
|
||||
return {
|
||||
failure: mismatchFailure("row", `rows[${i}].${col}`, expected, actual, `rows[${i}].${col} mismatch`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
const result = checkKeyValueExpect(row, rule, { path: `rows[${i}]`, phase: "row" });
|
||||
if (!result.matched) return result;
|
||||
}
|
||||
|
||||
return { failure: null, matched: true };
|
||||
|
||||
@@ -2,10 +2,7 @@ import { Type } from "@sinclair/typebox";
|
||||
|
||||
import type { CheckerSchemas } from "../types";
|
||||
|
||||
import { createPureOperatorSchema, jsonValueSchema, operatorProperties } from "../../schema/fragments";
|
||||
|
||||
// Db expect 允许行对象中的列值为字面量或 operator
|
||||
const dbRowValueSchema = Type.Union([jsonValueSchema, createPureOperatorSchema()]);
|
||||
import { createContentRulesSchema, createKeyValueExpectSchema, createValueMatcherSchema } from "../../schema/fragments";
|
||||
|
||||
export const dbCheckerSchemas: CheckerSchemas = {
|
||||
config: Type.Object(
|
||||
@@ -22,31 +19,11 @@ export const dbCheckerSchemas: CheckerSchemas = {
|
||||
defaults: Type.Object({}, { additionalProperties: false }),
|
||||
expect: Type.Object(
|
||||
{
|
||||
maxDurationMs: Type.Optional(Type.Number({ minimum: 0 })),
|
||||
rowCount: Type.Optional(createPureOperatorSchema()),
|
||||
rows: Type.Optional(
|
||||
Type.Array(
|
||||
Type.Record(Type.String(), dbRowValueSchema, {
|
||||
additionalProperties: false,
|
||||
minProperties: 1,
|
||||
}),
|
||||
),
|
||||
),
|
||||
durationMs: Type.Optional(createValueMatcherSchema()),
|
||||
result: Type.Optional(createContentRulesSchema()),
|
||||
rowCount: Type.Optional(createValueMatcherSchema()),
|
||||
rows: Type.Optional(Type.Array(createKeyValueExpectSchema())),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
};
|
||||
|
||||
// 导出用于 validate 的辅助类型
|
||||
export const DbOperatorKeys = new Set<string>([
|
||||
...Object.keys(operatorProperties()),
|
||||
"contains",
|
||||
"empty",
|
||||
"equals",
|
||||
"exists",
|
||||
"gt",
|
||||
"gte",
|
||||
"lt",
|
||||
"lte",
|
||||
"match",
|
||||
]);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { ExpectOperator, ExpectValue, ResolvedTargetBase } from "../../types";
|
||||
import type { ContentRules, KeyValueExpect, ValueMatcher } from "../../expect/types";
|
||||
import type { ResolvedTargetBase } from "../../types";
|
||||
|
||||
export interface DbExpectConfig {
|
||||
maxDurationMs?: number;
|
||||
rowCount?: ExpectOperator;
|
||||
rows?: Array<Record<string, ExpectValue>>;
|
||||
durationMs?: ValueMatcher;
|
||||
result?: ContentRules;
|
||||
rowCount?: ValueMatcher;
|
||||
rows?: KeyValueExpect[];
|
||||
}
|
||||
|
||||
export interface DbTargetConfig {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { isNumber, isPlainObject, isString } from "es-toolkit";
|
||||
import { isPlainObject, isString } from "es-toolkit";
|
||||
import { isArray } from "es-toolkit/compat";
|
||||
|
||||
import type { ConfigValidationIssue } from "../../schema/issues";
|
||||
import type { CheckerValidationInput } from "../types";
|
||||
|
||||
import { isUnsafeRegex } from "../../expect/redos";
|
||||
import { validateOperatorObject } from "../../expect/validate-operator";
|
||||
import { validateContentRules, validateKeyValueExpect, validateValueMatcher } from "../../expect/validate-matcher";
|
||||
import { issue, joinPath } from "../../schema/issues";
|
||||
|
||||
export function validateDbConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
|
||||
@@ -21,7 +20,7 @@ export function validateDbConfig(input: CheckerValidationInput): ConfigValidatio
|
||||
return issues;
|
||||
}
|
||||
|
||||
function collectRowOperators(rows: unknown[], path: string, targetName?: string): ConfigValidationIssue[] {
|
||||
function collectRowExpects(rows: unknown[], path: string, targetName?: string): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]!;
|
||||
@@ -29,28 +28,7 @@ function collectRowOperators(rows: unknown[], path: string, targetName?: string)
|
||||
issues.push(issue("invalid-type", `${path}[${i}]`, "必须为对象", targetName));
|
||||
continue;
|
||||
}
|
||||
for (const [col, value] of Object.entries(row)) {
|
||||
const colPath = `${path}[${i}].${col}`;
|
||||
if (isPlainObject(value) && Object.keys(value).some((k) => k === "match")) {
|
||||
// 检查 match 正则
|
||||
const valueRecord = value as Record<string, unknown>;
|
||||
const match: unknown = valueRecord["match"];
|
||||
if (isString(match)) {
|
||||
try {
|
||||
new RegExp(match);
|
||||
} catch {
|
||||
issues.push(issue("invalid-regex", colPath, "正则不合法", targetName));
|
||||
}
|
||||
if (isUnsafeRegex(match)) {
|
||||
issues.push(issue("unsafe-regex", colPath, "正则存在 ReDoS 风险", targetName));
|
||||
}
|
||||
}
|
||||
}
|
||||
// 校验 operator 对象
|
||||
if (isPlainObject(value)) {
|
||||
issues.push(...validateOperatorObject(value, colPath, targetName, { requireAtLeastOne: false }));
|
||||
}
|
||||
}
|
||||
issues.push(...validateKeyValueExpect(row, `${path}[${i}]`, targetName));
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
@@ -60,10 +38,6 @@ function getTargetName(target: Record<string, unknown>): string | undefined {
|
||||
return isString(target["id"]) ? target["id"] : undefined;
|
||||
}
|
||||
|
||||
function isNonNegativeFiniteNumber(value: unknown): boolean {
|
||||
return isNumber(value) && Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
function validateDbExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||
const targetName = getTargetName(target);
|
||||
const expect = target["expect"];
|
||||
@@ -71,24 +45,28 @@ function validateDbExpect(target: Record<string, unknown>, path: string): Config
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const expectPath = joinPath(path, "expect");
|
||||
|
||||
if (expect["maxDurationMs"] !== undefined && !isNonNegativeFiniteNumber(expect["maxDurationMs"])) {
|
||||
issues.push(issue("invalid-type", joinPath(expectPath, "maxDurationMs"), "必须为非负有限数字", targetName));
|
||||
if (expect["durationMs"] !== undefined) {
|
||||
issues.push(...validateValueMatcher(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
|
||||
}
|
||||
|
||||
if (expect["rowCount"] !== undefined) {
|
||||
issues.push(...validateOperatorObject(expect["rowCount"], joinPath(expectPath, "rowCount"), targetName));
|
||||
issues.push(...validateValueMatcher(expect["rowCount"], joinPath(expectPath, "rowCount"), targetName));
|
||||
}
|
||||
|
||||
if (expect["rows"] !== undefined) {
|
||||
if (!isArray(expect["rows"])) {
|
||||
issues.push(issue("invalid-type", joinPath(expectPath, "rows"), "必须为数组", targetName));
|
||||
} else {
|
||||
issues.push(...collectRowOperators(expect["rows"], joinPath(expectPath, "rows"), targetName));
|
||||
issues.push(...collectRowExpects(expect["rows"], joinPath(expectPath, "rows"), targetName));
|
||||
}
|
||||
}
|
||||
|
||||
if (expect["result"] !== undefined) {
|
||||
issues.push(...validateContentRules(expect["result"], joinPath(expectPath, "result"), targetName));
|
||||
}
|
||||
|
||||
// 检查未知字段
|
||||
const allowedKeys = new Set(["maxDurationMs", "rowCount", "rows"]);
|
||||
const allowedKeys = new Set(["durationMs", "result", "rowCount", "rows"]);
|
||||
for (const key of Object.keys(expect)) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 },
|
||||
),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -4,14 +4,24 @@ import { Type } from "@sinclair/typebox";
|
||||
|
||||
import type { CheckerDefinition } from "../runner/types";
|
||||
|
||||
import { durationSchema, variableValueSchema } from "./fragments";
|
||||
import {
|
||||
createContentRulesSchema,
|
||||
createKeyValueExpectSchema,
|
||||
createValueMatcherSchema,
|
||||
durationSchema,
|
||||
variableValueSchema,
|
||||
} from "./fragments";
|
||||
|
||||
export function createExternalProbeConfigSchema(checkers: CheckerDefinition[]): Record<string, unknown> {
|
||||
return {
|
||||
...cloneSchema(createProbeConfigSchema(checkers, true)),
|
||||
$id: "https://dial.local/probe-config.schema.json",
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
definitions: {},
|
||||
definitions: {
|
||||
ContentRules: cloneSchema(createContentRulesSchema()),
|
||||
KeyValueExpect: cloneSchema(createKeyValueExpectSchema()),
|
||||
ValueMatcher: cloneSchema(createValueMatcherSchema()),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,7 @@ import type { JsonValue } from "./types";
|
||||
|
||||
export const HTTP_METHODS = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] as const;
|
||||
|
||||
export const BodyRuleTypeKeys = ["contains", "regex", "json", "css", "xpath"] as const;
|
||||
|
||||
export const OperatorKeys = ["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "match"] as const;
|
||||
export const MatcherKeys = ["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "regex"] as const;
|
||||
|
||||
export const durationSchema = Type.String();
|
||||
|
||||
@@ -41,51 +39,43 @@ export const stringMapSchema = Type.Unsafe<Record<string, string>>({
|
||||
type: "object",
|
||||
});
|
||||
|
||||
export function createBodyRulesSchema(): TSchema {
|
||||
export function createContentRulesSchema(): TSchema {
|
||||
return Type.Array(
|
||||
Type.Object(
|
||||
{
|
||||
contains: Type.Optional(Type.String()),
|
||||
...matcherProperties(),
|
||||
css: Type.Optional(
|
||||
Type.Object(
|
||||
{ attr: Type.Optional(Type.String()), selector: Type.String({ minLength: 1 }), ...operatorProperties() },
|
||||
{ attr: Type.Optional(Type.String()), selector: Type.String({ minLength: 1 }), ...matcherProperties() },
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
json: Type.Optional(
|
||||
Type.Object({ path: Type.String(), ...operatorProperties() }, { additionalProperties: false }),
|
||||
Type.Object({ path: Type.String(), ...matcherProperties() }, { additionalProperties: false }),
|
||||
),
|
||||
regex: Type.Optional(Type.String()),
|
||||
xpath: Type.Optional(
|
||||
Type.Object(
|
||||
{ path: Type.String({ minLength: 1 }), ...operatorProperties() },
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
Type.Object({ path: Type.String({ minLength: 1 }), ...matcherProperties() }, { additionalProperties: false }),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
{ additionalProperties: false, minProperties: 1 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function createHeaderExpectSchema(): TSchema {
|
||||
export function createKeyValueExpectSchema(): TSchema {
|
||||
return Type.Unsafe<Record<string, unknown>>({
|
||||
additionalProperties: {
|
||||
anyOf: [{ type: "string" }, createPureOperatorSchema()],
|
||||
anyOf: [jsonValueSchema, createValueMatcherSchema()],
|
||||
},
|
||||
type: "object",
|
||||
});
|
||||
}
|
||||
|
||||
export function createPureOperatorSchema(): TSchema {
|
||||
return Type.Object(operatorProperties(), { additionalProperties: false, minProperties: 1 });
|
||||
export function createValueMatcherSchema(): TSchema {
|
||||
return Type.Object(matcherProperties(), { additionalProperties: false, minProperties: 1 });
|
||||
}
|
||||
|
||||
export function createTextRulesSchema(): TSchema {
|
||||
return Type.Array(createPureOperatorSchema());
|
||||
}
|
||||
|
||||
export function operatorProperties(): Record<string, TSchema> {
|
||||
export function matcherProperties(): Record<string, TSchema> {
|
||||
return {
|
||||
contains: Type.Optional(Type.String()),
|
||||
empty: Type.Optional(Type.Boolean()),
|
||||
@@ -95,6 +85,6 @@ export function operatorProperties(): Record<string, TSchema> {
|
||||
gte: Type.Optional(Type.Number()),
|
||||
lt: Type.Optional(Type.Number()),
|
||||
lte: Type.Optional(Type.Number()),
|
||||
match: Type.Optional(Type.String()),
|
||||
regex: Type.Optional(Type.String()),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,20 +15,6 @@ export interface EngineRuntimeConfig {
|
||||
retention?: string;
|
||||
}
|
||||
|
||||
export interface ExpectOperator {
|
||||
contains?: string;
|
||||
empty?: boolean;
|
||||
equals?: JsonValue;
|
||||
exists?: boolean;
|
||||
gt?: number;
|
||||
gte?: number;
|
||||
lt?: number;
|
||||
lte?: number;
|
||||
match?: string;
|
||||
}
|
||||
|
||||
export type ExpectValue = ExpectOperator | JsonValue;
|
||||
|
||||
export type JsonValue = boolean | JsonValue[] | null | number | string | { [key: string]: JsonValue };
|
||||
|
||||
export interface ProbeConfig {
|
||||
|
||||
Reference in New Issue
Block a user