refactor: expect 类型模型重构,Raw/Resolved 双层分离与断言基础设施内聚
- 重命名 ContentRules→ContentExpectations, KeyValueExpect→KeyedExpectations - 新增 Raw/Resolved 双层模型:resolve 阶段物化为执行计划,store 持久化 Raw 快照 - HTTP body 按需读取:status/headers 失败或无 body expectation 时不读取 body - 新增 displayValueExpectation() 解包 failure.expected 用户可读展示 - 修复 checkEarlyTimeout 独立 lte/lt 检查,修复 KeyedExpectations JSON Schema - 新增 expect/value.ts(resolve/check/display)、keyed.ts、content.ts、headers.ts、status.ts - 删除旧 normalize.ts/matcher.ts/validate-matcher.ts/key-value.ts - 更新 DEVELOPMENT.md:expect 五层管线表、displayValueExpectation、1.7↔1.10 交叉引用 - 同步 13 个 main specs,归档 refactor-expect-type-model 变更(62/62 tasks)
This commit is contained in:
@@ -1,53 +1,67 @@
|
||||
import { DOMParser } from "@xmldom/xmldom";
|
||||
import * as cheerio from "cheerio";
|
||||
import { isPlainObject } from "es-toolkit";
|
||||
import * as xpath from "xpath";
|
||||
|
||||
import type { CheckFailure } from "../types";
|
||||
import type {
|
||||
ContentCssRule,
|
||||
ContentJsonRule,
|
||||
ContentRule,
|
||||
ContentRules,
|
||||
ContentXpathRule,
|
||||
ExpectResult,
|
||||
ContentCssExpectation,
|
||||
ContentExpectation,
|
||||
ContentExpectations,
|
||||
ContentJsonExpectation,
|
||||
ContentValueExpectation,
|
||||
ContentXpathExpectation,
|
||||
ExpectationResult,
|
||||
RawContentCssExpectation,
|
||||
RawContentExpectation,
|
||||
RawContentExpectations,
|
||||
RawContentJsonExpectation,
|
||||
RawContentXpathExpectation,
|
||||
ValueExpectation,
|
||||
ValueMatcher,
|
||||
} from "./types";
|
||||
|
||||
import { errorFailure, mismatchFailure } from "./failure";
|
||||
import { applyMatcher, evaluateJsonPath } from "./matcher";
|
||||
import { CONTENT_EXTRACTOR_KEY_SET, MATCHER_KEY_SET } from "./keys";
|
||||
import { applyValueMatcher, displayValueExpectation, evaluateJsonPath } from "./value";
|
||||
|
||||
type ParsedJsonResult = { error: string; ok: false } | { ok: true; value: unknown };
|
||||
|
||||
export function checkContentRules(
|
||||
export function checkContentExpectations(
|
||||
source: unknown,
|
||||
rules: ContentRules | undefined,
|
||||
expectations: ContentExpectations | undefined,
|
||||
options: { path?: string; phase: CheckFailure["phase"] },
|
||||
): ExpectResult {
|
||||
if (!rules || rules.length === 0) return { failure: null, matched: true };
|
||||
): ExpectationResult {
|
||||
if (!expectations || expectations.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) {
|
||||
for (let i = 0; i < expectations.length; i++) {
|
||||
const expectation = expectations[i]!;
|
||||
if (expectation.kind === "json" && parsedJson === undefined) {
|
||||
parsedJson = parseJsonSource(source);
|
||||
}
|
||||
|
||||
const result = checkSingleContentRule(source, rule, `${basePath}[${i}]`, options.phase, parsedJson);
|
||||
const result = checkSingleContentExpectation(source, expectation, `${basePath}[${i}]`, options.phase, parsedJson);
|
||||
if (!result.matched) return result;
|
||||
}
|
||||
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
function checkCssRule(
|
||||
export function resolveContentExpectations(raw: RawContentExpectations | undefined): ContentExpectations | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
return raw.map((entry) => resolveContentExpectation(entry));
|
||||
}
|
||||
|
||||
function checkCssExpectation(
|
||||
source: unknown,
|
||||
rule: ContentCssRule,
|
||||
rulePath: string,
|
||||
expectation: ContentCssExpectation,
|
||||
expectationPath: string,
|
||||
phase: CheckFailure["phase"],
|
||||
): ExpectResult {
|
||||
const { attr, selector, ...matcher } = rule;
|
||||
const fullPath = `${rulePath}.css(${selector}${attr ? `@${attr}` : ""})`;
|
||||
): ExpectationResult {
|
||||
const fullPath = `${expectationPath}.css(${expectation.selector}${expectation.attr ? `@${expectation.attr}` : ""})`;
|
||||
|
||||
let $: cheerio.CheerioAPI;
|
||||
try {
|
||||
@@ -56,13 +70,18 @@ function checkCssRule(
|
||||
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;
|
||||
const el = $(expectation.selector).first();
|
||||
const actual = el.length === 0 ? undefined : expectation.attr ? el.attr(expectation.attr) : el.text();
|
||||
|
||||
if (!applyMatcher(actual, effectiveMatcher)) {
|
||||
if (!applyValueMatcher(actual, expectation.matcher)) {
|
||||
return {
|
||||
failure: mismatchFailure(phase, fullPath, effectiveMatcher, actual, `css selector ${selector} mismatch`),
|
||||
failure: mismatchFailure(
|
||||
phase,
|
||||
fullPath,
|
||||
displayValueExpectation(expectation.matcher),
|
||||
actual,
|
||||
`css selector ${expectation.selector} mismatch`,
|
||||
),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
@@ -70,24 +89,28 @@ function checkCssRule(
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
function checkJsonRule(
|
||||
rule: ContentJsonRule,
|
||||
rulePath: string,
|
||||
function checkJsonExpectation(
|
||||
expectation: ContentJsonExpectation,
|
||||
expectationPath: string,
|
||||
phase: CheckFailure["phase"],
|
||||
parsedJson?: ParsedJsonResult,
|
||||
): ExpectResult {
|
||||
const { path, ...matcher } = rule;
|
||||
const fullPath = `${rulePath}.json(${path})`;
|
||||
): ExpectationResult {
|
||||
const fullPath = `${expectationPath}.json(${expectation.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)) {
|
||||
const actual = evaluateJsonPath(parsedJson.value, expectation.path);
|
||||
if (!applyValueMatcher(actual, expectation.matcher)) {
|
||||
return {
|
||||
failure: mismatchFailure(phase, fullPath, effectiveMatcher, actual, `json path ${path} mismatch`),
|
||||
failure: mismatchFailure(
|
||||
phase,
|
||||
fullPath,
|
||||
displayValueExpectation(expectation.matcher),
|
||||
actual,
|
||||
`json path ${expectation.path} mismatch`,
|
||||
),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
@@ -95,34 +118,53 @@ function checkJsonRule(
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
function checkSingleContentRule(
|
||||
function checkSingleContentExpectation(
|
||||
source: unknown,
|
||||
rule: ContentRule,
|
||||
rulePath: string,
|
||||
expectation: ContentExpectation,
|
||||
expectationPath: 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);
|
||||
): ExpectationResult {
|
||||
switch (expectation.kind) {
|
||||
case "css":
|
||||
return checkCssExpectation(source, expectation, expectationPath, phase);
|
||||
case "json":
|
||||
return checkJsonExpectation(expectation, expectationPath, phase, parsedJson);
|
||||
case "value":
|
||||
return checkValueContentExpectation(source, expectation, expectationPath, phase);
|
||||
case "xpath":
|
||||
return checkXpathExpectation(source, expectation, expectationPath, phase);
|
||||
}
|
||||
}
|
||||
|
||||
if (!applyMatcher(source, rule, { stringifyNonString: true })) {
|
||||
function checkValueContentExpectation(
|
||||
source: unknown,
|
||||
expectation: ContentValueExpectation,
|
||||
expectationPath: string,
|
||||
phase: CheckFailure["phase"],
|
||||
): ExpectationResult {
|
||||
if (!applyValueMatcher(source, expectation.matcher, { stringifyNonString: true })) {
|
||||
return {
|
||||
failure: mismatchFailure(phase, rulePath, rule, source, `${phase} rule mismatch`),
|
||||
failure: mismatchFailure(
|
||||
phase,
|
||||
expectationPath,
|
||||
displayValueExpectation(expectation.matcher),
|
||||
source,
|
||||
`${phase} expectation mismatch`,
|
||||
),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
function checkXpathRule(
|
||||
function checkXpathExpectation(
|
||||
source: unknown,
|
||||
rule: ContentXpathRule,
|
||||
rulePath: string,
|
||||
expectation: ContentXpathExpectation,
|
||||
expectationPath: string,
|
||||
phase: CheckFailure["phase"],
|
||||
): ExpectResult {
|
||||
const { path, ...matcher } = rule;
|
||||
const fullPath = `${rulePath}.xpath(${path})`;
|
||||
): ExpectationResult {
|
||||
const fullPath = `${expectationPath}.xpath(${expectation.path})`;
|
||||
|
||||
let doc: ReturnType<DOMParser["parseFromString"]>;
|
||||
try {
|
||||
@@ -131,13 +173,18 @@ function checkXpathRule(
|
||||
return { failure: errorFailure(phase, fullPath, "failed to parse XML/HTML"), matched: false };
|
||||
}
|
||||
|
||||
const result = xpath.select(path, doc as unknown as Node);
|
||||
const result = xpath.select(expectation.path, doc as unknown as Node);
|
||||
const actual = xpathValue(result);
|
||||
const effectiveMatcher = Object.keys(matcher).length === 0 ? { exists: true } : matcher;
|
||||
|
||||
if (!applyMatcher(actual, effectiveMatcher)) {
|
||||
if (!applyValueMatcher(actual, expectation.matcher)) {
|
||||
return {
|
||||
failure: mismatchFailure(phase, fullPath, effectiveMatcher, actual, `xpath ${path} mismatch`),
|
||||
failure: mismatchFailure(
|
||||
phase,
|
||||
fullPath,
|
||||
displayValueExpectation(expectation.matcher),
|
||||
actual,
|
||||
`xpath ${expectation.path} mismatch`,
|
||||
),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
@@ -154,6 +201,28 @@ function contentText(source: unknown): string {
|
||||
return JSON.stringify(source) ?? "";
|
||||
}
|
||||
|
||||
function extractDirectMatcher(raw: Record<string, unknown>): ValueMatcher {
|
||||
const matcher: ValueMatcher = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (MATCHER_KEY_SET.has(key) && value !== undefined) {
|
||||
(matcher as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
}
|
||||
return matcher;
|
||||
}
|
||||
|
||||
function extractExtractorMatcher(raw: Record<string, unknown>, ownFields: ReadonlySet<string>): ValueExpectation {
|
||||
const matcher: ValueMatcher = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (ownFields.has(key)) continue;
|
||||
if (MATCHER_KEY_SET.has(key) && value !== undefined) {
|
||||
(matcher as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
}
|
||||
if (Object.keys(matcher).length === 0) return { exists: true };
|
||||
return matcher;
|
||||
}
|
||||
|
||||
function parseJsonSource(source: unknown): ParsedJsonResult {
|
||||
if (typeof source !== "string") return { ok: true, value: source };
|
||||
try {
|
||||
@@ -163,6 +232,44 @@ function parseJsonSource(source: unknown): ParsedJsonResult {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveContentExpectation(raw: RawContentExpectation): ContentExpectation {
|
||||
if (!isPlainObject(raw)) {
|
||||
return { kind: "value", matcher: { equals: raw } };
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
|
||||
if (CONTENT_EXTRACTOR_KEY_SET.has("json") && isPlainObject(record["json"])) {
|
||||
const json = record["json"] as RawContentJsonExpectation;
|
||||
return {
|
||||
kind: "json",
|
||||
matcher: extractExtractorMatcher(json as unknown as Record<string, unknown>, new Set(["path"])),
|
||||
path: json.path,
|
||||
};
|
||||
}
|
||||
|
||||
if (isPlainObject(record["css"])) {
|
||||
const css = record["css"] as RawContentCssExpectation;
|
||||
const expectation: ContentCssExpectation = {
|
||||
kind: "css",
|
||||
matcher: extractExtractorMatcher(css as unknown as Record<string, unknown>, new Set(["attr", "selector"])),
|
||||
selector: css.selector,
|
||||
};
|
||||
if (css.attr !== undefined) expectation.attr = css.attr;
|
||||
return expectation;
|
||||
}
|
||||
|
||||
if (isPlainObject(record["xpath"])) {
|
||||
const xpathExpectation = record["xpath"] as RawContentXpathExpectation;
|
||||
return {
|
||||
kind: "xpath",
|
||||
matcher: extractExtractorMatcher(xpathExpectation as unknown as Record<string, unknown>, new Set(["path"])),
|
||||
path: xpathExpectation.path,
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: "value", matcher: extractDirectMatcher(record) };
|
||||
}
|
||||
|
||||
function xpathValue(result: unknown): unknown {
|
||||
if (!Array.isArray(result)) return result;
|
||||
if (result.length === 0) return undefined;
|
||||
|
||||
14
src/server/checker/expect/headers.ts
Normal file
14
src/server/checker/expect/headers.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ExpectationResult, KeyedExpectations } from "./types";
|
||||
|
||||
import { checkKeyedExpectations } from "./keyed";
|
||||
|
||||
export function checkHeaderExpectations(
|
||||
headers: Record<string, unknown>,
|
||||
expectations?: KeyedExpectations,
|
||||
): ExpectationResult {
|
||||
return checkKeyedExpectations(headers, expectations, {
|
||||
normalizeKey: (key) => key.toLowerCase(),
|
||||
path: "headers",
|
||||
phase: "headers",
|
||||
});
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
46
src/server/checker/expect/keyed.ts
Normal file
46
src/server/checker/expect/keyed.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { CheckFailure } from "../types";
|
||||
import type { ExpectationResult, KeyedExpectations, RawKeyedExpectations } from "./types";
|
||||
|
||||
import { mismatchFailure } from "./failure";
|
||||
import { applyValueMatcher, displayValueExpectation, resolveValueExpectation } from "./value";
|
||||
|
||||
export function checkKeyedExpectations(
|
||||
actual: Record<string, unknown>,
|
||||
expectations: KeyedExpectations | undefined,
|
||||
options: { normalizeKey?: (key: string) => string; path?: string; phase: CheckFailure["phase"] },
|
||||
): ExpectationResult {
|
||||
if (!expectations || expectations.length === 0) 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 expectation of expectations) {
|
||||
const actualValue = actualMap.get(normalizeKey(expectation.key));
|
||||
if (!applyValueMatcher(actualValue, expectation.matcher)) {
|
||||
return {
|
||||
failure: mismatchFailure(
|
||||
options.phase,
|
||||
`${basePath}.${expectation.key}`,
|
||||
displayValueExpectation(expectation.matcher),
|
||||
actualValue,
|
||||
`${expectation.key} mismatch`,
|
||||
),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
export function resolveKeyedExpectations(raw: RawKeyedExpectations | undefined): KeyedExpectations | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
return Object.entries(raw).map(([key, value]) => ({
|
||||
key,
|
||||
matcher: resolveValueExpectation(value),
|
||||
}));
|
||||
}
|
||||
7
src/server/checker/expect/keys.ts
Normal file
7
src/server/checker/expect/keys.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const MatcherKeys = ["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "regex"] as const;
|
||||
|
||||
export const MATCHER_KEY_SET: ReadonlySet<string> = new Set<string>(MatcherKeys);
|
||||
|
||||
export const ContentExtractorKeys = ["css", "json", "xpath"] as const;
|
||||
|
||||
export const CONTENT_EXTRACTOR_KEY_SET: ReadonlySet<string> = new Set<string>(ContentExtractorKeys);
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { ValueMatcherPrimitive } from "./types";
|
||||
|
||||
import { isValueMatcherObject } from "./matcher";
|
||||
|
||||
export function isValueMatcherPrimitive(value: unknown): value is ValueMatcherPrimitive {
|
||||
return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
||||
}
|
||||
|
||||
export function normalizeExpectMatchers(expect: Record<string, unknown>, keys: string[]): void {
|
||||
for (const key of keys) {
|
||||
if (key in expect) {
|
||||
expect[key] = normalizeValueMatcher(expect[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeValueMatcher(value: unknown): unknown {
|
||||
if (value === undefined) return undefined;
|
||||
if (isValueMatcherObject(value)) return value;
|
||||
if (isValueMatcherPrimitive(value)) return { equals: value };
|
||||
return value;
|
||||
}
|
||||
27
src/server/checker/expect/status.ts
Normal file
27
src/server/checker/expect/status.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { isNumber } from "es-toolkit";
|
||||
|
||||
import type { ExpectationResult } from "./types";
|
||||
|
||||
import { mismatchFailure } from "./failure";
|
||||
|
||||
export function checkStatusCode(statusCode: number, allowed: Array<number | string>): ExpectationResult {
|
||||
const matched = allowed.some((pattern) => {
|
||||
if (isNumber(pattern)) return statusCode === pattern;
|
||||
const base = parseInt(pattern[0]!, 10) * 100;
|
||||
return statusCode >= base && statusCode < base + 100;
|
||||
});
|
||||
|
||||
if (!matched) {
|
||||
return {
|
||||
failure: mismatchFailure(
|
||||
"status",
|
||||
"status",
|
||||
allowed,
|
||||
statusCode,
|
||||
`status ${statusCode} not in [${allowed.join(", ")}]`,
|
||||
),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
@@ -1,32 +1,75 @@
|
||||
import type { CheckFailure, JsonValue } from "../types";
|
||||
|
||||
export interface ContentCssRule extends ValueMatcher {
|
||||
export interface ContentCssExpectation {
|
||||
attr?: string;
|
||||
kind: "css";
|
||||
matcher: ValueExpectation;
|
||||
selector: string;
|
||||
}
|
||||
|
||||
export interface ContentJsonRule extends ValueMatcher {
|
||||
export type ContentExpectation =
|
||||
| ContentCssExpectation
|
||||
| ContentJsonExpectation
|
||||
| ContentValueExpectation
|
||||
| ContentXpathExpectation;
|
||||
|
||||
export type ContentExpectations = ContentExpectation[];
|
||||
|
||||
export interface ContentJsonExpectation {
|
||||
kind: "json";
|
||||
matcher: ValueExpectation;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export type ContentRule =
|
||||
| ValueMatcher
|
||||
| { css: ContentCssRule }
|
||||
| { json: ContentJsonRule }
|
||||
| { xpath: ContentXpathRule };
|
||||
export interface ContentValueExpectation {
|
||||
kind: "value";
|
||||
matcher: ValueExpectation;
|
||||
}
|
||||
|
||||
export type ContentRules = ContentRule[];
|
||||
|
||||
export interface ContentXpathRule extends ValueMatcher {
|
||||
export interface ContentXpathExpectation {
|
||||
kind: "xpath";
|
||||
matcher: ValueExpectation;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ExpectResult {
|
||||
export interface ExpectationResult {
|
||||
failure: CheckFailure | null;
|
||||
matched: boolean;
|
||||
}
|
||||
|
||||
export type KeyValueExpect = Record<string, JsonValue | ValueMatcher>;
|
||||
export interface KeyedExpectation {
|
||||
key: string;
|
||||
matcher: ValueExpectation;
|
||||
}
|
||||
|
||||
export type KeyedExpectations = KeyedExpectation[];
|
||||
|
||||
export interface RawContentCssExpectation extends ValueMatcher {
|
||||
attr?: string;
|
||||
selector: string;
|
||||
}
|
||||
|
||||
export type RawContentExpectation =
|
||||
| ValueMatcher
|
||||
| { css: RawContentCssExpectation }
|
||||
| { json: RawContentJsonExpectation }
|
||||
| { xpath: RawContentXpathExpectation };
|
||||
|
||||
export type RawContentExpectations = RawContentExpectation[];
|
||||
|
||||
export interface RawContentJsonExpectation extends ValueMatcher {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface RawContentXpathExpectation extends ValueMatcher {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export type RawKeyedExpectations = Record<string, RawValueExpectation>;
|
||||
|
||||
export type RawValueExpectation = ValueMatcher | ValueMatcherPrimitive;
|
||||
|
||||
export type ValueExpectation = ValueMatcher;
|
||||
|
||||
export interface ValueMatcher {
|
||||
contains?: string;
|
||||
@@ -40,6 +83,4 @@ export interface ValueMatcher {
|
||||
regex?: string;
|
||||
}
|
||||
|
||||
export type ValueMatcherInput = ValueMatcher | ValueMatcherPrimitive;
|
||||
|
||||
export type ValueMatcherPrimitive = boolean | null | number | string;
|
||||
|
||||
@@ -6,14 +6,9 @@ import type { ConfigValidationIssue } from "../schema/issues";
|
||||
import type { JsonValue } from "../types";
|
||||
|
||||
import { issue, joinPath } from "../schema/issues";
|
||||
import { isValueMatcherPrimitive } from "./normalize";
|
||||
import { CONTENT_EXTRACTOR_KEY_SET, MATCHER_KEY_SET } from "./keys";
|
||||
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);
|
||||
import { isValueMatcherPrimitive } from "./value";
|
||||
|
||||
export function isJsonValue(value: unknown): value is JsonValue {
|
||||
if (value === null) return true;
|
||||
@@ -28,45 +23,64 @@ export function isPlainRecord(value: unknown): value is Record<string, unknown>
|
||||
return isPlainObject(value);
|
||||
}
|
||||
|
||||
export function validateContentRules(rules: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
|
||||
if (!Array.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[] {
|
||||
export function validateJsonPath(path: string, expectationPath: string, targetName?: string): ConfigValidationIssue[] {
|
||||
if (!path.startsWith("$.") || path.length <= 2) {
|
||||
return [issue("invalid-jsonpath", joinPath(rulePath, "path"), '必须为以 "$." 开头的有效 JSONPath', targetName)];
|
||||
return [
|
||||
issue("invalid-jsonpath", joinPath(expectationPath, "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));
|
||||
issues.push(issue("invalid-jsonpath", joinPath(expectationPath, "path"), "包含空段", targetName));
|
||||
}
|
||||
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
|
||||
if (bracketMatch?.[1]!.trim() === "") {
|
||||
issues.push(issue("invalid-jsonpath", joinPath(rulePath, "path"), "数组访问缺少属性名", targetName));
|
||||
issues.push(issue("invalid-jsonpath", joinPath(expectationPath, "path"), "数组访问缺少属性名", targetName));
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function validateKeyValueExpect(value: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
|
||||
export function validateRawContentExpectations(
|
||||
expectations: unknown,
|
||||
path: string,
|
||||
targetName?: string,
|
||||
): ConfigValidationIssue[] {
|
||||
if (!Array.isArray(expectations)) return [issue("invalid-type", path, "必须为数组", targetName)];
|
||||
return expectations.flatMap((entry, index) => validateRawContentExpectation(entry, `${path}[${index}]`, targetName));
|
||||
}
|
||||
|
||||
export function validateRawKeyedExpectations(
|
||||
value: unknown,
|
||||
path: string,
|
||||
targetName?: string,
|
||||
options?: { caseInsensitive?: boolean },
|
||||
): ConfigValidationIssue[] {
|
||||
if (!isPlainRecord(value)) return [issue("invalid-type", path, "必须为对象", targetName)];
|
||||
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
if (options?.caseInsensitive) {
|
||||
const seen = new Map<string, string>();
|
||||
for (const key of Object.keys(value)) {
|
||||
const lower = key.toLowerCase();
|
||||
const prev = seen.get(lower);
|
||||
if (prev !== undefined) {
|
||||
issues.push(issue("duplicate-key", joinPath(path, key), `与 "${prev}" 大小写归一化后重复`, targetName));
|
||||
} else {
|
||||
seen.set(lower, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
issues.push(...validateRawValueExpectation(item, itemPath, targetName));
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function validateValueMatcher(
|
||||
export function validateRawValueExpectation(
|
||||
matcher: unknown,
|
||||
path: string,
|
||||
targetName?: string,
|
||||
@@ -74,6 +88,16 @@ export function validateValueMatcher(
|
||||
): ConfigValidationIssue[] {
|
||||
const requireAtLeastOne = options.requireAtLeastOne ?? true;
|
||||
if (isValueMatcherPrimitive(matcher)) return [];
|
||||
if (Array.isArray(matcher)) {
|
||||
return [
|
||||
issue(
|
||||
"invalid-type",
|
||||
path,
|
||||
"必须为 primitive 原始值或 matcher 对象;如需数组 equals 匹配应写成 {equals: [...]}",
|
||||
targetName,
|
||||
),
|
||||
];
|
||||
}
|
||||
if (!isPlainRecord(matcher))
|
||||
return [issue("invalid-type", path, "必须为 primitive 原始值或 matcher 对象", targetName)];
|
||||
|
||||
@@ -100,82 +124,46 @@ export function validateValueMatcher(
|
||||
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)];
|
||||
function validateCssExpectation(expectation: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
|
||||
if (!isPlainRecord(expectation)) return [issue("invalid-type", path, "必须为对象", targetName)];
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
|
||||
if (!isString(rule["selector"]) || rule["selector"].trim() === "") {
|
||||
if (!isString(expectation["selector"]) || expectation["selector"].trim() === "") {
|
||||
issues.push(issue("invalid-type", joinPath(path, "selector"), "必须为非空字符串", targetName));
|
||||
}
|
||||
if ("attr" in rule && !isString(rule["attr"])) {
|
||||
if ("attr" in expectation && !isString(expectation["attr"])) {
|
||||
issues.push(issue("invalid-type", joinPath(path, "attr"), "必须为字符串", targetName));
|
||||
}
|
||||
issues.push(...validateExtractorMatcher(rule, new Set(["attr", "selector"]), path, targetName));
|
||||
issues.push(...validateExtractorMatcher(expectation, new Set(["attr", "selector"]), path, targetName));
|
||||
return issues;
|
||||
}
|
||||
|
||||
function validateExtractorMatcher(
|
||||
rule: Record<string, unknown>,
|
||||
expectation: 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)) {
|
||||
for (const [key, value] of Object.entries(expectation)) {
|
||||
if (allowedFields.has(key)) continue;
|
||||
matcher[key] = value;
|
||||
}
|
||||
issues.push(...validateValueMatcher(matcher, path, targetName, { requireAtLeastOne: false }));
|
||||
issues.push(...validateRawValueExpectation(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)];
|
||||
function validateJsonExpectation(expectation: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
|
||||
if (!isPlainRecord(expectation)) return [issue("invalid-type", path, "必须为对象", targetName)];
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
|
||||
if (!isString(rule["path"])) {
|
||||
if (!isString(expectation["path"])) {
|
||||
issues.push(issue("invalid-type", joinPath(path, "path"), "必须为字符串", targetName));
|
||||
} else {
|
||||
issues.push(...validateJsonPath(rule["path"], path, targetName));
|
||||
issues.push(...validateJsonPath(expectation["path"], path, targetName));
|
||||
}
|
||||
issues.push(...validateExtractorMatcher(rule, new Set(["path"]), path, targetName));
|
||||
issues.push(...validateExtractorMatcher(expectation, new Set(["path"]), path, targetName));
|
||||
return issues;
|
||||
}
|
||||
|
||||
@@ -208,20 +196,62 @@ function validateMatcherValue(key: string, value: unknown, path: string, targetN
|
||||
}
|
||||
}
|
||||
|
||||
function validateXpathRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
|
||||
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
|
||||
function validateRawContentExpectation(
|
||||
expectation: unknown,
|
||||
path: string,
|
||||
targetName?: string,
|
||||
): ConfigValidationIssue[] {
|
||||
if (!isPlainRecord(expectation)) return [issue("invalid-type", path, "必须为对象", targetName)];
|
||||
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const extractors = Object.keys(expectation).filter((key) => CONTENT_EXTRACTOR_KEY_SET.has(key));
|
||||
const directMatchers = Object.keys(expectation).filter((key) => MATCHER_KEY_SET.has(key));
|
||||
|
||||
for (const key of Object.keys(expectation)) {
|
||||
if (!MATCHER_KEY_SET.has(key) && !CONTENT_EXTRACTOR_KEY_SET.has(key)) {
|
||||
issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName));
|
||||
}
|
||||
}
|
||||
|
||||
if (extractors.length > 1) {
|
||||
issues.push(
|
||||
issue("multiple-content-expectations", path, "一条 expectation 不能同时包含多个 extractor", targetName),
|
||||
);
|
||||
}
|
||||
if (extractors.length === 1 && directMatchers.length > 0) {
|
||||
issues.push(issue("invalid-content-expectation", path, "直接 matcher 不能与 extractor 混用", targetName));
|
||||
}
|
||||
if (issues.length > 0) return issues;
|
||||
|
||||
if (extractors.length === 0) return validateRawValueExpectation(expectation, path, targetName);
|
||||
|
||||
const extractor = extractors[0]!;
|
||||
switch (extractor) {
|
||||
case "css":
|
||||
return validateCssExpectation(expectation["css"], joinPath(path, "css"), targetName);
|
||||
case "json":
|
||||
return validateJsonExpectation(expectation["json"], joinPath(path, "json"), targetName);
|
||||
case "xpath":
|
||||
return validateXpathExpectation(expectation["xpath"], joinPath(path, "xpath"), targetName);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function validateXpathExpectation(expectation: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
|
||||
if (!isPlainRecord(expectation)) return [issue("invalid-type", path, "必须为对象", targetName)];
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
|
||||
if (!isString(rule["path"]) || rule["path"].trim() === "") {
|
||||
if (!isString(expectation["path"]) || expectation["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);
|
||||
xpath.select(expectation["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));
|
||||
issues.push(...validateExtractorMatcher(expectation, new Set(["path"]), path, targetName));
|
||||
return issues;
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
import { isEmptyObject, isEqual, isNil, isPlainObject } from "es-toolkit";
|
||||
|
||||
import type { CheckFailure, JsonValue } from "../types";
|
||||
import type { ExpectResult, ValueMatcher, ValueMatcherInput } from "./types";
|
||||
import type { CheckFailure } from "../types";
|
||||
import type { ExpectationResult, RawValueExpectation, ValueExpectation, ValueMatcher } from "./types";
|
||||
|
||||
import { mismatchFailure } from "./failure";
|
||||
import { MATCHER_KEY_SET } from "./keys";
|
||||
|
||||
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(
|
||||
export function applyValueMatcher(
|
||||
actual: unknown,
|
||||
matcher: ValueMatcher,
|
||||
options: { stringifyNonString?: boolean } = {},
|
||||
@@ -57,28 +54,20 @@ export function applyMatcher(
|
||||
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(
|
||||
export function checkValueExpectation(
|
||||
actual: unknown,
|
||||
matcher: undefined | ValueMatcherInput,
|
||||
expectation: undefined | ValueExpectation,
|
||||
options: { message?: string; path: string; phase: CheckFailure["phase"]; stringifyNonString?: boolean },
|
||||
): ExpectResult {
|
||||
if (matcher === undefined) return { failure: null, matched: true };
|
||||
const normalized = isValueMatcherObject(matcher) ? matcher : { equals: matcher };
|
||||
if (applyMatcher(actual, normalized, { stringifyNonString: options.stringifyNonString })) {
|
||||
): ExpectationResult {
|
||||
if (expectation === undefined) return { failure: null, matched: true };
|
||||
if (applyValueMatcher(actual, expectation, { stringifyNonString: options.stringifyNonString })) {
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
return {
|
||||
failure: mismatchFailure(
|
||||
options.phase,
|
||||
options.path,
|
||||
normalized,
|
||||
displayValueExpectation(expectation),
|
||||
actual,
|
||||
options.message ?? `${options.path} mismatch`,
|
||||
),
|
||||
@@ -86,6 +75,12 @@ export function checkValueMatcher(
|
||||
};
|
||||
}
|
||||
|
||||
export function displayValueExpectation(expectation: ValueExpectation): unknown {
|
||||
const entries = Object.entries(expectation).filter(([, value]) => value !== undefined);
|
||||
if (entries.length === 1 && entries[0]?.[0] === "equals") return entries[0][1];
|
||||
return expectation;
|
||||
}
|
||||
|
||||
export function evaluateJsonPath(json: unknown, path: string): unknown {
|
||||
if (!path.startsWith("$.")) return undefined;
|
||||
|
||||
@@ -113,6 +108,18 @@ export function isValueMatcherObject(value: unknown): value is ValueMatcher {
|
||||
return isPlainObject(value) && Object.keys(value).some((key) => MATCHER_KEY_SET.has(key));
|
||||
}
|
||||
|
||||
export function isValueMatcherPrimitive(value: unknown): value is boolean | null | number | string {
|
||||
return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
||||
}
|
||||
|
||||
export function resolveValueExpectation(raw: RawValueExpectation): ValueExpectation;
|
||||
export function resolveValueExpectation(raw: RawValueExpectation | undefined): undefined | ValueExpectation;
|
||||
export function resolveValueExpectation(raw: RawValueExpectation | undefined): undefined | ValueExpectation {
|
||||
if (raw === undefined) return undefined;
|
||||
if (isValueMatcherObject(raw)) return raw;
|
||||
return { equals: raw };
|
||||
}
|
||||
|
||||
function compareNumber(
|
||||
actual: unknown,
|
||||
expected: number,
|
||||
@@ -3,11 +3,16 @@ import { resolve } from "node:path";
|
||||
|
||||
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type { CommandExpectConfig, CommandTargetConfig, ResolvedCommandTarget } from "./types";
|
||||
import type {
|
||||
CommandTargetConfig,
|
||||
RawCommandExpectConfig,
|
||||
ResolvedCommandExpectConfig,
|
||||
ResolvedCommandTarget,
|
||||
} from "./types";
|
||||
|
||||
import { checkContentRules } from "../../expect/content";
|
||||
import { checkContentExpectations, resolveContentExpectations } from "../../expect/content";
|
||||
import { errorFailure } from "../../expect/failure";
|
||||
import { checkValueMatcher } from "../../expect/matcher";
|
||||
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value";
|
||||
import { parseSize } from "../../utils";
|
||||
import { checkExitCode } from "./expect";
|
||||
import { commandCheckerSchemas } from "./schema";
|
||||
@@ -138,7 +143,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
};
|
||||
}
|
||||
|
||||
const durationResult = checkValueMatcher(durationMs, t.expect?.durationMs, {
|
||||
const durationResult = checkValueExpectation(durationMs, t.expect?.durationMs, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
phase: "duration",
|
||||
@@ -156,7 +161,10 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
}
|
||||
|
||||
if (t.expect?.stdout && t.expect.stdout.length > 0) {
|
||||
const stdoutResult = checkContentRules(outputResult.stdout, t.expect.stdout, { path: "stdout", phase: "stdout" });
|
||||
const stdoutResult = checkContentExpectations(outputResult.stdout, t.expect.stdout, {
|
||||
path: "stdout",
|
||||
phase: "stdout",
|
||||
});
|
||||
if (!stdoutResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
@@ -171,7 +179,10 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
}
|
||||
|
||||
if (t.expect?.stderr && t.expect.stderr.length > 0) {
|
||||
const stderrResult = checkContentRules(outputResult.stderr, t.expect.stderr, { path: "stderr", phase: "stderr" });
|
||||
const stderrResult = checkContentExpectations(outputResult.stderr, t.expect.stderr, {
|
||||
path: "stderr",
|
||||
phase: "stderr",
|
||||
});
|
||||
if (!stderrResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
@@ -207,6 +218,16 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
|
||||
const env = { ...process.env, ...(t.cmd.env ?? {}) } as Record<string, string>;
|
||||
|
||||
const rawExpect = target.expect as RawCommandExpectConfig | undefined;
|
||||
const resolvedExpect: ResolvedCommandExpectConfig = rawExpect
|
||||
? {
|
||||
durationMs: resolveValueExpectation(rawExpect.durationMs),
|
||||
exitCode: rawExpect.exitCode ?? [0],
|
||||
stderr: resolveContentExpectations(rawExpect.stderr),
|
||||
stdout: resolveContentExpectations(rawExpect.stdout),
|
||||
}
|
||||
: { exitCode: [0] };
|
||||
|
||||
return {
|
||||
cmd: {
|
||||
args: t.cmd.args ?? [],
|
||||
@@ -216,11 +237,12 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
maxOutputBytes,
|
||||
},
|
||||
description: null,
|
||||
expect: target.expect as CommandExpectConfig | undefined,
|
||||
expect: resolvedExpect,
|
||||
group: target.group ?? "default",
|
||||
id: t.id,
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
name: t.name ?? null,
|
||||
rawExpect,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
type: "cmd",
|
||||
} satisfies ResolvedCommandTarget;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ExpectResult } from "../../expect/types";
|
||||
import type { ExpectationResult } from "../../expect/types";
|
||||
|
||||
import { mismatchFailure } from "../../expect/failure";
|
||||
|
||||
export function checkExitCode(exitCode: number, allowed: number[]): ExpectResult {
|
||||
export function checkExitCode(exitCode: number, allowed: number[]): ExpectationResult {
|
||||
if (!allowed.includes(exitCode)) {
|
||||
return {
|
||||
failure: mismatchFailure(
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Type } from "@sinclair/typebox";
|
||||
import type { CheckerSchemas } from "../types";
|
||||
|
||||
import {
|
||||
createContentRulesSchema,
|
||||
createValueMatcherSchema,
|
||||
createRawContentExpectationsSchema,
|
||||
createRawValueExpectationSchema,
|
||||
sizeSchema,
|
||||
stringMapSchema,
|
||||
} from "../../schema/fragments";
|
||||
@@ -29,10 +29,10 @@ export const commandCheckerSchemas: CheckerSchemas = {
|
||||
),
|
||||
expect: Type.Object(
|
||||
{
|
||||
durationMs: Type.Optional(createValueMatcherSchema()),
|
||||
durationMs: Type.Optional(createRawValueExpectationSchema()),
|
||||
exitCode: Type.Optional(Type.Array(Type.Integer())),
|
||||
stderr: Type.Optional(createContentRulesSchema()),
|
||||
stdout: Type.Optional(createContentRulesSchema()),
|
||||
stderr: Type.Optional(createRawContentExpectationsSchema()),
|
||||
stdout: Type.Optional(createRawContentExpectationsSchema()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { ContentRules, ValueMatcherInput } from "../../expect/types";
|
||||
import type {
|
||||
ContentExpectations,
|
||||
RawContentExpectations,
|
||||
RawValueExpectation,
|
||||
ValueExpectation,
|
||||
} from "../../expect/types";
|
||||
import type { ResolvedTargetBase } from "../../types";
|
||||
|
||||
export interface CommandDefaultsConfig {
|
||||
@@ -6,13 +11,6 @@ export interface CommandDefaultsConfig {
|
||||
maxOutputBytes?: string;
|
||||
}
|
||||
|
||||
export interface CommandExpectConfig {
|
||||
durationMs?: ValueMatcherInput;
|
||||
exitCode?: number[];
|
||||
stderr?: ContentRules;
|
||||
stdout?: ContentRules;
|
||||
}
|
||||
|
||||
export interface CommandTargetConfig {
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
@@ -21,6 +19,13 @@ export interface CommandTargetConfig {
|
||||
maxOutputBytes?: string;
|
||||
}
|
||||
|
||||
export interface RawCommandExpectConfig {
|
||||
durationMs?: RawValueExpectation;
|
||||
exitCode?: number[];
|
||||
stderr?: RawContentExpectations;
|
||||
stdout?: RawContentExpectations;
|
||||
}
|
||||
|
||||
export interface ResolvedCommandConfig {
|
||||
args: string[];
|
||||
cwd: string;
|
||||
@@ -29,12 +34,20 @@ export interface ResolvedCommandConfig {
|
||||
maxOutputBytes: number;
|
||||
}
|
||||
|
||||
export interface ResolvedCommandExpectConfig {
|
||||
durationMs?: ValueExpectation;
|
||||
exitCode: number[];
|
||||
stderr?: ContentExpectations;
|
||||
stdout?: ContentExpectations;
|
||||
}
|
||||
|
||||
export interface ResolvedCommandTarget extends ResolvedTargetBase {
|
||||
cmd: ResolvedCommandConfig;
|
||||
expect?: CommandExpectConfig;
|
||||
expect?: ResolvedCommandExpectConfig;
|
||||
group: string;
|
||||
intervalMs: number;
|
||||
name: null | string;
|
||||
rawExpect?: RawCommandExpectConfig;
|
||||
timeoutMs: number;
|
||||
type: "cmd";
|
||||
}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { isNumber, isPlainObject, isString } from "es-toolkit";
|
||||
import { isNumber, isString } from "es-toolkit";
|
||||
|
||||
import type { ConfigValidationIssue } from "../../schema/issues";
|
||||
import type { CheckerValidationInput } from "../types";
|
||||
|
||||
import { normalizeExpectMatchers } from "../../expect/normalize";
|
||||
import { validateContentRules, validateValueMatcher } from "../../expect/validate-matcher";
|
||||
import { isPlainRecord, validateRawContentExpectations, validateRawValueExpectation } from "../../expect/validate";
|
||||
import { issue, joinPath } from "../../schema/issues";
|
||||
import { parseSize } from "../../utils";
|
||||
|
||||
export function validateCommandConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const defaults =
|
||||
isPlainObject(input.defaults) && isPlainObject(input.defaults["cmd"]) ? input.defaults["cmd"] : undefined;
|
||||
isPlainRecord(input.defaults) && isPlainRecord(input.defaults["cmd"]) ? input.defaults["cmd"] : undefined;
|
||||
|
||||
if (isSizeInput(defaults?.["maxOutputBytes"])) {
|
||||
issues.push(...validateSizeValue(defaults["maxOutputBytes"], "defaults.cmd.maxOutputBytes"));
|
||||
@@ -19,7 +18,7 @@ export function validateCommandConfig(input: CheckerValidationInput): ConfigVali
|
||||
|
||||
for (let i = 0; i < input.targets.length; i++) {
|
||||
const target = input.targets[i] as unknown;
|
||||
if (!isPlainObject(target)) continue;
|
||||
if (!isPlainRecord(target)) continue;
|
||||
if (target["type"] !== "cmd") continue;
|
||||
issues.push(...validateCommandTarget(target, `targets[${i}]`));
|
||||
}
|
||||
@@ -39,20 +38,18 @@ function isSizeInput(value: unknown): value is number | string {
|
||||
function validateCommandExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||
const targetName = getTargetName(target);
|
||||
const expect = target["expect"];
|
||||
if (expect === undefined || expect === null || !isPlainObject(expect)) return [];
|
||||
if (expect === undefined || expect === null || !isPlainRecord(expect)) return [];
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const expectPath = joinPath(path, "expect");
|
||||
|
||||
normalizeExpectMatchers(expect, ["durationMs"]);
|
||||
|
||||
if (expect["stdout"] !== undefined) {
|
||||
issues.push(...validateContentRules(expect["stdout"], joinPath(expectPath, "stdout"), targetName));
|
||||
issues.push(...validateRawContentExpectations(expect["stdout"], joinPath(expectPath, "stdout"), targetName));
|
||||
}
|
||||
if (expect["stderr"] !== undefined) {
|
||||
issues.push(...validateContentRules(expect["stderr"], joinPath(expectPath, "stderr"), targetName));
|
||||
issues.push(...validateRawContentExpectations(expect["stderr"], joinPath(expectPath, "stderr"), targetName));
|
||||
}
|
||||
if (expect["durationMs"] !== undefined) {
|
||||
issues.push(...validateValueMatcher(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
|
||||
issues.push(...validateRawValueExpectation(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
@@ -61,7 +58,7 @@ function validateCommandTarget(target: Record<string, unknown>, path: string): C
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const targetName = getTargetName(target);
|
||||
const cmd = target["cmd"];
|
||||
if (!isPlainObject(cmd)) {
|
||||
if (!isPlainRecord(cmd)) {
|
||||
issues.push(issue("required", joinPath(path, "cmd"), "缺少 cmd.exec 字段", targetName));
|
||||
issues.push(...validateCommandExpect(target, path));
|
||||
return issues;
|
||||
|
||||
@@ -3,11 +3,12 @@ import { isError } from "es-toolkit";
|
||||
|
||||
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type { DbExpectConfig, DbTargetConfig, ResolvedDbTarget } from "./types";
|
||||
import type { DbTargetConfig, RawDbExpectConfig, ResolvedDbExpectConfig, ResolvedDbTarget } from "./types";
|
||||
|
||||
import { checkContentRules } from "../../expect/content";
|
||||
import { checkContentExpectations, resolveContentExpectations } from "../../expect/content";
|
||||
import { errorFailure } from "../../expect/failure";
|
||||
import { checkValueMatcher } from "../../expect/matcher";
|
||||
import { resolveKeyedExpectations } from "../../expect/keyed";
|
||||
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value";
|
||||
import { checkRowCount, checkRows } from "./expect";
|
||||
import { dbCheckerSchemas } from "./schema";
|
||||
import { validateDbConfig } from "./validate";
|
||||
@@ -77,7 +78,7 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
rowCount: null,
|
||||
rowsPreview: null,
|
||||
};
|
||||
const durationResult = checkValueMatcher(durationMs, t.expect?.durationMs, {
|
||||
const durationResult = checkValueExpectation(durationMs, t.expect?.durationMs, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
phase: "duration",
|
||||
@@ -138,7 +139,7 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
};
|
||||
}
|
||||
|
||||
const durationResult = checkValueMatcher(durationMs, t.expect?.durationMs, {
|
||||
const durationResult = checkValueExpectation(durationMs, t.expect?.durationMs, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
phase: "duration",
|
||||
@@ -186,7 +187,10 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
}
|
||||
|
||||
if (t.expect?.result && t.expect.result.length > 0) {
|
||||
const resultCheck = checkContentRules({ rowCount, rows }, t.expect.result, { path: "result", phase: "result" });
|
||||
const resultCheck = checkContentExpectations({ rowCount, rows }, t.expect.result, {
|
||||
path: "result",
|
||||
phase: "result",
|
||||
});
|
||||
if (!resultCheck.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
@@ -223,17 +227,28 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedDbTarget {
|
||||
const t = target as RawTargetConfig & { db: DbTargetConfig; type: "db" };
|
||||
|
||||
const rawExpect = target.expect as RawDbExpectConfig | undefined;
|
||||
const resolvedExpect: ResolvedDbExpectConfig | undefined = rawExpect
|
||||
? {
|
||||
durationMs: resolveValueExpectation(rawExpect.durationMs),
|
||||
result: resolveContentExpectations(rawExpect.result),
|
||||
rowCount: resolveValueExpectation(rawExpect.rowCount),
|
||||
rows: rawExpect.rows?.map((r) => resolveKeyedExpectations(r)!),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
db: {
|
||||
query: t.db.query,
|
||||
url: t.db.url,
|
||||
},
|
||||
description: null,
|
||||
expect: target.expect as DbExpectConfig | undefined,
|
||||
expect: resolvedExpect,
|
||||
group: target.group ?? "default",
|
||||
id: t.id,
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
name: t.name ?? null,
|
||||
rawExpect,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
type: "db",
|
||||
} satisfies ResolvedDbTarget;
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { isPlainObject } from "es-toolkit";
|
||||
|
||||
import type { ExpectResult, KeyValueExpect, ValueMatcherInput } from "../../expect/types";
|
||||
import type { ExpectationResult, KeyedExpectations, ValueExpectation } from "../../expect/types";
|
||||
|
||||
import { mismatchFailure } from "../../expect/failure";
|
||||
import { checkKeyValueExpect } from "../../expect/key-value";
|
||||
import { checkValueMatcher } from "../../expect/matcher";
|
||||
import { checkKeyedExpectations } from "../../expect/keyed";
|
||||
import { checkValueExpectation } from "../../expect/value";
|
||||
|
||||
export function checkRowCount(actual: number, matcher: ValueMatcherInput): ExpectResult {
|
||||
return checkValueMatcher(actual, matcher, {
|
||||
export function checkRowCount(actual: number, matcher: ValueExpectation): ExpectationResult {
|
||||
return checkValueExpectation(actual, matcher, {
|
||||
message: `rowCount ${actual} 不满足条件`,
|
||||
path: "rowCount",
|
||||
phase: "rowCount",
|
||||
});
|
||||
}
|
||||
|
||||
export function checkRows(rows: unknown, rules: KeyValueExpect[]): ExpectResult {
|
||||
export function checkRows(rows: unknown, rules: KeyedExpectations[]): ExpectationResult {
|
||||
if (!Array.isArray(rows)) {
|
||||
return {
|
||||
failure: mismatchFailure("row", "rows", rules, rows, "查询结果不是数组"),
|
||||
@@ -39,7 +39,7 @@ export function checkRows(rows: unknown, rules: KeyValueExpect[]): ExpectResult
|
||||
};
|
||||
}
|
||||
|
||||
const result = checkKeyValueExpect(row, rule, { path: `rows[${i}]`, phase: "row" });
|
||||
const result = checkKeyedExpectations(row, rule, { path: `rows[${i}]`, phase: "row" });
|
||||
if (!result.matched) return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@ import { Type } from "@sinclair/typebox";
|
||||
|
||||
import type { CheckerSchemas } from "../types";
|
||||
|
||||
import { createContentRulesSchema, createKeyValueExpectSchema, createValueMatcherSchema } from "../../schema/fragments";
|
||||
import {
|
||||
createRawContentExpectationsSchema,
|
||||
createRawKeyedExpectationsSchema,
|
||||
createRawValueExpectationSchema,
|
||||
} from "../../schema/fragments";
|
||||
|
||||
export const dbCheckerSchemas: CheckerSchemas = {
|
||||
config: Type.Object(
|
||||
@@ -19,10 +23,10 @@ export const dbCheckerSchemas: CheckerSchemas = {
|
||||
defaults: Type.Object({}, { additionalProperties: false }),
|
||||
expect: Type.Object(
|
||||
{
|
||||
durationMs: Type.Optional(createValueMatcherSchema()),
|
||||
result: Type.Optional(createContentRulesSchema()),
|
||||
rowCount: Type.Optional(createValueMatcherSchema()),
|
||||
rows: Type.Optional(Type.Array(createKeyValueExpectSchema())),
|
||||
durationMs: Type.Optional(createRawValueExpectationSchema()),
|
||||
result: Type.Optional(createRawContentExpectationsSchema()),
|
||||
rowCount: Type.Optional(createRawValueExpectationSchema()),
|
||||
rows: Type.Optional(Type.Array(createRawKeyedExpectationsSchema())),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
|
||||
@@ -1,29 +1,44 @@
|
||||
import type { ContentRules, KeyValueExpect, ValueMatcherInput } from "../../expect/types";
|
||||
import type {
|
||||
ContentExpectations,
|
||||
KeyedExpectations,
|
||||
RawContentExpectations,
|
||||
RawKeyedExpectations,
|
||||
RawValueExpectation,
|
||||
ValueExpectation,
|
||||
} from "../../expect/types";
|
||||
import type { ResolvedTargetBase } from "../../types";
|
||||
|
||||
export interface DbExpectConfig {
|
||||
durationMs?: ValueMatcherInput;
|
||||
result?: ContentRules;
|
||||
rowCount?: ValueMatcherInput;
|
||||
rows?: KeyValueExpect[];
|
||||
}
|
||||
|
||||
export interface DbTargetConfig {
|
||||
query?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface RawDbExpectConfig {
|
||||
durationMs?: RawValueExpectation;
|
||||
result?: RawContentExpectations;
|
||||
rowCount?: RawValueExpectation;
|
||||
rows?: RawKeyedExpectations[];
|
||||
}
|
||||
|
||||
export interface ResolvedDbConfig {
|
||||
query?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ResolvedDbExpectConfig {
|
||||
durationMs?: ValueExpectation;
|
||||
result?: ContentExpectations;
|
||||
rowCount?: ValueExpectation;
|
||||
rows?: KeyedExpectations[];
|
||||
}
|
||||
|
||||
export interface ResolvedDbTarget extends ResolvedTargetBase {
|
||||
db: ResolvedDbConfig;
|
||||
expect?: DbExpectConfig;
|
||||
expect?: ResolvedDbExpectConfig;
|
||||
group: string;
|
||||
intervalMs: number;
|
||||
name: null | string;
|
||||
rawExpect?: RawDbExpectConfig;
|
||||
timeoutMs: number;
|
||||
type: "db";
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { isPlainObject, isString } from "es-toolkit";
|
||||
import { isString } from "es-toolkit";
|
||||
|
||||
import type { ConfigValidationIssue } from "../../schema/issues";
|
||||
import type { CheckerValidationInput } from "../types";
|
||||
|
||||
import { normalizeExpectMatchers } from "../../expect/normalize";
|
||||
import { validateContentRules, validateKeyValueExpect, validateValueMatcher } from "../../expect/validate-matcher";
|
||||
import {
|
||||
isPlainRecord,
|
||||
validateRawContentExpectations,
|
||||
validateRawKeyedExpectations,
|
||||
validateRawValueExpectation,
|
||||
} from "../../expect/validate";
|
||||
import { issue, joinPath } from "../../schema/issues";
|
||||
|
||||
export function validateDbConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
|
||||
@@ -12,7 +16,7 @@ export function validateDbConfig(input: CheckerValidationInput): ConfigValidatio
|
||||
|
||||
for (let i = 0; i < input.targets.length; i++) {
|
||||
const target = input.targets[i] as unknown;
|
||||
if (!isPlainObject(target)) continue;
|
||||
if (!isPlainRecord(target)) continue;
|
||||
if (target["type"] !== "db") continue;
|
||||
issues.push(...validateDbTarget(target, `targets[${i}]`));
|
||||
}
|
||||
@@ -24,11 +28,11 @@ function collectRowExpects(rows: unknown[], path: string, targetName?: string):
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]!;
|
||||
if (!isPlainObject(row)) {
|
||||
if (!isPlainRecord(row)) {
|
||||
issues.push(issue("invalid-type", `${path}[${i}]`, "必须为对象", targetName));
|
||||
continue;
|
||||
}
|
||||
issues.push(...validateKeyValueExpect(row, `${path}[${i}]`, targetName));
|
||||
issues.push(...validateRawKeyedExpectations(row, `${path}[${i}]`, targetName));
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
@@ -41,18 +45,16 @@ function getTargetName(target: Record<string, unknown>): string | undefined {
|
||||
function validateDbExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||
const targetName = getTargetName(target);
|
||||
const expect = target["expect"];
|
||||
if (expect === undefined || expect === null || !isPlainObject(expect)) return [];
|
||||
if (expect === undefined || expect === null || !isPlainRecord(expect)) return [];
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const expectPath = joinPath(path, "expect");
|
||||
|
||||
normalizeExpectMatchers(expect, ["durationMs", "rowCount"]);
|
||||
|
||||
if (expect["durationMs"] !== undefined) {
|
||||
issues.push(...validateValueMatcher(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
|
||||
issues.push(...validateRawValueExpectation(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
|
||||
}
|
||||
|
||||
if (expect["rowCount"] !== undefined) {
|
||||
issues.push(...validateValueMatcher(expect["rowCount"], joinPath(expectPath, "rowCount"), targetName));
|
||||
issues.push(...validateRawValueExpectation(expect["rowCount"], joinPath(expectPath, "rowCount"), targetName));
|
||||
}
|
||||
|
||||
if (expect["rows"] !== undefined) {
|
||||
@@ -64,10 +66,9 @@ function validateDbExpect(target: Record<string, unknown>, path: string): Config
|
||||
}
|
||||
|
||||
if (expect["result"] !== undefined) {
|
||||
issues.push(...validateContentRules(expect["result"], joinPath(expectPath, "result"), targetName));
|
||||
issues.push(...validateRawContentExpectations(expect["result"], joinPath(expectPath, "result"), targetName));
|
||||
}
|
||||
|
||||
// 检查未知字段
|
||||
const allowedKeys = new Set(["durationMs", "result", "rowCount", "rows"]);
|
||||
for (const key of Object.keys(expect)) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
@@ -83,18 +84,16 @@ function validateDbTarget(target: Record<string, unknown>, path: string): Config
|
||||
const targetName = getTargetName(target);
|
||||
const db = target["db"];
|
||||
|
||||
if (!isPlainObject(db)) {
|
||||
if (!isPlainRecord(db)) {
|
||||
issues.push(issue("required", joinPath(path, "db"), "缺少 db.url 字段", targetName));
|
||||
issues.push(...validateDbExpect(target, path));
|
||||
return issues;
|
||||
}
|
||||
|
||||
// url 必填
|
||||
if (!isString(db["url"]) || db["url"].trim() === "") {
|
||||
issues.push(issue("required", joinPath(joinPath(path, "db"), "url"), "缺少 db.url 字段", targetName));
|
||||
}
|
||||
|
||||
// query 可选但不能为空字符串
|
||||
if (db["query"] !== undefined) {
|
||||
if (!isString(db["query"])) {
|
||||
issues.push(issue("invalid-type", joinPath(joinPath(path, "db"), "query"), "必须为字符串", targetName));
|
||||
@@ -110,7 +109,6 @@ function validateDbTarget(target: Record<string, unknown>, path: string): Config
|
||||
}
|
||||
}
|
||||
|
||||
// 检查未知字段
|
||||
const allowedDbKeys = new Set(["query", "url"]);
|
||||
for (const key of Object.keys(db)) {
|
||||
if (!allowedDbKeys.has(key)) {
|
||||
|
||||
@@ -2,18 +2,19 @@ import { isError } from "es-toolkit";
|
||||
|
||||
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type { HttpExpectConfig, HttpTargetConfig, ResolvedHttpTarget } from "./types";
|
||||
import type { HttpTargetConfig, RawHttpExpectConfig, ResolvedHttpExpectConfig, ResolvedHttpTarget } from "./types";
|
||||
|
||||
import { checkContentRules } from "../../expect/content";
|
||||
import { checkContentExpectations, resolveContentExpectations } from "../../expect/content";
|
||||
import { errorFailure, mismatchFailure } from "../../expect/failure";
|
||||
import { checkValueMatcher, isValueMatcherObject } from "../../expect/matcher";
|
||||
import { checkHeaderExpectations } from "../../expect/headers";
|
||||
import { resolveKeyedExpectations } from "../../expect/keyed";
|
||||
import { checkStatusCode } from "../../expect/status";
|
||||
import { checkValueExpectation, displayValueExpectation, resolveValueExpectation } from "../../expect/value";
|
||||
import { parseSize } from "../../utils";
|
||||
import { checkHeaders, checkStatus } from "./expect";
|
||||
import { httpCheckerSchemas } from "./schema";
|
||||
import { validateHttpConfig } from "./validate";
|
||||
|
||||
const CHARSET_RE = /charset="?([^";\s]+)"?/i;
|
||||
const BODY_PREVIEW_BYTES = 1024;
|
||||
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
||||
const SENSITIVE_HEADERS = new Set(["authorization", "cookie"]);
|
||||
|
||||
@@ -46,27 +47,11 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
||||
|
||||
const statusCode = response.status;
|
||||
const responseHeaders = truncateHeaders(Object.fromEntries(response.headers));
|
||||
const hasBodyRules = !!(expect?.body && expect.body.length > 0);
|
||||
const bodyReadResult = await readBodyStream(
|
||||
response,
|
||||
hasBodyRules ? t.http.maxBodyBytes : BODY_PREVIEW_BYTES,
|
||||
!hasBodyRules,
|
||||
);
|
||||
let bodyPreview: null | string = null;
|
||||
let bodyText: null | string = null;
|
||||
let bodyDecodeFailure: CheckResult["failure"] = null;
|
||||
const hasBodyExpectations = !!(expect?.body && expect.body.length > 0);
|
||||
|
||||
if (bodyReadResult.data.byteLength > 0) {
|
||||
const decodeResult = decodeBody(bodyReadResult.data, response.headers);
|
||||
if (decodeResult.ok) {
|
||||
bodyText = decodeResult.text;
|
||||
bodyPreview = truncateBodyPreview(decodeResult.text);
|
||||
} else {
|
||||
bodyDecodeFailure = decodeResult.failure;
|
||||
}
|
||||
}
|
||||
|
||||
const statusResult = checkStatus(statusCode, expect?.status ?? [200]);
|
||||
const statusResult = checkStatusCode(statusCode, expect?.status ?? [200]);
|
||||
if (!statusResult.matched) {
|
||||
return makeResult(
|
||||
t,
|
||||
@@ -79,7 +64,7 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
||||
);
|
||||
}
|
||||
|
||||
const headersResult = checkHeaders(Object.fromEntries(response.headers), expect?.headers);
|
||||
const headersResult = checkHeaderExpectations(Object.fromEntries(response.headers), expect?.headers);
|
||||
if (!headersResult.matched) {
|
||||
return makeResult(
|
||||
t,
|
||||
@@ -92,7 +77,7 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
||||
);
|
||||
}
|
||||
|
||||
const earlyTimeout = hasBodyRules ? checkEarlyTimeout(start, expect?.durationMs) : null;
|
||||
const earlyTimeout = hasBodyExpectations ? checkEarlyTimeout(start, expect?.durationMs) : null;
|
||||
if (earlyTimeout) {
|
||||
return makeResult(
|
||||
t,
|
||||
@@ -105,32 +90,45 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
||||
);
|
||||
}
|
||||
|
||||
if (!bodyReadResult.ok) {
|
||||
return makeResult(
|
||||
t,
|
||||
timestamp,
|
||||
performance.now() - start,
|
||||
bodyReadResult.failure,
|
||||
response,
|
||||
responseHeaders,
|
||||
bodyPreview,
|
||||
);
|
||||
}
|
||||
if (hasBodyExpectations) {
|
||||
const bodyReadResult = await readBodyStream(response, t.http.maxBodyBytes);
|
||||
let bodyDecodeFailure: CheckResult["failure"] = null;
|
||||
|
||||
if (bodyDecodeFailure) {
|
||||
return makeResult(
|
||||
t,
|
||||
timestamp,
|
||||
performance.now() - start,
|
||||
bodyDecodeFailure,
|
||||
response,
|
||||
responseHeaders,
|
||||
bodyPreview,
|
||||
);
|
||||
}
|
||||
if (bodyReadResult.data.byteLength > 0) {
|
||||
const decodeResult = decodeBody(bodyReadResult.data, response.headers);
|
||||
if (decodeResult.ok) {
|
||||
bodyText = decodeResult.text;
|
||||
bodyPreview = truncateBodyPreview(decodeResult.text);
|
||||
} else {
|
||||
bodyDecodeFailure = decodeResult.failure;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasBodyRules) {
|
||||
const bodyResult = checkContentRules(bodyText ?? "", expect.body, { path: "body", phase: "body" });
|
||||
if (!bodyReadResult.ok) {
|
||||
return makeResult(
|
||||
t,
|
||||
timestamp,
|
||||
performance.now() - start,
|
||||
bodyReadResult.failure,
|
||||
response,
|
||||
responseHeaders,
|
||||
bodyPreview,
|
||||
);
|
||||
}
|
||||
|
||||
if (bodyDecodeFailure) {
|
||||
return makeResult(
|
||||
t,
|
||||
timestamp,
|
||||
performance.now() - start,
|
||||
bodyDecodeFailure,
|
||||
response,
|
||||
responseHeaders,
|
||||
bodyPreview,
|
||||
);
|
||||
}
|
||||
|
||||
const bodyResult = checkContentExpectations(bodyText ?? "", expect.body, { path: "body", phase: "body" });
|
||||
if (!bodyResult.matched) {
|
||||
return makeResult(
|
||||
t,
|
||||
@@ -145,7 +143,7 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
||||
}
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const durationResult = checkValueMatcher(durationMs, expect?.durationMs, {
|
||||
const durationResult = checkValueExpectation(durationMs, expect?.durationMs, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
phase: "duration",
|
||||
@@ -184,9 +182,19 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
||||
const method = t.http.method ?? "GET";
|
||||
const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB");
|
||||
|
||||
const rawExpect = target.expect as RawHttpExpectConfig | undefined;
|
||||
const resolvedExpect: ResolvedHttpExpectConfig = rawExpect
|
||||
? {
|
||||
body: resolveContentExpectations(rawExpect.body),
|
||||
durationMs: resolveValueExpectation(rawExpect.durationMs),
|
||||
headers: resolveKeyedExpectations(rawExpect.headers),
|
||||
status: rawExpect.status ?? [200],
|
||||
}
|
||||
: { status: [200] };
|
||||
|
||||
return {
|
||||
description: null,
|
||||
expect: target.expect as HttpExpectConfig | undefined,
|
||||
expect: resolvedExpect,
|
||||
group: target.group ?? "default",
|
||||
http: {
|
||||
body: t.http.body,
|
||||
@@ -200,6 +208,7 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
||||
id: t.id,
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
name: t.name ?? null,
|
||||
rawExpect,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
type: "http",
|
||||
} satisfies ResolvedHttpTarget;
|
||||
@@ -277,20 +286,16 @@ function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: strin
|
||||
|
||||
function checkEarlyTimeout(
|
||||
start: number,
|
||||
durationMatcher: HttpExpectConfig["durationMs"] | undefined,
|
||||
durationMatcher: ResolvedHttpExpectConfig["durationMs"] | undefined,
|
||||
): null | { elapsed: number; failure: CheckResult["failure"] } {
|
||||
if (!isValueMatcherObject(durationMatcher)) return null;
|
||||
const limit = Math.min(
|
||||
durationMatcher.lte ?? Number.POSITIVE_INFINITY,
|
||||
durationMatcher.lt ?? Number.POSITIVE_INFINITY,
|
||||
);
|
||||
if (!Number.isFinite(limit)) return null;
|
||||
|
||||
if (!durationMatcher) return null;
|
||||
const elapsed = performance.now() - start;
|
||||
if (durationMatcher.lt !== undefined ? elapsed < limit : elapsed <= limit) return null;
|
||||
const lteFailed = durationMatcher.lte !== undefined && elapsed > durationMatcher.lte;
|
||||
const ltFailed = durationMatcher.lt !== undefined && elapsed >= durationMatcher.lt;
|
||||
if (!lteFailed && !ltFailed) return null;
|
||||
|
||||
const durationMs = Math.round(elapsed);
|
||||
const durationResult = checkValueMatcher(durationMs, durationMatcher, {
|
||||
const durationResult = checkValueExpectation(durationMs, durationMatcher, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
phase: "duration",
|
||||
@@ -299,7 +304,13 @@ function checkEarlyTimeout(
|
||||
elapsed,
|
||||
failure:
|
||||
durationResult.failure ??
|
||||
mismatchFailure("duration", "durationMs", durationMatcher, durationMs, "durationMs mismatch"),
|
||||
mismatchFailure(
|
||||
"duration",
|
||||
"durationMs",
|
||||
displayValueExpectation(durationMatcher),
|
||||
durationMs,
|
||||
"durationMs mismatch",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { isNumber } from "es-toolkit";
|
||||
|
||||
import type { ExpectResult, KeyValueExpect } from "../../expect/types";
|
||||
|
||||
import { mismatchFailure } from "../../expect/failure";
|
||||
import { checkKeyValueExpect } from "../../expect/key-value";
|
||||
|
||||
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 {
|
||||
const matched = allowed.some((pattern) => {
|
||||
if (isNumber(pattern)) return statusCode === pattern;
|
||||
const base = parseInt(pattern[0]!, 10) * 100;
|
||||
return statusCode >= base && statusCode < base + 100;
|
||||
});
|
||||
|
||||
if (!matched) {
|
||||
return {
|
||||
failure: mismatchFailure(
|
||||
"status",
|
||||
"status",
|
||||
allowed,
|
||||
statusCode,
|
||||
`status ${statusCode} not in [${allowed.join(", ")}]`,
|
||||
),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
@@ -3,9 +3,9 @@ import { Type } from "@sinclair/typebox";
|
||||
import type { CheckerSchemas } from "../types";
|
||||
|
||||
import {
|
||||
createContentRulesSchema,
|
||||
createKeyValueExpectSchema,
|
||||
createValueMatcherSchema,
|
||||
createRawContentExpectationsSchema,
|
||||
createRawKeyedExpectationsSchema,
|
||||
createRawValueExpectationSchema,
|
||||
httpMethodSchema,
|
||||
sizeSchema,
|
||||
statusCodePatternSchema,
|
||||
@@ -34,9 +34,9 @@ export const httpCheckerSchemas: CheckerSchemas = {
|
||||
),
|
||||
expect: Type.Object(
|
||||
{
|
||||
body: Type.Optional(createContentRulesSchema()),
|
||||
durationMs: Type.Optional(createValueMatcherSchema()),
|
||||
headers: Type.Optional(createKeyValueExpectSchema()),
|
||||
body: Type.Optional(createRawContentExpectationsSchema()),
|
||||
durationMs: Type.Optional(createRawValueExpectationSchema()),
|
||||
headers: Type.Optional(createRawKeyedExpectationsSchema()),
|
||||
status: Type.Optional(Type.Array(statusCodePatternSchema)),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import type { ContentRules, KeyValueExpect, ValueMatcherInput } from "../../expect/types";
|
||||
import type {
|
||||
ContentExpectations,
|
||||
KeyedExpectations,
|
||||
RawContentExpectations,
|
||||
RawKeyedExpectations,
|
||||
RawValueExpectation,
|
||||
ValueExpectation,
|
||||
} from "../../expect/types";
|
||||
import type { ResolvedTargetBase } from "../../types";
|
||||
|
||||
export interface HttpDefaultsConfig {
|
||||
@@ -7,13 +14,6 @@ export interface HttpDefaultsConfig {
|
||||
method?: string;
|
||||
}
|
||||
|
||||
export interface HttpExpectConfig {
|
||||
body?: ContentRules;
|
||||
durationMs?: ValueMatcherInput;
|
||||
headers?: KeyValueExpect;
|
||||
status?: Array<number | string>;
|
||||
}
|
||||
|
||||
export interface HttpTargetConfig {
|
||||
body?: string;
|
||||
headers?: Record<string, string>;
|
||||
@@ -24,6 +24,13 @@ export interface HttpTargetConfig {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface RawHttpExpectConfig {
|
||||
body?: RawContentExpectations;
|
||||
durationMs?: RawValueExpectation;
|
||||
headers?: RawKeyedExpectations;
|
||||
status?: Array<number | string>;
|
||||
}
|
||||
|
||||
export interface ResolvedHttpConfig {
|
||||
body?: string;
|
||||
headers: Record<string, string>;
|
||||
@@ -34,12 +41,20 @@ export interface ResolvedHttpConfig {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ResolvedHttpExpectConfig {
|
||||
body?: ContentExpectations;
|
||||
durationMs?: ValueExpectation;
|
||||
headers?: KeyedExpectations;
|
||||
status: Array<number | string>;
|
||||
}
|
||||
|
||||
export interface ResolvedHttpTarget extends ResolvedTargetBase {
|
||||
expect?: HttpExpectConfig;
|
||||
expect?: ResolvedHttpExpectConfig;
|
||||
group: string;
|
||||
http: ResolvedHttpConfig;
|
||||
intervalMs: number;
|
||||
name: null | string;
|
||||
rawExpect?: RawHttpExpectConfig;
|
||||
timeoutMs: number;
|
||||
type: "http";
|
||||
}
|
||||
|
||||
@@ -3,13 +3,12 @@ import { isNumber, isString } from "es-toolkit";
|
||||
import type { ConfigValidationIssue } from "../../schema/issues";
|
||||
import type { CheckerValidationInput } from "../types";
|
||||
|
||||
import { normalizeExpectMatchers } from "../../expect/normalize";
|
||||
import {
|
||||
isPlainRecord,
|
||||
validateContentRules,
|
||||
validateKeyValueExpect,
|
||||
validateValueMatcher,
|
||||
} from "../../expect/validate-matcher";
|
||||
validateRawContentExpectations,
|
||||
validateRawKeyedExpectations,
|
||||
validateRawValueExpectation,
|
||||
} from "../../expect/validate";
|
||||
import { issue, joinPath } from "../../schema/issues";
|
||||
import { parseSize } from "../../utils";
|
||||
|
||||
@@ -34,24 +33,6 @@ export function validateHttpConfig(input: CheckerValidationInput): ConfigValidat
|
||||
return issues;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function getTargetName(target: Record<string, unknown>): string | undefined {
|
||||
if (isString(target["name"])) return target["name"];
|
||||
return isString(target["id"]) ? target["id"] : undefined;
|
||||
@@ -68,14 +49,16 @@ function validateHttpExpect(target: Record<string, unknown>, path: string): Conf
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const expectPath = joinPath(path, "expect");
|
||||
|
||||
normalizeExpectMatchers(expect, ["durationMs"]);
|
||||
|
||||
if (isPlainRecord(expect["headers"])) {
|
||||
issues.push(...validateKeyValueExpect(expect["headers"], joinPath(expectPath, "headers"), targetName));
|
||||
issues.push(
|
||||
...validateRawKeyedExpectations(expect["headers"], joinPath(expectPath, "headers"), targetName, {
|
||||
caseInsensitive: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (expect["body"] !== undefined) {
|
||||
issues.push(...validateContentRules(expect["body"], joinPath(expectPath, "body"), targetName));
|
||||
issues.push(...validateRawContentExpectations(expect["body"], joinPath(expectPath, "body"), targetName));
|
||||
}
|
||||
|
||||
if (Array.isArray(expect["status"])) {
|
||||
@@ -83,7 +66,7 @@ function validateHttpExpect(target: Record<string, unknown>, path: string): Conf
|
||||
}
|
||||
|
||||
if (expect["durationMs"] !== undefined) {
|
||||
issues.push(...validateValueMatcher(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
|
||||
issues.push(...validateRawValueExpectation(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
|
||||
}
|
||||
|
||||
return issues;
|
||||
|
||||
@@ -2,10 +2,16 @@ import { isError } from "es-toolkit";
|
||||
|
||||
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type { PingExpectConfig, PingStats, PingTargetConfig, ResolvedPingTarget } from "./types";
|
||||
import type {
|
||||
PingStats,
|
||||
PingTargetConfig,
|
||||
RawIcmpExpectConfig,
|
||||
ResolvedIcmpExpectConfig,
|
||||
ResolvedPingTarget,
|
||||
} from "./types";
|
||||
|
||||
import { errorFailure } from "../../expect/failure";
|
||||
import { checkValueMatcher } from "../../expect/matcher";
|
||||
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value";
|
||||
import { buildPingCommand } from "./command";
|
||||
import { checkAlive, checkAvgLatency, checkMaxLatency, checkPacketLoss } from "./expect";
|
||||
import { parsePingOutput } from "./parse";
|
||||
@@ -155,9 +161,21 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
|
||||
|
||||
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedPingTarget {
|
||||
const t = target as RawTargetConfig & { icmp: PingTargetConfig; type: "icmp" };
|
||||
|
||||
const rawExpect = target.expect as RawIcmpExpectConfig | undefined;
|
||||
const resolvedExpect: ResolvedIcmpExpectConfig = rawExpect
|
||||
? {
|
||||
alive: rawExpect.alive ?? true,
|
||||
avgLatencyMs: resolveValueExpectation(rawExpect.avgLatencyMs),
|
||||
durationMs: resolveValueExpectation(rawExpect.durationMs),
|
||||
maxLatencyMs: resolveValueExpectation(rawExpect.maxLatencyMs),
|
||||
packetLossPercent: resolveValueExpectation(rawExpect.packetLossPercent),
|
||||
}
|
||||
: { alive: true };
|
||||
|
||||
return {
|
||||
description: null,
|
||||
expect: target.expect as PingExpectConfig | undefined,
|
||||
expect: resolvedExpect,
|
||||
group: target.group ?? "default",
|
||||
icmp: {
|
||||
count: t.icmp.count ?? DEFAULT_COUNT,
|
||||
@@ -167,6 +185,7 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
|
||||
id: t.id,
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
name: t.name ?? null,
|
||||
rawExpect,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
type: "icmp",
|
||||
} satisfies ResolvedPingTarget;
|
||||
@@ -184,7 +203,7 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
|
||||
}
|
||||
}
|
||||
|
||||
function checkStats(stats: PingStats, expect: PingExpectConfig | undefined, durationMs: number) {
|
||||
function checkStats(stats: PingStats, expect: ResolvedIcmpExpectConfig | undefined, durationMs: number) {
|
||||
const aliveResult = checkAlive(stats.alive, expect?.alive ?? true);
|
||||
if (!aliveResult.matched) return aliveResult;
|
||||
const packetLossResult = checkPacketLoss(stats.packetLoss, expect?.packetLossPercent);
|
||||
@@ -193,7 +212,7 @@ function checkStats(stats: PingStats, expect: PingExpectConfig | undefined, dura
|
||||
if (!avgLatencyResult.matched) return avgLatencyResult;
|
||||
const maxLatencyResult = checkMaxLatency(stats.maxLatencyMs, expect?.maxLatencyMs);
|
||||
if (!maxLatencyResult.matched) return maxLatencyResult;
|
||||
return checkValueMatcher(durationMs, expect?.durationMs, {
|
||||
return checkValueExpectation(durationMs, expect?.durationMs, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
phase: "duration",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { ExpectResult, ValueMatcherInput } from "../../expect/types";
|
||||
import type { ExpectationResult, ValueExpectation } from "../../expect/types";
|
||||
|
||||
import { mismatchFailure } from "../../expect/failure";
|
||||
import { checkValueMatcher } from "../../expect/matcher";
|
||||
import { checkValueExpectation } from "../../expect/value";
|
||||
|
||||
export function checkAlive(actual: boolean, expected: boolean): ExpectResult {
|
||||
export function checkAlive(actual: boolean, expected: boolean): ExpectationResult {
|
||||
if (actual === expected) return { failure: null, matched: true };
|
||||
return {
|
||||
failure: mismatchFailure(
|
||||
@@ -17,24 +17,24 @@ export function checkAlive(actual: boolean, expected: boolean): ExpectResult {
|
||||
};
|
||||
}
|
||||
|
||||
export function checkAvgLatency(actual: null | number, matcher: undefined | ValueMatcherInput): ExpectResult {
|
||||
return checkValueMatcher(actual, matcher, {
|
||||
export function checkAvgLatency(actual: null | number, matcher: undefined | ValueExpectation): ExpectationResult {
|
||||
return checkValueExpectation(actual, matcher, {
|
||||
message: "平均延迟不满足条件",
|
||||
path: "avgLatencyMs",
|
||||
phase: "avgLatency",
|
||||
});
|
||||
}
|
||||
|
||||
export function checkMaxLatency(actual: null | number, matcher: undefined | ValueMatcherInput): ExpectResult {
|
||||
return checkValueMatcher(actual, matcher, {
|
||||
export function checkMaxLatency(actual: null | number, matcher: undefined | ValueExpectation): ExpectationResult {
|
||||
return checkValueExpectation(actual, matcher, {
|
||||
message: "最大延迟不满足条件",
|
||||
path: "maxLatencyMs",
|
||||
phase: "maxLatency",
|
||||
});
|
||||
}
|
||||
|
||||
export function checkPacketLoss(actual: number, matcher: undefined | ValueMatcherInput): ExpectResult {
|
||||
return checkValueMatcher(actual, matcher, {
|
||||
export function checkPacketLoss(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
|
||||
return checkValueExpectation(actual, matcher, {
|
||||
message: "丢包率不满足条件",
|
||||
path: "packetLossPercent",
|
||||
phase: "packetLoss",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Type } from "@sinclair/typebox";
|
||||
|
||||
import type { CheckerSchemas } from "../types";
|
||||
|
||||
import { createValueMatcherSchema } from "../../schema/fragments";
|
||||
import { createRawValueExpectationSchema } from "../../schema/fragments";
|
||||
|
||||
export const icmpCheckerSchemas: CheckerSchemas = {
|
||||
config: Type.Object(
|
||||
@@ -17,10 +17,10 @@ export const icmpCheckerSchemas: CheckerSchemas = {
|
||||
expect: Type.Object(
|
||||
{
|
||||
alive: Type.Optional(Type.Boolean()),
|
||||
avgLatencyMs: Type.Optional(createValueMatcherSchema()),
|
||||
durationMs: Type.Optional(createValueMatcherSchema()),
|
||||
maxLatencyMs: Type.Optional(createValueMatcherSchema()),
|
||||
packetLossPercent: Type.Optional(createValueMatcherSchema()),
|
||||
avgLatencyMs: Type.Optional(createRawValueExpectationSchema()),
|
||||
durationMs: Type.Optional(createRawValueExpectationSchema()),
|
||||
maxLatencyMs: Type.Optional(createRawValueExpectationSchema()),
|
||||
packetLossPercent: Type.Optional(createRawValueExpectationSchema()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import type { ValueMatcherInput } from "../../expect/types";
|
||||
import type { RawValueExpectation, ValueExpectation } from "../../expect/types";
|
||||
import type { ResolvedTargetBase } from "../../types";
|
||||
|
||||
export interface PingExpectConfig {
|
||||
alive?: boolean;
|
||||
avgLatencyMs?: ValueMatcherInput;
|
||||
durationMs?: ValueMatcherInput;
|
||||
maxLatencyMs?: ValueMatcherInput;
|
||||
packetLossPercent?: ValueMatcherInput;
|
||||
}
|
||||
|
||||
export interface PingStats {
|
||||
alive: boolean;
|
||||
avgLatencyMs: null | number;
|
||||
@@ -25,6 +17,22 @@ export interface PingTargetConfig {
|
||||
packetSize?: number;
|
||||
}
|
||||
|
||||
export interface RawIcmpExpectConfig {
|
||||
alive?: boolean;
|
||||
avgLatencyMs?: RawValueExpectation;
|
||||
durationMs?: RawValueExpectation;
|
||||
maxLatencyMs?: RawValueExpectation;
|
||||
packetLossPercent?: RawValueExpectation;
|
||||
}
|
||||
|
||||
export interface ResolvedIcmpExpectConfig {
|
||||
alive: boolean;
|
||||
avgLatencyMs?: ValueExpectation;
|
||||
durationMs?: ValueExpectation;
|
||||
maxLatencyMs?: ValueExpectation;
|
||||
packetLossPercent?: ValueExpectation;
|
||||
}
|
||||
|
||||
export interface ResolvedPingConfig {
|
||||
count: number;
|
||||
host: string;
|
||||
@@ -32,11 +40,12 @@ export interface ResolvedPingConfig {
|
||||
}
|
||||
|
||||
export interface ResolvedPingTarget extends ResolvedTargetBase {
|
||||
expect?: PingExpectConfig;
|
||||
expect?: ResolvedIcmpExpectConfig;
|
||||
group: string;
|
||||
icmp: ResolvedPingConfig;
|
||||
intervalMs: number;
|
||||
name: null | string;
|
||||
rawExpect?: RawIcmpExpectConfig;
|
||||
timeoutMs: number;
|
||||
type: "icmp";
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { isNumber, isPlainObject, isString } from "es-toolkit";
|
||||
import { isNumber, isString } from "es-toolkit";
|
||||
|
||||
import type { ConfigValidationIssue } from "../../schema/issues";
|
||||
import type { CheckerValidationInput } from "../types";
|
||||
|
||||
import { normalizeExpectMatchers } from "../../expect/normalize";
|
||||
import { validateValueMatcher } from "../../expect/validate-matcher";
|
||||
import { isPlainRecord, validateRawValueExpectation } from "../../expect/validate";
|
||||
import { issue, joinPath } from "../../schema/issues";
|
||||
|
||||
export function validatePingConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
|
||||
@@ -13,10 +12,10 @@ export function validatePingConfig(input: CheckerValidationInput): ConfigValidat
|
||||
const defaults = input.defaults["icmp"];
|
||||
if (defaults !== undefined && defaults !== null) {
|
||||
const targetName = "defaults.icmp";
|
||||
if (!isPlainObject(defaults)) {
|
||||
if (!isPlainRecord(defaults)) {
|
||||
issues.push(issue("invalid-type", "defaults.icmp", "必须为对象", targetName));
|
||||
} else {
|
||||
const icmpDefaults = defaults as Record<string, unknown>;
|
||||
const icmpDefaults = defaults;
|
||||
for (const key of Object.keys(icmpDefaults)) {
|
||||
issues.push(issue("unknown-field", joinPath("defaults.icmp", key), "是未知字段", targetName));
|
||||
}
|
||||
@@ -25,8 +24,8 @@ export function validatePingConfig(input: CheckerValidationInput): ConfigValidat
|
||||
|
||||
for (let i = 0; i < input.targets.length; i++) {
|
||||
const target = input.targets[i] as unknown;
|
||||
if (!isPlainObject(target)) continue;
|
||||
const targetRecord = target as Record<string, unknown>;
|
||||
if (!isPlainRecord(target)) continue;
|
||||
const targetRecord = target;
|
||||
if (targetRecord["type"] !== "icmp") continue;
|
||||
issues.push(...validatePingTarget(targetRecord, `targets[${i}]`));
|
||||
}
|
||||
@@ -41,20 +40,18 @@ function getTargetName(target: Record<string, unknown>): string | undefined {
|
||||
|
||||
function validatePingExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||
const rawExpect = target["expect"];
|
||||
if (rawExpect === undefined || rawExpect === null || !isPlainObject(rawExpect)) return [];
|
||||
const expect = rawExpect as Record<string, unknown>;
|
||||
if (rawExpect === undefined || rawExpect === null || !isPlainRecord(rawExpect)) return [];
|
||||
const expect = rawExpect;
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const targetName = getTargetName(target);
|
||||
const expectPath = joinPath(path, "expect");
|
||||
|
||||
normalizeExpectMatchers(expect, ["packetLossPercent", "avgLatencyMs", "maxLatencyMs", "durationMs"]);
|
||||
|
||||
if (expect["alive"] !== undefined && typeof expect["alive"] !== "boolean") {
|
||||
issues.push(issue("invalid-type", joinPath(expectPath, "alive"), "必须为布尔值", targetName));
|
||||
}
|
||||
for (const key of ["packetLossPercent", "avgLatencyMs", "maxLatencyMs", "durationMs"]) {
|
||||
if (expect[key] !== undefined) {
|
||||
issues.push(...validateValueMatcher(expect[key], joinPath(expectPath, key), targetName));
|
||||
issues.push(...validateRawValueExpectation(expect[key], joinPath(expectPath, key), targetName));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,12 +70,12 @@ function validatePingTarget(target: Record<string, unknown>, path: string): Conf
|
||||
const targetName = getTargetName(target);
|
||||
const rawIcmp = target["icmp"];
|
||||
|
||||
if (!isPlainObject(rawIcmp)) {
|
||||
if (!isPlainRecord(rawIcmp)) {
|
||||
issues.push(issue("required", joinPath(path, "icmp"), "缺少 icmp 配置分组", targetName));
|
||||
issues.push(...validatePingExpect(target, path));
|
||||
return issues;
|
||||
}
|
||||
const icmp = rawIcmp as Record<string, unknown>;
|
||||
const icmp = rawIcmp;
|
||||
|
||||
if (!isString(icmp["host"]) || icmp["host"].trim() === "") {
|
||||
issues.push(issue("required", joinPath(joinPath(path, "icmp"), "host"), "缺少 icmp.host 字段", targetName));
|
||||
|
||||
@@ -5,10 +5,12 @@ import { isError } from "es-toolkit";
|
||||
|
||||
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type { LlmExpectConfig, LlmTargetConfig, ResolvedLlmTarget } from "./types";
|
||||
import type { LlmTargetConfig, RawLlmExpectConfig, ResolvedLlmExpectConfig, ResolvedLlmTarget } from "./types";
|
||||
|
||||
import { resolveContentExpectations } from "../../expect/content";
|
||||
import { errorFailure } from "../../expect/failure";
|
||||
import { checkValueMatcher } from "../../expect/matcher";
|
||||
import { resolveKeyedExpectations } from "../../expect/keyed";
|
||||
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value";
|
||||
import { runExpects } from "./expect";
|
||||
import {
|
||||
buildObservationFromApiCallError,
|
||||
@@ -93,7 +95,7 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
};
|
||||
}
|
||||
|
||||
const durationResult = checkValueMatcher(durationMs, expect?.durationMs, {
|
||||
const durationResult = checkValueExpectation(durationMs, expect?.durationMs, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
phase: "duration",
|
||||
@@ -171,14 +173,40 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
url: t.llm.url,
|
||||
};
|
||||
|
||||
const rawExpect = target.expect as RawLlmExpectConfig | undefined;
|
||||
const resolvedExpect: ResolvedLlmExpectConfig = rawExpect
|
||||
? {
|
||||
durationMs: resolveValueExpectation(rawExpect.durationMs),
|
||||
finishReason: resolveValueExpectation(rawExpect.finishReason),
|
||||
headers: resolveKeyedExpectations(rawExpect.headers),
|
||||
output: resolveContentExpectations(rawExpect.output),
|
||||
rawFinishReason: resolveValueExpectation(rawExpect.rawFinishReason),
|
||||
status: rawExpect.status ?? [200],
|
||||
stream: rawExpect.stream
|
||||
? {
|
||||
completed: rawExpect.stream.completed ?? true,
|
||||
firstTokenMs: resolveValueExpectation(rawExpect.stream.firstTokenMs),
|
||||
}
|
||||
: undefined,
|
||||
usage: rawExpect.usage
|
||||
? {
|
||||
inputTokens: resolveValueExpectation(rawExpect.usage.inputTokens),
|
||||
outputTokens: resolveValueExpectation(rawExpect.usage.outputTokens),
|
||||
totalTokens: resolveValueExpectation(rawExpect.usage.totalTokens),
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: { status: [200] };
|
||||
|
||||
return {
|
||||
description: (target.description as null | string) ?? null,
|
||||
expect: target.expect as LlmExpectConfig | undefined,
|
||||
expect: resolvedExpect,
|
||||
group: target.group ?? "default",
|
||||
id: t.id,
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
llm: resolvedConfig,
|
||||
name: (target.name as null | string) ?? null,
|
||||
rawExpect,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
type: "llm",
|
||||
} satisfies ResolvedLlmTarget;
|
||||
@@ -210,7 +238,7 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
t: ResolvedLlmTarget,
|
||||
model: ReturnType<typeof createProviderModel>["model"],
|
||||
httpMeta: null | { headers: Record<string, string>; status: number; statusText: string },
|
||||
expect: LlmExpectConfig | undefined,
|
||||
expect: ResolvedLlmExpectConfig | undefined,
|
||||
ctx: CheckerContext,
|
||||
timestamp: string,
|
||||
start: number,
|
||||
@@ -254,7 +282,7 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
);
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const durationResult = checkValueMatcher(durationMs, expect?.durationMs, {
|
||||
const durationResult = checkValueExpectation(durationMs, expect?.durationMs, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
phase: "duration",
|
||||
@@ -277,7 +305,7 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
t: ResolvedLlmTarget,
|
||||
model: ReturnType<typeof createProviderModel>["model"],
|
||||
httpMeta: null | { headers: Record<string, string>; status: number; statusText: string },
|
||||
expect: LlmExpectConfig | undefined,
|
||||
expect: ResolvedLlmExpectConfig | undefined,
|
||||
ctx: CheckerContext,
|
||||
timestamp: string,
|
||||
start: number,
|
||||
@@ -301,7 +329,7 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
);
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const durationResult = checkValueMatcher(durationMs, expect?.durationMs, {
|
||||
const durationResult = checkValueExpectation(durationMs, expect?.durationMs, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
phase: "duration",
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import type { ExpectResult } from "../../expect/types";
|
||||
import type { LlmCheckObservation, LlmExpectConfig, LlmUsageExpect } from "./types";
|
||||
import type { ExpectationResult } from "../../expect/types";
|
||||
import type { LlmCheckObservation, ResolvedLlmExpectConfig, ResolvedLlmUsageExpect } from "./types";
|
||||
|
||||
import { checkContentRules } from "../../expect/content";
|
||||
import { checkContentExpectations } from "../../expect/content";
|
||||
import { mismatchFailure } from "../../expect/failure";
|
||||
import { checkValueMatcher } from "../../expect/matcher";
|
||||
import { checkHeaders, checkStatus } from "../http/expect";
|
||||
import { checkHeaderExpectations } from "../../expect/headers";
|
||||
import { checkStatusCode } from "../../expect/status";
|
||||
import { checkValueExpectation } from "../../expect/value";
|
||||
|
||||
export function checkStreamExpect(observation: LlmCheckObservation, expect: LlmExpectConfig): ExpectResult {
|
||||
export function checkStreamExpect(
|
||||
observation: LlmCheckObservation,
|
||||
expect: ResolvedLlmExpectConfig,
|
||||
): ExpectationResult {
|
||||
if (!observation.stream || !expect.stream) return { failure: null, matched: true };
|
||||
|
||||
const expectedCompleted = expect.stream.completed ?? true;
|
||||
const expectedCompleted = expect.stream.completed;
|
||||
if (observation.stream.completed !== expectedCompleted) {
|
||||
return {
|
||||
failure: mismatchFailure(
|
||||
@@ -24,7 +28,7 @@ export function checkStreamExpect(observation: LlmCheckObservation, expect: LlmE
|
||||
}
|
||||
|
||||
if (expect.stream.firstTokenMs && observation.stream.firstTokenMs !== null) {
|
||||
return checkValueMatcher(observation.stream.firstTokenMs, expect.stream.firstTokenMs, {
|
||||
return checkValueExpectation(observation.stream.firstTokenMs, expect.stream.firstTokenMs, {
|
||||
message: "stream.firstTokenMs mismatch",
|
||||
path: "stream.firstTokenMs",
|
||||
phase: "stream",
|
||||
@@ -45,20 +49,23 @@ export function checkStreamExpect(observation: LlmCheckObservation, expect: LlmE
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
export function runExpects(observation: LlmCheckObservation, expect: LlmExpectConfig | undefined): ExpectResult {
|
||||
export function runExpects(
|
||||
observation: LlmCheckObservation,
|
||||
expect: ResolvedLlmExpectConfig | undefined,
|
||||
): ExpectationResult {
|
||||
if (!expect) {
|
||||
const defaultStatus = checkStatus(observation.http?.status ?? 0, [200]);
|
||||
const defaultStatus = checkStatusCode(observation.http?.status ?? 0, [200]);
|
||||
if (!defaultStatus.matched) return defaultStatus;
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
const http = observation.http;
|
||||
|
||||
const statusResult = checkStatus(http?.status ?? 0, expect.status ?? [200]);
|
||||
const statusResult = checkStatusCode(http?.status ?? 0, expect.status);
|
||||
if (!statusResult.matched) return statusResult;
|
||||
|
||||
if (http && expect.headers) {
|
||||
const headersResult = checkHeaders(http.headers, expect.headers);
|
||||
const headersResult = checkHeaderExpectations(http.headers, expect.headers);
|
||||
if (!headersResult.matched) return headersResult;
|
||||
}
|
||||
|
||||
@@ -67,11 +74,14 @@ export function runExpects(observation: LlmCheckObservation, expect: LlmExpectCo
|
||||
if (!streamResult.matched) return streamResult;
|
||||
}
|
||||
|
||||
const outputResult = checkContentRules(observation.outputText, expect.output, { path: "output", phase: "output" });
|
||||
const outputResult = checkContentExpectations(observation.outputText, expect.output, {
|
||||
path: "output",
|
||||
phase: "output",
|
||||
});
|
||||
if (!outputResult.matched) return outputResult;
|
||||
|
||||
if (expect.finishReason !== undefined) {
|
||||
const result = checkValueMatcher(observation.finishReason, expect.finishReason, {
|
||||
const result = checkValueExpectation(observation.finishReason, expect.finishReason, {
|
||||
message: "finishReason mismatch",
|
||||
path: "finishReason",
|
||||
phase: "finishReason",
|
||||
@@ -80,7 +90,7 @@ export function runExpects(observation: LlmCheckObservation, expect: LlmExpectCo
|
||||
}
|
||||
|
||||
if (expect.rawFinishReason !== undefined) {
|
||||
const result = checkValueMatcher(observation.rawFinishReason, expect.rawFinishReason, {
|
||||
const result = checkValueExpectation(observation.rawFinishReason, expect.rawFinishReason, {
|
||||
message: "rawFinishReason mismatch",
|
||||
path: "rawFinishReason",
|
||||
phase: "rawFinishReason",
|
||||
@@ -98,10 +108,10 @@ export function runExpects(observation: LlmCheckObservation, expect: LlmExpectCo
|
||||
|
||||
function checkUsageExpect(
|
||||
usage: { inputTokens: number; outputTokens: number; totalTokens: number },
|
||||
expectUsage: LlmUsageExpect,
|
||||
): ExpectResult {
|
||||
expectUsage: ResolvedLlmUsageExpect,
|
||||
): ExpectationResult {
|
||||
if (expectUsage.inputTokens !== undefined) {
|
||||
const result = checkValueMatcher(usage.inputTokens, expectUsage.inputTokens, {
|
||||
const result = checkValueExpectation(usage.inputTokens, expectUsage.inputTokens, {
|
||||
message: "usage.inputTokens mismatch",
|
||||
path: "usage.inputTokens",
|
||||
phase: "usage",
|
||||
@@ -109,7 +119,7 @@ function checkUsageExpect(
|
||||
if (!result.matched) return result;
|
||||
}
|
||||
if (expectUsage.outputTokens !== undefined) {
|
||||
const result = checkValueMatcher(usage.outputTokens, expectUsage.outputTokens, {
|
||||
const result = checkValueExpectation(usage.outputTokens, expectUsage.outputTokens, {
|
||||
message: "usage.outputTokens mismatch",
|
||||
path: "usage.outputTokens",
|
||||
phase: "usage",
|
||||
@@ -117,7 +127,7 @@ function checkUsageExpect(
|
||||
if (!result.matched) return result;
|
||||
}
|
||||
if (expectUsage.totalTokens !== undefined) {
|
||||
const result = checkValueMatcher(usage.totalTokens, expectUsage.totalTokens, {
|
||||
const result = checkValueExpectation(usage.totalTokens, expectUsage.totalTokens, {
|
||||
message: "usage.totalTokens mismatch",
|
||||
path: "usage.totalTokens",
|
||||
phase: "usage",
|
||||
|
||||
@@ -3,9 +3,9 @@ import { Type } from "@sinclair/typebox";
|
||||
import type { CheckerSchemas } from "../types";
|
||||
|
||||
import {
|
||||
createContentRulesSchema,
|
||||
createKeyValueExpectSchema,
|
||||
createValueMatcherSchema,
|
||||
createRawContentExpectationsSchema,
|
||||
createRawKeyedExpectationsSchema,
|
||||
createRawValueExpectationSchema,
|
||||
statusCodePatternSchema,
|
||||
stringMapSchema,
|
||||
} from "../../schema/fragments";
|
||||
@@ -55,17 +55,17 @@ export const llmCheckerSchemas: CheckerSchemas = {
|
||||
),
|
||||
expect: Type.Object(
|
||||
{
|
||||
durationMs: Type.Optional(createValueMatcherSchema()),
|
||||
finishReason: Type.Optional(createValueMatcherSchema()),
|
||||
headers: Type.Optional(createKeyValueExpectSchema()),
|
||||
output: Type.Optional(createContentRulesSchema()),
|
||||
rawFinishReason: Type.Optional(createValueMatcherSchema()),
|
||||
durationMs: Type.Optional(createRawValueExpectationSchema()),
|
||||
finishReason: Type.Optional(createRawValueExpectationSchema()),
|
||||
headers: Type.Optional(createRawKeyedExpectationsSchema()),
|
||||
output: Type.Optional(createRawContentExpectationsSchema()),
|
||||
rawFinishReason: Type.Optional(createRawValueExpectationSchema()),
|
||||
status: Type.Optional(Type.Array(statusCodePatternSchema)),
|
||||
stream: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
completed: Type.Optional(Type.Boolean()),
|
||||
firstTokenMs: Type.Optional(createValueMatcherSchema()),
|
||||
firstTokenMs: Type.Optional(createRawValueExpectationSchema()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
@@ -73,9 +73,9 @@ export const llmCheckerSchemas: CheckerSchemas = {
|
||||
usage: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
inputTokens: Type.Optional(createValueMatcherSchema()),
|
||||
outputTokens: Type.Optional(createValueMatcherSchema()),
|
||||
totalTokens: Type.Optional(createValueMatcherSchema()),
|
||||
inputTokens: Type.Optional(createRawValueExpectationSchema()),
|
||||
outputTokens: Type.Optional(createRawValueExpectationSchema()),
|
||||
totalTokens: Type.Optional(createRawValueExpectationSchema()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { JSONObject } from "@ai-sdk/provider";
|
||||
|
||||
import type { ContentRules, KeyValueExpect, ValueMatcherInput } from "../../expect/types";
|
||||
import type {
|
||||
ContentExpectations,
|
||||
KeyedExpectations,
|
||||
RawContentExpectations,
|
||||
RawKeyedExpectations,
|
||||
RawValueExpectation,
|
||||
ValueExpectation,
|
||||
} from "../../expect/types";
|
||||
import type { ResolvedTargetBase } from "../../types";
|
||||
|
||||
export interface LlmCheckObservation {
|
||||
@@ -24,16 +31,6 @@ export interface LlmDefaultsConfig {
|
||||
providerOptions?: Record<string, JSONObject>;
|
||||
}
|
||||
|
||||
export interface LlmExpectConfig {
|
||||
durationMs?: ValueMatcherInput;
|
||||
finishReason?: ValueMatcherInput;
|
||||
headers?: KeyValueExpect;
|
||||
output?: ContentRules;
|
||||
rawFinishReason?: ValueMatcherInput;
|
||||
status?: Array<number | string>;
|
||||
stream?: LlmStreamExpect;
|
||||
usage?: LlmUsageExpect;
|
||||
}
|
||||
export interface LlmHttpMetadata {
|
||||
headers: Record<string, string>;
|
||||
status: number;
|
||||
@@ -80,11 +77,6 @@ export interface LlmOptions {
|
||||
|
||||
export type LlmProvider = "anthropic" | "openai" | "openai-responses";
|
||||
|
||||
export interface LlmStreamExpect {
|
||||
completed?: boolean;
|
||||
firstTokenMs?: ValueMatcherInput;
|
||||
}
|
||||
|
||||
export interface LlmStreamObservation {
|
||||
completed: boolean;
|
||||
firstTokenMs: null | number;
|
||||
@@ -104,18 +96,34 @@ export interface LlmTargetConfig {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface LlmUsageExpect {
|
||||
inputTokens?: ValueMatcherInput;
|
||||
outputTokens?: ValueMatcherInput;
|
||||
totalTokens?: ValueMatcherInput;
|
||||
}
|
||||
|
||||
export interface LlmUsageObservation {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
export interface RawLlmExpectConfig {
|
||||
durationMs?: RawValueExpectation;
|
||||
finishReason?: RawValueExpectation;
|
||||
headers?: RawKeyedExpectations;
|
||||
output?: RawContentExpectations;
|
||||
rawFinishReason?: RawValueExpectation;
|
||||
status?: Array<number | string>;
|
||||
stream?: RawLlmStreamExpect;
|
||||
usage?: RawLlmUsageExpect;
|
||||
}
|
||||
|
||||
export interface RawLlmStreamExpect {
|
||||
completed?: boolean;
|
||||
firstTokenMs?: RawValueExpectation;
|
||||
}
|
||||
|
||||
export interface RawLlmUsageExpect {
|
||||
inputTokens?: RawValueExpectation;
|
||||
outputTokens?: RawValueExpectation;
|
||||
totalTokens?: RawValueExpectation;
|
||||
}
|
||||
|
||||
export interface ResolvedLlmConfig {
|
||||
authToken?: string;
|
||||
headers: Record<string, string>;
|
||||
@@ -130,12 +138,35 @@ export interface ResolvedLlmConfig {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ResolvedLlmExpectConfig {
|
||||
durationMs?: ValueExpectation;
|
||||
finishReason?: ValueExpectation;
|
||||
headers?: KeyedExpectations;
|
||||
output?: ContentExpectations;
|
||||
rawFinishReason?: ValueExpectation;
|
||||
status: Array<number | string>;
|
||||
stream?: ResolvedLlmStreamExpect;
|
||||
usage?: ResolvedLlmUsageExpect;
|
||||
}
|
||||
|
||||
export interface ResolvedLlmStreamExpect {
|
||||
completed: boolean;
|
||||
firstTokenMs?: ValueExpectation;
|
||||
}
|
||||
|
||||
export interface ResolvedLlmTarget extends ResolvedTargetBase {
|
||||
expect?: LlmExpectConfig;
|
||||
expect?: ResolvedLlmExpectConfig;
|
||||
group: string;
|
||||
intervalMs: number;
|
||||
llm: ResolvedLlmConfig;
|
||||
name: null | string;
|
||||
rawExpect?: RawLlmExpectConfig;
|
||||
timeoutMs: number;
|
||||
type: "llm";
|
||||
}
|
||||
|
||||
export interface ResolvedLlmUsageExpect {
|
||||
inputTokens?: ValueExpectation;
|
||||
outputTokens?: ValueExpectation;
|
||||
totalTokens?: ValueExpectation;
|
||||
}
|
||||
|
||||
@@ -3,13 +3,12 @@ import { isBoolean, isNumber, isString } from "es-toolkit";
|
||||
import type { ConfigValidationIssue } from "../../schema/issues";
|
||||
import type { CheckerValidationInput } from "../types";
|
||||
|
||||
import { normalizeExpectMatchers } from "../../expect/normalize";
|
||||
import {
|
||||
isPlainRecord,
|
||||
validateContentRules,
|
||||
validateKeyValueExpect,
|
||||
validateValueMatcher,
|
||||
} from "../../expect/validate-matcher";
|
||||
validateRawContentExpectations,
|
||||
validateRawKeyedExpectations,
|
||||
validateRawValueExpectation,
|
||||
} from "../../expect/validate";
|
||||
import { issue, joinPath } from "../../schema/issues";
|
||||
|
||||
const ALLOWED_MODES = new Set(["http", "stream"]);
|
||||
@@ -73,23 +72,27 @@ function validateLlmExpect(
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const expectPath = joinPath(path, "expect");
|
||||
|
||||
normalizeExpectMatchers(expect, ["durationMs", "finishReason", "rawFinishReason"]);
|
||||
|
||||
if (Array.isArray(expect["status"])) {
|
||||
issues.push(...validateStatusValues(expect["status"], joinPath(expectPath, "status"), targetName));
|
||||
}
|
||||
if (expect["headers"] !== undefined) {
|
||||
issues.push(...validateKeyValueExpect(expect["headers"], joinPath(expectPath, "headers"), targetName));
|
||||
issues.push(
|
||||
...validateRawKeyedExpectations(expect["headers"], joinPath(expectPath, "headers"), targetName, {
|
||||
caseInsensitive: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (expect["output"] !== undefined) {
|
||||
issues.push(...validateContentRules(expect["output"], joinPath(expectPath, "output"), targetName));
|
||||
issues.push(...validateRawContentExpectations(expect["output"], joinPath(expectPath, "output"), targetName));
|
||||
}
|
||||
if (expect["finishReason"] !== undefined) {
|
||||
issues.push(...validateValueMatcher(expect["finishReason"], joinPath(expectPath, "finishReason"), targetName));
|
||||
issues.push(
|
||||
...validateRawValueExpectation(expect["finishReason"], joinPath(expectPath, "finishReason"), targetName),
|
||||
);
|
||||
}
|
||||
if (expect["rawFinishReason"] !== undefined) {
|
||||
issues.push(
|
||||
...validateValueMatcher(expect["rawFinishReason"], joinPath(expectPath, "rawFinishReason"), targetName),
|
||||
...validateRawValueExpectation(expect["rawFinishReason"], joinPath(expectPath, "rawFinishReason"), targetName),
|
||||
);
|
||||
}
|
||||
if (expect["usage"] !== undefined) {
|
||||
@@ -105,7 +108,7 @@ function validateLlmExpect(
|
||||
}
|
||||
}
|
||||
if (expect["durationMs"] !== undefined) {
|
||||
issues.push(...validateValueMatcher(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
|
||||
issues.push(...validateRawValueExpectation(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
|
||||
}
|
||||
|
||||
const allowedKeys = new Set([
|
||||
@@ -289,13 +292,11 @@ function validateStreamExpect(stream: unknown, path: string, targetName?: string
|
||||
if (!isPlainRecord(stream)) return [issue("invalid-type", path, "必须为对象", targetName)];
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
|
||||
normalizeExpectMatchers(stream, ["firstTokenMs"]);
|
||||
|
||||
if (stream["completed"] !== undefined && !isBoolean(stream["completed"])) {
|
||||
issues.push(issue("invalid-type", joinPath(path, "completed"), "必须为布尔值", targetName));
|
||||
}
|
||||
if (stream["firstTokenMs"] !== undefined) {
|
||||
issues.push(...validateValueMatcher(stream["firstTokenMs"], joinPath(path, "firstTokenMs"), targetName));
|
||||
issues.push(...validateRawValueExpectation(stream["firstTokenMs"], joinPath(path, "firstTokenMs"), targetName));
|
||||
}
|
||||
|
||||
const allowedKeys = new Set(["completed", "firstTokenMs"]);
|
||||
@@ -321,11 +322,9 @@ function validateUsageExpect(usage: unknown, path: string, targetName?: string):
|
||||
if (!isPlainRecord(usage)) return [issue("invalid-type", path, "必须为对象", targetName)];
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
|
||||
normalizeExpectMatchers(usage, ["inputTokens", "outputTokens", "totalTokens"]);
|
||||
|
||||
for (const key of ["inputTokens", "outputTokens", "totalTokens"]) {
|
||||
if (usage[key] !== undefined) {
|
||||
issues.push(...validateValueMatcher(usage[key], joinPath(path, key), targetName));
|
||||
issues.push(...validateRawValueExpectation(usage[key], joinPath(path, key), targetName));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@ import { isError } from "es-toolkit";
|
||||
|
||||
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type { ResolvedTcpTarget, TcpExpectConfig, TcpTargetConfig } from "./types";
|
||||
import type { RawTcpExpectConfig, ResolvedTcpExpectConfig, ResolvedTcpTarget, TcpTargetConfig } from "./types";
|
||||
|
||||
import { resolveContentExpectations } from "../../expect/content";
|
||||
import { errorFailure } from "../../expect/failure";
|
||||
import { checkValueMatcher } from "../../expect/matcher";
|
||||
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value";
|
||||
import { parseSize } from "../../utils";
|
||||
import { checkBanner, checkConnected } from "./expect";
|
||||
import { tcpCheckerSchemas } from "./schema";
|
||||
@@ -159,7 +160,7 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
|
||||
}
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const durationResult = checkValueMatcher(durationMs, expect?.durationMs, {
|
||||
const durationResult = checkValueExpectation(durationMs, expect?.durationMs, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
phase: "duration",
|
||||
@@ -212,13 +213,23 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
|
||||
const maxBannerBytes = parseSize(t.tcp.maxBannerBytes ?? tcpDefaults?.maxBannerBytes ?? DEFAULT_MAX_BANNER_BYTES);
|
||||
const bannerReadTimeout = t.tcp.bannerReadTimeout ?? tcpDefaults?.bannerReadTimeout ?? DEFAULT_BANNER_READ_TIMEOUT;
|
||||
|
||||
const rawExpect = target.expect as RawTcpExpectConfig | undefined;
|
||||
const resolvedExpect: ResolvedTcpExpectConfig = rawExpect
|
||||
? {
|
||||
banner: resolveContentExpectations(rawExpect.banner),
|
||||
connected: rawExpect.connected ?? true,
|
||||
durationMs: resolveValueExpectation(rawExpect.durationMs),
|
||||
}
|
||||
: { connected: true };
|
||||
|
||||
return {
|
||||
description: null,
|
||||
expect: target.expect as TcpExpectConfig | undefined,
|
||||
expect: resolvedExpect,
|
||||
group: target.group ?? "default",
|
||||
id: t.id,
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
name: t.name ?? null,
|
||||
rawExpect,
|
||||
tcp: {
|
||||
bannerReadTimeout,
|
||||
host: t.tcp.host,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { ContentRules, ExpectResult } from "../../expect/types";
|
||||
import type { ContentExpectations, ExpectationResult } from "../../expect/types";
|
||||
|
||||
import { checkContentRules } from "../../expect/content";
|
||||
import { checkContentExpectations } from "../../expect/content";
|
||||
import { mismatchFailure } from "../../expect/failure";
|
||||
|
||||
export function checkBanner(banner: string, rules: ContentRules): ExpectResult {
|
||||
return checkContentRules(banner, rules, { path: "banner", phase: "banner" });
|
||||
export function checkBanner(banner: string, expectations: ContentExpectations): ExpectationResult {
|
||||
return checkContentExpectations(banner, expectations, { path: "banner", phase: "banner" });
|
||||
}
|
||||
|
||||
export function checkConnected(connected: boolean, expected: boolean): ExpectResult {
|
||||
export function checkConnected(connected: boolean, expected: boolean): ExpectationResult {
|
||||
if (connected === expected) return { failure: null, matched: true };
|
||||
if (!connected && expected) {
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,11 @@ import { Type } from "@sinclair/typebox";
|
||||
|
||||
import type { CheckerSchemas } from "../types";
|
||||
|
||||
import { createContentRulesSchema, createValueMatcherSchema, sizeSchema } from "../../schema/fragments";
|
||||
import {
|
||||
createRawContentExpectationsSchema,
|
||||
createRawValueExpectationSchema,
|
||||
sizeSchema,
|
||||
} from "../../schema/fragments";
|
||||
|
||||
export const tcpCheckerSchemas: CheckerSchemas = {
|
||||
config: Type.Object(
|
||||
@@ -24,9 +28,9 @@ export const tcpCheckerSchemas: CheckerSchemas = {
|
||||
),
|
||||
expect: Type.Object(
|
||||
{
|
||||
banner: Type.Optional(createContentRulesSchema()),
|
||||
banner: Type.Optional(createRawContentExpectationsSchema()),
|
||||
connected: Type.Optional(Type.Boolean()),
|
||||
durationMs: Type.Optional(createValueMatcherSchema()),
|
||||
durationMs: Type.Optional(createRawValueExpectationSchema()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import type { ContentRules, ValueMatcherInput } from "../../expect/types";
|
||||
import type {
|
||||
ContentExpectations,
|
||||
RawContentExpectations,
|
||||
RawValueExpectation,
|
||||
ValueExpectation,
|
||||
} from "../../expect/types";
|
||||
import type { ResolvedTargetBase } from "../../types";
|
||||
|
||||
export interface RawTcpExpectConfig {
|
||||
banner?: RawContentExpectations;
|
||||
connected?: boolean;
|
||||
durationMs?: RawValueExpectation;
|
||||
}
|
||||
|
||||
export interface ResolvedTcpConfig {
|
||||
bannerReadTimeout: number;
|
||||
host: string;
|
||||
@@ -9,11 +20,18 @@ export interface ResolvedTcpConfig {
|
||||
readBanner: boolean;
|
||||
}
|
||||
|
||||
export interface ResolvedTcpExpectConfig {
|
||||
banner?: ContentExpectations;
|
||||
connected: boolean;
|
||||
durationMs?: ValueExpectation;
|
||||
}
|
||||
|
||||
export interface ResolvedTcpTarget extends ResolvedTargetBase {
|
||||
expect?: TcpExpectConfig;
|
||||
expect?: ResolvedTcpExpectConfig;
|
||||
group: string;
|
||||
intervalMs: number;
|
||||
name: null | string;
|
||||
rawExpect?: RawTcpExpectConfig;
|
||||
tcp: ResolvedTcpConfig;
|
||||
timeoutMs: number;
|
||||
type: "tcp";
|
||||
@@ -24,12 +42,6 @@ export interface TcpDefaultsConfig {
|
||||
maxBannerBytes?: number | string;
|
||||
}
|
||||
|
||||
export interface TcpExpectConfig {
|
||||
banner?: ContentRules;
|
||||
connected?: boolean;
|
||||
durationMs?: ValueMatcherInput;
|
||||
}
|
||||
|
||||
export interface TcpTargetConfig {
|
||||
bannerReadTimeout?: number;
|
||||
host: string;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { isNumber, isPlainObject, isString } from "es-toolkit";
|
||||
import { isNumber, isString } from "es-toolkit";
|
||||
|
||||
import type { ConfigValidationIssue } from "../../schema/issues";
|
||||
import type { CheckerValidationInput } from "../types";
|
||||
|
||||
import { normalizeExpectMatchers } from "../../expect/normalize";
|
||||
import { validateContentRules, validateValueMatcher } from "../../expect/validate-matcher";
|
||||
import { isPlainRecord, validateRawContentExpectations, validateRawValueExpectation } from "../../expect/validate";
|
||||
import { issue, joinPath } from "../../schema/issues";
|
||||
|
||||
export function validateTcpConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
|
||||
@@ -14,7 +13,7 @@ export function validateTcpConfig(input: CheckerValidationInput): ConfigValidati
|
||||
|
||||
for (let i = 0; i < input.targets.length; i++) {
|
||||
const target = input.targets[i] as unknown;
|
||||
if (!isPlainObject(target)) continue;
|
||||
if (!isPlainRecord(target)) continue;
|
||||
if (target["type"] !== "tcp") continue;
|
||||
issues.push(...validateTcpTarget(target, `targets[${i}]`));
|
||||
}
|
||||
@@ -34,7 +33,7 @@ function isNonNegativeFiniteNumber(value: unknown): boolean {
|
||||
function validateTcpDefaults(input: CheckerValidationInput): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const defaults = input.defaults["tcp"];
|
||||
if (defaults === undefined || defaults === null || !isPlainObject(defaults)) return issues;
|
||||
if (defaults === undefined || defaults === null || !isPlainRecord(defaults)) return issues;
|
||||
|
||||
const targetName = "defaults.tcp";
|
||||
|
||||
@@ -72,18 +71,16 @@ function validateTcpExpect(
|
||||
): ConfigValidationIssue[] {
|
||||
const targetName = getTargetName(target);
|
||||
const expect = target["expect"];
|
||||
if (expect === undefined || expect === null || !isPlainObject(expect)) return [];
|
||||
if (expect === undefined || expect === null || !isPlainRecord(expect)) return [];
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const expectPath = joinPath(path, "expect");
|
||||
|
||||
normalizeExpectMatchers(expect, ["durationMs"]);
|
||||
|
||||
if (expect["connected"] !== undefined && typeof expect["connected"] !== "boolean") {
|
||||
issues.push(issue("invalid-type", joinPath(expectPath, "connected"), "必须为布尔值", targetName));
|
||||
}
|
||||
|
||||
if (expect["durationMs"] !== undefined) {
|
||||
issues.push(...validateValueMatcher(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
|
||||
issues.push(...validateRawValueExpectation(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
|
||||
}
|
||||
|
||||
if (expect["banner"] !== undefined) {
|
||||
@@ -92,7 +89,7 @@ function validateTcpExpect(
|
||||
issue("invalid-value", joinPath(expectPath, "banner"), "banner 断言需要启用 tcp.readBanner", targetName),
|
||||
);
|
||||
} else {
|
||||
issues.push(...validateContentRules(expect["banner"], joinPath(expectPath, "banner"), targetName));
|
||||
issues.push(...validateRawContentExpectations(expect["banner"], joinPath(expectPath, "banner"), targetName));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +108,7 @@ function validateTcpTarget(target: Record<string, unknown>, path: string): Confi
|
||||
const targetName = getTargetName(target);
|
||||
const tcp = target["tcp"];
|
||||
|
||||
if (!isPlainObject(tcp)) {
|
||||
if (!isPlainRecord(tcp)) {
|
||||
issues.push(issue("required", joinPath(path, "tcp"), "缺少 tcp 配置分组", targetName));
|
||||
issues.push(...validateTcpExpect(target, path, false));
|
||||
return issues;
|
||||
|
||||
@@ -2,10 +2,17 @@ import { isError } from "es-toolkit";
|
||||
|
||||
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type { ResolvedUdpTarget, UdpDefaultsConfig, UdpExpectConfig, UdpTargetConfig } from "./types";
|
||||
import type {
|
||||
RawUdpExpectConfig,
|
||||
ResolvedUdpExpectConfig,
|
||||
ResolvedUdpTarget,
|
||||
UdpDefaultsConfig,
|
||||
UdpTargetConfig,
|
||||
} from "./types";
|
||||
|
||||
import { resolveContentExpectations } from "../../expect/content";
|
||||
import { errorFailure } from "../../expect/failure";
|
||||
import { checkValueMatcher } from "../../expect/matcher";
|
||||
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value";
|
||||
import { parseSize } from "../../utils";
|
||||
import { decodePayload, encodeResponse } from "./encoding";
|
||||
import { checkResponded, checkResponseSize, checkResponseText, checkSourceHost, checkSourcePort } from "./expect";
|
||||
@@ -111,7 +118,7 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
};
|
||||
}
|
||||
|
||||
const durationResult = checkValueMatcher(durationMs, expect?.durationMs, {
|
||||
const durationResult = checkValueExpectation(durationMs, expect?.durationMs, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
phase: "duration",
|
||||
@@ -255,7 +262,7 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
}
|
||||
}
|
||||
|
||||
const durationResult = checkValueMatcher(durationMs, expect?.durationMs, {
|
||||
const durationResult = checkValueExpectation(durationMs, expect?.durationMs, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
phase: "duration",
|
||||
@@ -305,13 +312,26 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
t.udp.maxResponseBytes ?? udpDefaults?.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES,
|
||||
);
|
||||
|
||||
const rawExpect = target.expect as RawUdpExpectConfig | undefined;
|
||||
const resolvedExpect: ResolvedUdpExpectConfig = rawExpect
|
||||
? {
|
||||
durationMs: resolveValueExpectation(rawExpect.durationMs),
|
||||
responded: rawExpect.responded ?? true,
|
||||
response: resolveContentExpectations(rawExpect.response),
|
||||
responseSize: resolveValueExpectation(rawExpect.responseSize),
|
||||
sourceHost: resolveValueExpectation(rawExpect.sourceHost),
|
||||
sourcePort: resolveValueExpectation(rawExpect.sourcePort),
|
||||
}
|
||||
: { responded: true };
|
||||
|
||||
return {
|
||||
description: null,
|
||||
expect: target.expect as UdpExpectConfig | undefined,
|
||||
expect: resolvedExpect,
|
||||
group: target.group ?? "default",
|
||||
id: t.id,
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
name: t.name ?? null,
|
||||
rawExpect,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
type: "udp",
|
||||
udp: {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ContentRules, ExpectResult, ValueMatcherInput } from "../../expect/types";
|
||||
import type { ContentExpectations, ExpectationResult, ValueExpectation } from "../../expect/types";
|
||||
|
||||
import { checkContentRules } from "../../expect/content";
|
||||
import { checkContentExpectations } from "../../expect/content";
|
||||
import { mismatchFailure } from "../../expect/failure";
|
||||
import { checkValueMatcher } from "../../expect/matcher";
|
||||
import { checkValueExpectation } from "../../expect/value";
|
||||
|
||||
export function checkResponded(responded: boolean, expected: boolean): ExpectResult {
|
||||
export function checkResponded(responded: boolean, expected: boolean): ExpectationResult {
|
||||
if (responded === expected) return { failure: null, matched: true };
|
||||
if (!responded && expected) {
|
||||
return {
|
||||
@@ -18,28 +18,28 @@ export function checkResponded(responded: boolean, expected: boolean): ExpectRes
|
||||
};
|
||||
}
|
||||
|
||||
export function checkResponseSize(size: number, matcher: ValueMatcherInput): ExpectResult {
|
||||
return checkValueMatcher(size, matcher, {
|
||||
export function checkResponseSize(size: number, matcher: ValueExpectation): ExpectationResult {
|
||||
return checkValueExpectation(size, matcher, {
|
||||
message: "响应大小不满足条件",
|
||||
path: "responseSize",
|
||||
phase: "responseSize",
|
||||
});
|
||||
}
|
||||
|
||||
export function checkResponseText(text: string, rules: ContentRules): ExpectResult {
|
||||
return checkContentRules(text, rules, { path: "response", phase: "response" });
|
||||
export function checkResponseText(text: string, expectations: ContentExpectations): ExpectationResult {
|
||||
return checkContentExpectations(text, expectations, { path: "response", phase: "response" });
|
||||
}
|
||||
|
||||
export function checkSourceHost(actual: string, matcher: ValueMatcherInput): ExpectResult {
|
||||
return checkValueMatcher(actual, matcher, {
|
||||
export function checkSourceHost(actual: string, matcher: ValueExpectation): ExpectationResult {
|
||||
return checkValueExpectation(actual, matcher, {
|
||||
message: "响应来源地址不满足条件",
|
||||
path: "sourceHost",
|
||||
phase: "sourceHost",
|
||||
});
|
||||
}
|
||||
|
||||
export function checkSourcePort(actual: number, matcher: ValueMatcherInput): ExpectResult {
|
||||
return checkValueMatcher(actual, matcher, {
|
||||
export function checkSourcePort(actual: number, matcher: ValueExpectation): ExpectationResult {
|
||||
return checkValueExpectation(actual, matcher, {
|
||||
message: "响应来源端口不满足条件",
|
||||
path: "sourcePort",
|
||||
phase: "sourcePort",
|
||||
|
||||
@@ -2,7 +2,11 @@ import { Type } from "@sinclair/typebox";
|
||||
|
||||
import type { CheckerSchemas } from "../types";
|
||||
|
||||
import { createContentRulesSchema, createValueMatcherSchema, sizeSchema } from "../../schema/fragments";
|
||||
import {
|
||||
createRawContentExpectationsSchema,
|
||||
createRawValueExpectationSchema,
|
||||
sizeSchema,
|
||||
} from "../../schema/fragments";
|
||||
|
||||
export const udpCheckerSchemas: CheckerSchemas = {
|
||||
config: Type.Object(
|
||||
@@ -26,12 +30,12 @@ export const udpCheckerSchemas: CheckerSchemas = {
|
||||
),
|
||||
expect: Type.Object(
|
||||
{
|
||||
durationMs: Type.Optional(createValueMatcherSchema()),
|
||||
durationMs: Type.Optional(createRawValueExpectationSchema()),
|
||||
responded: Type.Optional(Type.Boolean()),
|
||||
response: Type.Optional(createContentRulesSchema()),
|
||||
responseSize: Type.Optional(createValueMatcherSchema()),
|
||||
sourceHost: Type.Optional(createValueMatcherSchema()),
|
||||
sourcePort: Type.Optional(createValueMatcherSchema()),
|
||||
response: Type.Optional(createRawContentExpectationsSchema()),
|
||||
responseSize: Type.Optional(createRawValueExpectationSchema()),
|
||||
sourceHost: Type.Optional(createRawValueExpectationSchema()),
|
||||
sourcePort: Type.Optional(createRawValueExpectationSchema()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import type { ContentRules, ValueMatcherInput } from "../../expect/types";
|
||||
import type {
|
||||
ContentExpectations,
|
||||
RawContentExpectations,
|
||||
RawValueExpectation,
|
||||
ValueExpectation,
|
||||
} from "../../expect/types";
|
||||
import type { ResolvedTargetBase } from "../../types";
|
||||
|
||||
export interface RawUdpExpectConfig {
|
||||
durationMs?: RawValueExpectation;
|
||||
responded?: boolean;
|
||||
response?: RawContentExpectations;
|
||||
responseSize?: RawValueExpectation;
|
||||
sourceHost?: RawValueExpectation;
|
||||
sourcePort?: RawValueExpectation;
|
||||
}
|
||||
|
||||
export interface ResolvedUdpConfig {
|
||||
encoding: UdpEncoding;
|
||||
host: string;
|
||||
@@ -10,11 +24,21 @@ export interface ResolvedUdpConfig {
|
||||
responseEncoding: UdpEncoding;
|
||||
}
|
||||
|
||||
export interface ResolvedUdpExpectConfig {
|
||||
durationMs?: ValueExpectation;
|
||||
responded: boolean;
|
||||
response?: ContentExpectations;
|
||||
responseSize?: ValueExpectation;
|
||||
sourceHost?: ValueExpectation;
|
||||
sourcePort?: ValueExpectation;
|
||||
}
|
||||
|
||||
export interface ResolvedUdpTarget extends ResolvedTargetBase {
|
||||
expect?: UdpExpectConfig;
|
||||
expect?: ResolvedUdpExpectConfig;
|
||||
group: string;
|
||||
intervalMs: number;
|
||||
name: null | string;
|
||||
rawExpect?: RawUdpExpectConfig;
|
||||
timeoutMs: number;
|
||||
type: "udp";
|
||||
udp: ResolvedUdpConfig;
|
||||
@@ -28,15 +52,6 @@ export interface UdpDefaultsConfig {
|
||||
|
||||
export type UdpEncoding = "base64" | "hex" | "text";
|
||||
|
||||
export interface UdpExpectConfig {
|
||||
durationMs?: ValueMatcherInput;
|
||||
responded?: boolean;
|
||||
response?: ContentRules;
|
||||
responseSize?: ValueMatcherInput;
|
||||
sourceHost?: ValueMatcherInput;
|
||||
sourcePort?: ValueMatcherInput;
|
||||
}
|
||||
|
||||
export interface UdpTargetConfig {
|
||||
encoding?: UdpEncoding;
|
||||
host: string;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { isNumber, isPlainObject, isString } from "es-toolkit";
|
||||
import { isNumber, isString } from "es-toolkit";
|
||||
|
||||
import type { ConfigValidationIssue } from "../../schema/issues";
|
||||
import type { CheckerValidationInput } from "../types";
|
||||
|
||||
import { normalizeExpectMatchers } from "../../expect/normalize";
|
||||
import { validateContentRules, validateValueMatcher } from "../../expect/validate-matcher";
|
||||
import { isPlainRecord, validateRawContentExpectations, validateRawValueExpectation } from "../../expect/validate";
|
||||
import { issue, joinPath } from "../../schema/issues";
|
||||
|
||||
const VALID_ENCODINGS = new Set(["base64", "hex", "text"]);
|
||||
@@ -16,7 +15,7 @@ export function validateUdpConfig(input: CheckerValidationInput): ConfigValidati
|
||||
|
||||
for (let i = 0; i < input.targets.length; i++) {
|
||||
const target = input.targets[i] as unknown;
|
||||
if (!isPlainObject(target)) continue;
|
||||
if (!isPlainRecord(target)) continue;
|
||||
if (target["type"] !== "udp") continue;
|
||||
issues.push(...validateUdpTarget(target, `targets[${i}]`));
|
||||
}
|
||||
@@ -48,7 +47,7 @@ function validateSize(value: unknown, path: string, targetName: string | undefin
|
||||
function validateUdpDefaults(input: CheckerValidationInput): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const defaults = input.defaults["udp"];
|
||||
if (defaults === undefined || defaults === null || !isPlainObject(defaults)) return issues;
|
||||
if (defaults === undefined || defaults === null || !isPlainRecord(defaults)) return issues;
|
||||
|
||||
const targetName = "defaults.udp";
|
||||
|
||||
@@ -71,35 +70,35 @@ function validateUdpDefaults(input: CheckerValidationInput): ConfigValidationIss
|
||||
function validateUdpExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||
const targetName = getTargetName(target);
|
||||
const expect = target["expect"];
|
||||
if (expect === undefined || expect === null || !isPlainObject(expect)) return [];
|
||||
if (expect === undefined || expect === null || !isPlainRecord(expect)) return [];
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const expectPath = joinPath(path, "expect");
|
||||
const responded: unknown = expect["responded"];
|
||||
|
||||
normalizeExpectMatchers(expect, ["durationMs", "responseSize", "sourceHost", "sourcePort"]);
|
||||
|
||||
if (responded !== undefined && typeof responded !== "boolean") {
|
||||
issues.push(issue("invalid-type", joinPath(expectPath, "responded"), "必须为布尔值", targetName));
|
||||
}
|
||||
|
||||
if (expect["durationMs"] !== undefined) {
|
||||
issues.push(...validateValueMatcher(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
|
||||
issues.push(...validateRawValueExpectation(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
|
||||
}
|
||||
|
||||
if (expect["response"] !== undefined) {
|
||||
issues.push(...validateContentRules(expect["response"], joinPath(expectPath, "response"), targetName));
|
||||
issues.push(...validateRawContentExpectations(expect["response"], joinPath(expectPath, "response"), targetName));
|
||||
}
|
||||
|
||||
if (expect["responseSize"] !== undefined) {
|
||||
issues.push(...validateValueMatcher(expect["responseSize"], joinPath(expectPath, "responseSize"), targetName));
|
||||
issues.push(
|
||||
...validateRawValueExpectation(expect["responseSize"], joinPath(expectPath, "responseSize"), targetName),
|
||||
);
|
||||
}
|
||||
|
||||
if (expect["sourceHost"] !== undefined) {
|
||||
issues.push(...validateValueMatcher(expect["sourceHost"], joinPath(expectPath, "sourceHost"), targetName));
|
||||
issues.push(...validateRawValueExpectation(expect["sourceHost"], joinPath(expectPath, "sourceHost"), targetName));
|
||||
}
|
||||
|
||||
if (expect["sourcePort"] !== undefined) {
|
||||
issues.push(...validateValueMatcher(expect["sourcePort"], joinPath(expectPath, "sourcePort"), targetName));
|
||||
issues.push(...validateRawValueExpectation(expect["sourcePort"], joinPath(expectPath, "sourcePort"), targetName));
|
||||
}
|
||||
|
||||
const respondedFalse = responded === false;
|
||||
@@ -141,7 +140,7 @@ function validateUdpTarget(target: Record<string, unknown>, path: string): Confi
|
||||
const targetName = getTargetName(target);
|
||||
const udp = target["udp"];
|
||||
|
||||
if (!isPlainObject(udp)) {
|
||||
if (!isPlainRecord(udp)) {
|
||||
issues.push(issue("required", joinPath(path, "udp"), "缺少 udp 配置分组", targetName));
|
||||
issues.push(...validateUdpExpect(target, path));
|
||||
return issues;
|
||||
|
||||
@@ -5,9 +5,10 @@ import { Type } from "@sinclair/typebox";
|
||||
import type { CheckerDefinition } from "../runner/types";
|
||||
|
||||
import {
|
||||
createContentRulesSchema,
|
||||
createKeyValueExpectSchema,
|
||||
createValueMatcherSchema,
|
||||
createRawContentExpectationsSchema,
|
||||
createRawKeyedExpectationsSchema,
|
||||
createRawValueExpectationSchema,
|
||||
createValueMatcherObjectSchema,
|
||||
durationSchema,
|
||||
variableValueSchema,
|
||||
} from "./fragments";
|
||||
@@ -18,9 +19,10 @@ export function createExternalProbeConfigSchema(checkers: CheckerDefinition[]):
|
||||
$id: "https://dial.local/probe-config.schema.json",
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
definitions: {
|
||||
ContentRules: cloneSchema(createContentRulesSchema()),
|
||||
KeyValueExpect: cloneSchema(createKeyValueExpectSchema()),
|
||||
ValueMatcher: cloneSchema(createValueMatcherSchema()),
|
||||
ContentExpectations: cloneSchema(createRawContentExpectationsSchema()),
|
||||
KeyedExpectations: cloneSchema(createRawKeyedExpectationsSchema()),
|
||||
ValueExpectation: cloneSchema(createRawValueExpectationSchema()),
|
||||
ValueMatcher: cloneSchema(createValueMatcherObjectSchema()),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ import type { JsonValue } from "./types";
|
||||
|
||||
export const HTTP_METHODS = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] as const;
|
||||
|
||||
export const MatcherKeys = ["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "regex"] as const;
|
||||
|
||||
export const durationSchema = Type.String();
|
||||
|
||||
export const httpMethodSchema = Type.Union(
|
||||
@@ -43,7 +41,7 @@ export const stringMapSchema = Type.Unsafe<Record<string, string>>({
|
||||
type: "object",
|
||||
});
|
||||
|
||||
export function createContentRulesSchema(): TSchema {
|
||||
export function createRawContentExpectationsSchema(): TSchema {
|
||||
return Type.Array(
|
||||
Type.Object(
|
||||
{
|
||||
@@ -66,21 +64,23 @@ export function createContentRulesSchema(): TSchema {
|
||||
);
|
||||
}
|
||||
|
||||
export function createKeyValueExpectSchema(): TSchema {
|
||||
export function createRawKeyedExpectationsSchema(): TSchema {
|
||||
return Type.Unsafe<Record<string, unknown>>({
|
||||
additionalProperties: {
|
||||
anyOf: [jsonValueSchema, createValueMatcherSchema()],
|
||||
},
|
||||
additionalProperties: createRawValueExpectationSchema(),
|
||||
type: "object",
|
||||
});
|
||||
}
|
||||
|
||||
export function createValueMatcherSchema(): TSchema {
|
||||
export function createRawValueExpectationSchema(): TSchema {
|
||||
return Type.Unsafe({
|
||||
anyOf: [primitiveValueSchema, Type.Object(matcherProperties(), { additionalProperties: false, minProperties: 1 })],
|
||||
});
|
||||
}
|
||||
|
||||
export function createValueMatcherObjectSchema(): TSchema {
|
||||
return Type.Object(matcherProperties(), { additionalProperties: false, minProperties: 1 });
|
||||
}
|
||||
|
||||
export function matcherProperties(): Record<string, TSchema> {
|
||||
return {
|
||||
contains: Type.Optional(Type.String()),
|
||||
|
||||
@@ -352,7 +352,7 @@ export class ProbeStore {
|
||||
const serialized = checkerRegistry.get(t.type).serialize(t);
|
||||
const target = serialized.target;
|
||||
const config = serialized.config;
|
||||
const expect = t.expect ? JSON.stringify(t.expect) : null;
|
||||
const expect = t.rawExpect ? JSON.stringify(t.rawExpect) : null;
|
||||
|
||||
if (existingIds.has(t.id)) {
|
||||
updateStmt.run(t.name, t.description, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group, t.id);
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface ResolvedTargetBase {
|
||||
id: string;
|
||||
intervalMs: number;
|
||||
name: null | string;
|
||||
rawExpect?: unknown;
|
||||
timeoutMs: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user