1
0

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:
2026-05-20 16:12:48 +08:00
parent 6098be2d9e
commit 60a54b483f
90 changed files with 2487 additions and 1493 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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