feat: 重构配置生命周期为 Authoring/Normalized/Resolved 三层
将变量替换和 expect 简写展开统一放入 Normalized 阶段, 运行时 AJV 使用 Normalized schema,导出 schema 面向 Authoring Config。 主要变更: - 新增 normalizer.ts 实现 normalizeAuthoringConfig() - 拆分 Authoring/Normalized 双 schema,checker 接口支持 authoring/normalized 片段 - config-loader 流程:normalize → Normalized AJV → semantic → resolve - validator 兼容层自动分派 raw/normalized expect 形态 - 删除 rawExpect,store.expect 列写入 null - Authoring schema 对 integer/boolean/enum 字段接受变量引用 - 修复 DB/HTTP validate 入口守卫和 LLM options integer 变量引用 - 优化 compact() 避免 undefined 覆盖隐患 - 移除 content.ts 恒为 true 的前置条件 - 同步 5 个主规范并归档 change
This commit is contained in:
@@ -13,12 +13,12 @@ import type {
|
||||
ServerStorageConfig,
|
||||
} from "./types";
|
||||
|
||||
import { normalizeAuthoringConfig } from "./normalizer";
|
||||
import { checkerRegistry } from "./runner";
|
||||
import { issue, throwConfigIssues } from "./schema/issues";
|
||||
import { asValidatedConfig, type RawProbeConfig } from "./schema/types";
|
||||
import { asValidatedConfig, type NormalizedProbeConfig } from "./schema/types";
|
||||
import { validateProbeConfigContract } from "./schema/validate";
|
||||
import { parseDuration, parseSize } from "./utils";
|
||||
import { resolveVariables } from "./variables";
|
||||
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
const DEFAULT_PORT = 3000;
|
||||
@@ -60,17 +60,17 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
|
||||
throw new Error("配置文件内容为空或格式无效");
|
||||
}
|
||||
|
||||
const variableResult = resolveVariables(parsed);
|
||||
if (variableResult.issues.length > 0) {
|
||||
throwConfigIssues(dedupeIssues(variableResult.issues));
|
||||
const normalizeResult = normalizeAuthoringConfig(parsed);
|
||||
if (normalizeResult.issues.length > 0) {
|
||||
throwConfigIssues(dedupeIssues(normalizeResult.issues));
|
||||
}
|
||||
|
||||
const resolvedVariablesConfig = variableResult.config;
|
||||
const contractResult = validateProbeConfigContract(resolvedVariablesConfig, checkerRegistry);
|
||||
if (contractResult.config === null && !canRunSemanticValidation(resolvedVariablesConfig)) {
|
||||
const normalizedConfig = normalizeResult.config;
|
||||
const contractResult = validateProbeConfigContract(normalizedConfig, checkerRegistry);
|
||||
if (contractResult.config === null && !canRunSemanticValidation(normalizedConfig)) {
|
||||
throwConfigIssues(contractResult.issues);
|
||||
}
|
||||
const semanticInput = (contractResult.config ?? resolvedVariablesConfig) as RawProbeConfig;
|
||||
const semanticInput = (contractResult.config ?? normalizedConfig) as NormalizedProbeConfig;
|
||||
const validationIssues = validateConfig(semanticInput);
|
||||
|
||||
const allIssues = [...contractResult.issues, ...validationIssues];
|
||||
@@ -208,7 +208,7 @@ function resolveTarget(
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
|
||||
function validateConfig(config: NormalizedProbeConfig): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
if (!Array.isArray(config.targets) || config.targets.length === 0) {
|
||||
issues.push(issue("required", "targets", "配置文件必须包含至少一个 target"));
|
||||
|
||||
@@ -22,7 +22,7 @@ import type {
|
||||
} from "./types";
|
||||
|
||||
import { errorFailure, mismatchFailure } from "./failure";
|
||||
import { CONTENT_EXTRACTOR_KEY_SET, MATCHER_KEY_SET } from "./keys";
|
||||
import { MATCHER_KEY_SET } from "./keys";
|
||||
import { applyValueMatcher, displayValueExpectation, evaluateJsonPath } from "./value";
|
||||
|
||||
type ParsedJsonResult = { error: string; ok: false } | { ok: true; value: unknown };
|
||||
@@ -238,7 +238,7 @@ function resolveContentExpectation(raw: RawContentExpectation): ContentExpectati
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
|
||||
if (CONTENT_EXTRACTOR_KEY_SET.has("json") && isPlainObject(record["json"])) {
|
||||
if (isPlainObject(record["json"])) {
|
||||
const json = record["json"] as RawContentJsonExpectation;
|
||||
return {
|
||||
kind: "json",
|
||||
|
||||
@@ -58,6 +58,7 @@ export function validateRawKeyedExpectations(
|
||||
targetName?: string,
|
||||
options?: { caseInsensitive?: boolean },
|
||||
): ConfigValidationIssue[] {
|
||||
if (Array.isArray(value)) return validateNormalizedKeyedExpectations(value, path, targetName, options);
|
||||
if (!isPlainRecord(value)) return [issue("invalid-type", path, "必须为对象", targetName)];
|
||||
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
@@ -196,12 +197,76 @@ function validateMatcherValue(key: string, value: unknown, path: string, targetN
|
||||
}
|
||||
}
|
||||
|
||||
function validateNormalizedContentExpectation(
|
||||
expectation: Record<string, unknown>,
|
||||
path: string,
|
||||
targetName?: string,
|
||||
): ConfigValidationIssue[] {
|
||||
const kind = expectation["kind"];
|
||||
const matcherPath = joinPath(path, "matcher");
|
||||
const issues = validateRawValueExpectation(expectation["matcher"], matcherPath, targetName);
|
||||
switch (kind) {
|
||||
case "css":
|
||||
if (!isString(expectation["selector"]) || expectation["selector"].trim() === "") {
|
||||
issues.push(issue("invalid-type", joinPath(path, "selector"), "必须为非空字符串", targetName));
|
||||
}
|
||||
if ("attr" in expectation && !isString(expectation["attr"])) {
|
||||
issues.push(issue("invalid-type", joinPath(path, "attr"), "必须为字符串", targetName));
|
||||
}
|
||||
return issues;
|
||||
case "json":
|
||||
return isString(expectation["path"])
|
||||
? [...issues, ...validateJsonPath(expectation["path"], path, targetName)]
|
||||
: [...issues, issue("invalid-type", joinPath(path, "path"), "必须为字符串", targetName)];
|
||||
case "value":
|
||||
return issues;
|
||||
case "xpath":
|
||||
return isString(expectation["path"])
|
||||
? [...issues, ...validateXpathExpectation({ path: expectation["path"] }, path, targetName)]
|
||||
: [...issues, issue("invalid-type", joinPath(path, "path"), "必须为非空字符串", targetName)];
|
||||
default:
|
||||
return [...issues, issue("invalid-type", joinPath(path, "kind"), "必须为 value、json、css 或 xpath", targetName)];
|
||||
}
|
||||
}
|
||||
|
||||
function validateNormalizedKeyedExpectations(
|
||||
value: unknown[],
|
||||
path: string,
|
||||
targetName?: string,
|
||||
options?: { caseInsensitive?: boolean },
|
||||
): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const seen = new Map<string, string>();
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const itemPath = `${path}[${i}]`;
|
||||
const item = value[i];
|
||||
if (!isPlainRecord(item)) {
|
||||
issues.push(issue("invalid-type", itemPath, "必须为对象", targetName));
|
||||
continue;
|
||||
}
|
||||
if (!isString(item["key"])) {
|
||||
issues.push(issue("invalid-type", joinPath(itemPath, "key"), "必须为字符串", targetName));
|
||||
} else if (options?.caseInsensitive) {
|
||||
const normalized = item["key"].toLowerCase();
|
||||
const prev = seen.get(normalized);
|
||||
if (prev !== undefined) {
|
||||
issues.push(issue("duplicate-key", joinPath(itemPath, "key"), `与 "${prev}" 大小写归一化后重复`, targetName));
|
||||
} else {
|
||||
seen.set(normalized, item["key"]);
|
||||
}
|
||||
}
|
||||
issues.push(...validateRawValueExpectation(item["matcher"], joinPath(itemPath, "matcher"), targetName));
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
function validateRawContentExpectation(
|
||||
expectation: unknown,
|
||||
path: string,
|
||||
targetName?: string,
|
||||
): ConfigValidationIssue[] {
|
||||
if (!isPlainRecord(expectation)) return [issue("invalid-type", path, "必须为对象", targetName)];
|
||||
if (isString(expectation["kind"])) return validateNormalizedContentExpectation(expectation, path, targetName);
|
||||
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const extractors = Object.keys(expectation).filter((key) => CONTENT_EXTRACTOR_KEY_SET.has(key));
|
||||
|
||||
187
src/server/checker/normalizer.ts
Normal file
187
src/server/checker/normalizer.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { isPlainObject } from "es-toolkit";
|
||||
|
||||
import type { ConfigValidationIssue } from "./schema/issues";
|
||||
import type { AuthoringProbeConfig, NormalizedProbeConfig } from "./schema/types";
|
||||
import type { RawTargetConfig } from "./types";
|
||||
|
||||
import { resolveContentExpectations } from "./expect/content";
|
||||
import { CONTENT_EXTRACTOR_KEY_SET, MATCHER_KEY_SET } from "./expect/keys";
|
||||
import { isValueMatcherObject, isValueMatcherPrimitive, resolveValueExpectation } from "./expect/value";
|
||||
import { resolveVariables } from "./variables";
|
||||
|
||||
type ExpectRecord = Record<string, unknown>;
|
||||
|
||||
export function normalizeAuthoringConfig(config: unknown): {
|
||||
config: unknown;
|
||||
issues: ConfigValidationIssue[];
|
||||
} {
|
||||
const variableResult = resolveVariables(config);
|
||||
if (!isPlainObject(variableResult.config)) {
|
||||
return variableResult;
|
||||
}
|
||||
|
||||
const normalized = { ...(variableResult.config as Record<string, unknown>) };
|
||||
delete normalized["variables"];
|
||||
if (Array.isArray(normalized["targets"])) {
|
||||
normalized["targets"] = normalized["targets"].map((target) => normalizeTarget(target));
|
||||
}
|
||||
|
||||
return { config: normalized, issues: variableResult.issues };
|
||||
}
|
||||
|
||||
function canNormalizeContentEntry(value: unknown): boolean {
|
||||
if (!isPlainObject(value)) return false;
|
||||
const keys = Object.keys(value);
|
||||
const extractorKeys = keys.filter((key) => CONTENT_EXTRACTOR_KEY_SET.has(key));
|
||||
const matcherKeys = keys.filter((key) => MATCHER_KEY_SET.has(key));
|
||||
if (extractorKeys.length === 0) return matcherKeys.length > 0 && matcherKeys.length === keys.length;
|
||||
if (extractorKeys.length !== 1 || matcherKeys.length > 0 || keys.length !== 1) return false;
|
||||
return isPlainObject((value as ExpectRecord)[extractorKeys[0]!]);
|
||||
}
|
||||
|
||||
function compact(original: ExpectRecord, overrides: ExpectRecord): ExpectRecord {
|
||||
const result: ExpectRecord = {};
|
||||
for (const [key, value] of Object.entries(original)) {
|
||||
if (value !== undefined) result[key] = value;
|
||||
}
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
if (value !== undefined) result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeCommandExpect(raw: ExpectRecord): ExpectRecord {
|
||||
return compact(raw, {
|
||||
durationMs: normalizeValue(raw["durationMs"]),
|
||||
exitCode: raw["exitCode"],
|
||||
stderr: normalizeContent(raw["stderr"]),
|
||||
stdout: normalizeContent(raw["stdout"]),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeContent(value: unknown): unknown {
|
||||
if (value === undefined) return undefined;
|
||||
if (!Array.isArray(value)) return value;
|
||||
return (value as unknown[]).map((entry): unknown => {
|
||||
if (!canNormalizeContentEntry(entry)) return entry;
|
||||
const resolved = resolveContentExpectations([entry] as never);
|
||||
return resolved?.[0];
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeDbExpect(raw: ExpectRecord): ExpectRecord {
|
||||
return compact(raw, {
|
||||
durationMs: normalizeValue(raw["durationMs"]),
|
||||
result: normalizeContent(raw["result"]),
|
||||
rowCount: normalizeValue(raw["rowCount"]),
|
||||
rows: Array.isArray(raw["rows"]) ? raw["rows"].map((row) => normalizeKeyed(row)) : raw["rows"],
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeExpect(type: string, expect: unknown): unknown {
|
||||
if (!isPlainObject(expect)) return expect;
|
||||
const raw = expect as ExpectRecord;
|
||||
switch (type) {
|
||||
case "cmd":
|
||||
return normalizeCommandExpect(raw);
|
||||
case "db":
|
||||
return normalizeDbExpect(raw);
|
||||
case "http":
|
||||
return normalizeHttpExpect(raw);
|
||||
case "icmp":
|
||||
return normalizeIcmpExpect(raw);
|
||||
case "llm":
|
||||
return normalizeLlmExpect(raw);
|
||||
case "tcp":
|
||||
return normalizeTcpExpect(raw);
|
||||
case "udp":
|
||||
return normalizeUdpExpect(raw);
|
||||
default:
|
||||
return expect;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHttpExpect(raw: ExpectRecord): ExpectRecord {
|
||||
return compact(raw, {
|
||||
body: normalizeContent(raw["body"]),
|
||||
durationMs: normalizeValue(raw["durationMs"]),
|
||||
headers: normalizeKeyed(raw["headers"]),
|
||||
status: raw["status"],
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeIcmpExpect(raw: ExpectRecord): ExpectRecord {
|
||||
return compact(raw, {
|
||||
alive: raw["alive"],
|
||||
avgLatencyMs: normalizeValue(raw["avgLatencyMs"]),
|
||||
durationMs: normalizeValue(raw["durationMs"]),
|
||||
maxLatencyMs: normalizeValue(raw["maxLatencyMs"]),
|
||||
packetLossPercent: normalizeValue(raw["packetLossPercent"]),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeKeyed(value: unknown): unknown {
|
||||
if (value === undefined) return undefined;
|
||||
if (!isPlainObject(value)) return value;
|
||||
return Object.entries(value as ExpectRecord).map(([key, item]) => ({ key, matcher: normalizeValue(item) }));
|
||||
}
|
||||
|
||||
function normalizeLlmExpect(raw: ExpectRecord): ExpectRecord {
|
||||
return compact(raw, {
|
||||
durationMs: normalizeValue(raw["durationMs"]),
|
||||
finishReason: normalizeValue(raw["finishReason"]),
|
||||
headers: normalizeKeyed(raw["headers"]),
|
||||
output: normalizeContent(raw["output"]),
|
||||
rawFinishReason: normalizeValue(raw["rawFinishReason"]),
|
||||
status: raw["status"],
|
||||
stream: isPlainObject(raw["stream"])
|
||||
? compact(raw["stream"] as ExpectRecord, {
|
||||
completed: (raw["stream"] as ExpectRecord)["completed"],
|
||||
firstTokenMs: normalizeValue((raw["stream"] as ExpectRecord)["firstTokenMs"]),
|
||||
})
|
||||
: raw["stream"],
|
||||
usage: isPlainObject(raw["usage"])
|
||||
? compact(raw["usage"] as ExpectRecord, {
|
||||
inputTokens: normalizeValue((raw["usage"] as ExpectRecord)["inputTokens"]),
|
||||
outputTokens: normalizeValue((raw["usage"] as ExpectRecord)["outputTokens"]),
|
||||
totalTokens: normalizeValue((raw["usage"] as ExpectRecord)["totalTokens"]),
|
||||
})
|
||||
: raw["usage"],
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeTarget(target: unknown): unknown {
|
||||
if (!isPlainObject(target)) return target;
|
||||
const result = { ...(target as RawTargetConfig) };
|
||||
if (result.expect !== undefined) {
|
||||
result.expect = normalizeExpect(result.type, result.expect);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeTcpExpect(raw: ExpectRecord): ExpectRecord {
|
||||
return compact(raw, {
|
||||
banner: normalizeContent(raw["banner"]),
|
||||
connected: raw["connected"],
|
||||
durationMs: normalizeValue(raw["durationMs"]),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeUdpExpect(raw: ExpectRecord): ExpectRecord {
|
||||
return compact(raw, {
|
||||
durationMs: normalizeValue(raw["durationMs"]),
|
||||
responded: raw["responded"],
|
||||
response: normalizeContent(raw["response"]),
|
||||
responseSize: normalizeValue(raw["responseSize"]),
|
||||
sourceHost: normalizeValue(raw["sourceHost"]),
|
||||
sourcePort: normalizeValue(raw["sourcePort"]),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeValue(value: unknown): unknown {
|
||||
if (value === undefined) return undefined;
|
||||
if (isValueMatcherPrimitive(value) || isValueMatcherObject(value)) return resolveValueExpectation(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
export type { AuthoringProbeConfig, NormalizedProbeConfig };
|
||||
@@ -3,16 +3,11 @@ import { resolve } from "node:path";
|
||||
|
||||
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type {
|
||||
CommandTargetConfig,
|
||||
RawCommandExpectConfig,
|
||||
ResolvedCommandExpectConfig,
|
||||
ResolvedCommandTarget,
|
||||
} from "./types";
|
||||
import type { CommandTargetConfig, ResolvedCommandExpectConfig, ResolvedCommandTarget } from "./types";
|
||||
|
||||
import { checkContentExpectations, resolveContentExpectations } from "../../expect/content";
|
||||
import { checkContentExpectations } from "../../expect/content";
|
||||
import { errorFailure } from "../../expect/failure";
|
||||
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value";
|
||||
import { checkValueExpectation } from "../../expect/value";
|
||||
import { parseSize } from "../../utils";
|
||||
import { checkExitCode } from "./expect";
|
||||
import { commandCheckerSchemas } from "./schema";
|
||||
@@ -217,13 +212,11 @@ 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
|
||||
const expect = target.expect as ResolvedCommandExpectConfig | undefined;
|
||||
const resolvedExpect: ResolvedCommandExpectConfig = expect
|
||||
? {
|
||||
durationMs: resolveValueExpectation(rawExpect.durationMs),
|
||||
exitCode: rawExpect.exitCode ?? [0],
|
||||
stderr: resolveContentExpectations(rawExpect.stderr),
|
||||
stdout: resolveContentExpectations(rawExpect.stdout),
|
||||
...expect,
|
||||
exitCode: expect.exitCode ?? [0],
|
||||
}
|
||||
: { exitCode: [0] };
|
||||
|
||||
@@ -241,7 +234,6 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
id: t.id,
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
name: t.name ?? null,
|
||||
rawExpect,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
type: "cmd",
|
||||
} satisfies ResolvedCommandTarget;
|
||||
|
||||
@@ -3,14 +3,27 @@ import { Type } from "@sinclair/typebox";
|
||||
import type { CheckerSchemas } from "../types";
|
||||
|
||||
import {
|
||||
createRawContentExpectationsSchema,
|
||||
createRawValueExpectationSchema,
|
||||
createAuthoringContentExpectationsSchema,
|
||||
createAuthoringValueExpectationSchema,
|
||||
createNormalizedContentExpectationsSchema,
|
||||
createNormalizedValueExpectationSchema,
|
||||
sizeSchema,
|
||||
stringMapSchema,
|
||||
} from "../../schema/fragments";
|
||||
|
||||
export const commandCheckerSchemas: CheckerSchemas = {
|
||||
config: Type.Object(
|
||||
authoring: {
|
||||
config: createCommandConfigSchema(),
|
||||
expect: createCommandExpectSchema("authoring"),
|
||||
},
|
||||
normalized: {
|
||||
config: createCommandConfigSchema(),
|
||||
expect: createCommandExpectSchema("normalized"),
|
||||
},
|
||||
};
|
||||
|
||||
function createCommandConfigSchema() {
|
||||
return Type.Object(
|
||||
{
|
||||
args: Type.Optional(Type.Array(Type.String())),
|
||||
cwd: Type.Optional(Type.String()),
|
||||
@@ -19,14 +32,23 @@ export const commandCheckerSchemas: CheckerSchemas = {
|
||||
maxOutputBytes: Type.Optional(sizeSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
expect: Type.Object(
|
||||
);
|
||||
}
|
||||
|
||||
function createCommandExpectSchema(kind: "authoring" | "normalized") {
|
||||
return Type.Object(
|
||||
{
|
||||
durationMs: Type.Optional(createRawValueExpectationSchema()),
|
||||
durationMs: Type.Optional(
|
||||
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
|
||||
),
|
||||
exitCode: Type.Optional(Type.Array(Type.Integer())),
|
||||
stderr: Type.Optional(createRawContentExpectationsSchema()),
|
||||
stdout: Type.Optional(createRawContentExpectationsSchema()),
|
||||
stderr: Type.Optional(
|
||||
kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(),
|
||||
),
|
||||
stdout: Type.Optional(
|
||||
kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
};
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ export interface ResolvedCommandTarget extends ResolvedTargetBase {
|
||||
group: string;
|
||||
intervalMs: number;
|
||||
name: null | string;
|
||||
rawExpect?: RawCommandExpectConfig;
|
||||
timeoutMs: number;
|
||||
type: "cmd";
|
||||
}
|
||||
|
||||
@@ -3,12 +3,11 @@ import { isError } from "es-toolkit";
|
||||
|
||||
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type { DbTargetConfig, RawDbExpectConfig, ResolvedDbExpectConfig, ResolvedDbTarget } from "./types";
|
||||
import type { DbTargetConfig, ResolvedDbExpectConfig, ResolvedDbTarget } from "./types";
|
||||
|
||||
import { checkContentExpectations, resolveContentExpectations } from "../../expect/content";
|
||||
import { checkContentExpectations } from "../../expect/content";
|
||||
import { errorFailure } from "../../expect/failure";
|
||||
import { resolveKeyedExpectations } from "../../expect/keyed";
|
||||
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value";
|
||||
import { checkValueExpectation } from "../../expect/value";
|
||||
import { checkRowCount, checkRows } from "./expect";
|
||||
import { dbCheckerSchemas } from "./schema";
|
||||
import { validateDbConfig } from "./validate";
|
||||
@@ -227,15 +226,7 @@ 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;
|
||||
const resolvedExpect = target.expect as ResolvedDbExpectConfig | undefined;
|
||||
|
||||
return {
|
||||
db: {
|
||||
@@ -248,7 +239,6 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
id: t.id,
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
name: t.name ?? null,
|
||||
rawExpect,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
type: "db",
|
||||
} satisfies ResolvedDbTarget;
|
||||
|
||||
@@ -3,13 +3,27 @@ import { Type } from "@sinclair/typebox";
|
||||
import type { CheckerSchemas } from "../types";
|
||||
|
||||
import {
|
||||
createRawContentExpectationsSchema,
|
||||
createRawKeyedExpectationsSchema,
|
||||
createRawValueExpectationSchema,
|
||||
createAuthoringContentExpectationsSchema,
|
||||
createAuthoringKeyedExpectationsSchema,
|
||||
createAuthoringValueExpectationSchema,
|
||||
createNormalizedContentExpectationsSchema,
|
||||
createNormalizedKeyedExpectationsSchema,
|
||||
createNormalizedValueExpectationSchema,
|
||||
} from "../../schema/fragments";
|
||||
|
||||
export const dbCheckerSchemas: CheckerSchemas = {
|
||||
config: Type.Object(
|
||||
authoring: {
|
||||
config: createDbConfigSchema(),
|
||||
expect: createDbExpectSchema("authoring"),
|
||||
},
|
||||
normalized: {
|
||||
config: createDbConfigSchema(),
|
||||
expect: createDbExpectSchema("normalized"),
|
||||
},
|
||||
};
|
||||
|
||||
function createDbConfigSchema() {
|
||||
return Type.Object(
|
||||
{
|
||||
query: Type.Optional(
|
||||
Type.String({
|
||||
@@ -19,14 +33,27 @@ export const dbCheckerSchemas: CheckerSchemas = {
|
||||
url: Type.String({ minLength: 1 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
expect: Type.Object(
|
||||
);
|
||||
}
|
||||
|
||||
function createDbExpectSchema(kind: "authoring" | "normalized") {
|
||||
return Type.Object(
|
||||
{
|
||||
durationMs: Type.Optional(createRawValueExpectationSchema()),
|
||||
result: Type.Optional(createRawContentExpectationsSchema()),
|
||||
rowCount: Type.Optional(createRawValueExpectationSchema()),
|
||||
rows: Type.Optional(Type.Array(createRawKeyedExpectationsSchema())),
|
||||
durationMs: Type.Optional(
|
||||
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
|
||||
),
|
||||
result: Type.Optional(
|
||||
kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(),
|
||||
),
|
||||
rowCount: Type.Optional(
|
||||
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
|
||||
),
|
||||
rows: Type.Optional(
|
||||
Type.Array(
|
||||
kind === "authoring" ? createAuthoringKeyedExpectationsSchema() : createNormalizedKeyedExpectationsSchema(),
|
||||
),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
};
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ export interface ResolvedDbTarget extends ResolvedTargetBase {
|
||||
group: string;
|
||||
intervalMs: number;
|
||||
name: null | string;
|
||||
rawExpect?: RawDbExpectConfig;
|
||||
timeoutMs: number;
|
||||
type: "db";
|
||||
}
|
||||
|
||||
@@ -27,12 +27,7 @@ export function validateDbConfig(input: CheckerValidationInput): ConfigValidatio
|
||||
function collectRowExpects(rows: unknown[], path: string, targetName?: string): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]!;
|
||||
if (!isPlainRecord(row)) {
|
||||
issues.push(issue("invalid-type", `${path}[${i}]`, "必须为对象", targetName));
|
||||
continue;
|
||||
}
|
||||
issues.push(...validateRawKeyedExpectations(row, `${path}[${i}]`, targetName));
|
||||
issues.push(...validateRawKeyedExpectations(rows[i], `${path}[${i}]`, targetName));
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
@@ -2,14 +2,13 @@ import { isError } from "es-toolkit";
|
||||
|
||||
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type { HttpTargetConfig, RawHttpExpectConfig, ResolvedHttpExpectConfig, ResolvedHttpTarget } from "./types";
|
||||
import type { HttpTargetConfig, ResolvedHttpExpectConfig, ResolvedHttpTarget } from "./types";
|
||||
|
||||
import { checkContentExpectations, resolveContentExpectations } from "../../expect/content";
|
||||
import { checkContentExpectations } from "../../expect/content";
|
||||
import { errorFailure, mismatchFailure } from "../../expect/failure";
|
||||
import { checkHeaderExpectations } from "../../expect/headers";
|
||||
import { resolveKeyedExpectations } from "../../expect/keyed";
|
||||
import { checkStatusCode } from "../../expect/status";
|
||||
import { checkValueExpectation, displayValueExpectation, resolveValueExpectation } from "../../expect/value";
|
||||
import { checkValueExpectation, displayValueExpectation } from "../../expect/value";
|
||||
import { parseSize } from "../../utils";
|
||||
import { httpCheckerSchemas } from "./schema";
|
||||
import { validateHttpConfig } from "./validate";
|
||||
@@ -179,13 +178,11 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
||||
const method = t.http.method ?? "GET";
|
||||
const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? "100MB");
|
||||
|
||||
const rawExpect = target.expect as RawHttpExpectConfig | undefined;
|
||||
const resolvedExpect: ResolvedHttpExpectConfig = rawExpect
|
||||
const expect = target.expect as ResolvedHttpExpectConfig | undefined;
|
||||
const resolvedExpect: ResolvedHttpExpectConfig = expect
|
||||
? {
|
||||
body: resolveContentExpectations(rawExpect.body),
|
||||
durationMs: resolveValueExpectation(rawExpect.durationMs),
|
||||
headers: resolveKeyedExpectations(rawExpect.headers),
|
||||
status: rawExpect.status ?? [200],
|
||||
...expect,
|
||||
status: expect.status ?? [200],
|
||||
}
|
||||
: { status: [200] };
|
||||
|
||||
@@ -205,7 +202,6 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
||||
id: t.id,
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
name: t.name ?? null,
|
||||
rawExpect,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
type: "http",
|
||||
} satisfies ResolvedHttpTarget;
|
||||
|
||||
@@ -3,9 +3,14 @@ import { Type } from "@sinclair/typebox";
|
||||
import type { CheckerSchemas } from "../types";
|
||||
|
||||
import {
|
||||
createRawContentExpectationsSchema,
|
||||
createRawKeyedExpectationsSchema,
|
||||
createRawValueExpectationSchema,
|
||||
createAuthoringContentExpectationsSchema,
|
||||
createAuthoringFieldSchema,
|
||||
createAuthoringKeyedExpectationsSchema,
|
||||
createAuthoringStringMapSchema,
|
||||
createAuthoringValueExpectationSchema,
|
||||
createNormalizedContentExpectationsSchema,
|
||||
createNormalizedKeyedExpectationsSchema,
|
||||
createNormalizedValueExpectationSchema,
|
||||
httpMethodSchema,
|
||||
sizeSchema,
|
||||
statusCodePatternSchema,
|
||||
@@ -13,25 +18,47 @@ import {
|
||||
} from "../../schema/fragments";
|
||||
|
||||
export const httpCheckerSchemas: CheckerSchemas = {
|
||||
config: Type.Object(
|
||||
authoring: {
|
||||
config: createHttpConfigSchema("authoring"),
|
||||
expect: createHttpExpectSchema("authoring"),
|
||||
},
|
||||
normalized: {
|
||||
config: createHttpConfigSchema("normalized"),
|
||||
expect: createHttpExpectSchema("normalized"),
|
||||
},
|
||||
};
|
||||
|
||||
function createHttpConfigSchema(kind: "authoring" | "normalized") {
|
||||
const bool = Type.Boolean();
|
||||
const redirects = Type.Integer({ minimum: 0 });
|
||||
return Type.Object(
|
||||
{
|
||||
body: Type.Optional(Type.String()),
|
||||
headers: Type.Optional(stringMapSchema),
|
||||
ignoreSSL: Type.Optional(Type.Boolean()),
|
||||
headers: Type.Optional(kind === "authoring" ? createAuthoringStringMapSchema() : stringMapSchema),
|
||||
ignoreSSL: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(bool) : bool),
|
||||
maxBodyBytes: Type.Optional(sizeSchema),
|
||||
maxRedirects: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
method: Type.Optional(httpMethodSchema),
|
||||
maxRedirects: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(redirects) : redirects),
|
||||
method: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(httpMethodSchema) : httpMethodSchema),
|
||||
url: Type.String({ minLength: 1 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
expect: Type.Object(
|
||||
);
|
||||
}
|
||||
|
||||
function createHttpExpectSchema(kind: "authoring" | "normalized") {
|
||||
return Type.Object(
|
||||
{
|
||||
body: Type.Optional(createRawContentExpectationsSchema()),
|
||||
durationMs: Type.Optional(createRawValueExpectationSchema()),
|
||||
headers: Type.Optional(createRawKeyedExpectationsSchema()),
|
||||
body: Type.Optional(
|
||||
kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(),
|
||||
),
|
||||
durationMs: Type.Optional(
|
||||
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
|
||||
),
|
||||
headers: Type.Optional(
|
||||
kind === "authoring" ? createAuthoringKeyedExpectationsSchema() : createNormalizedKeyedExpectationsSchema(),
|
||||
),
|
||||
status: Type.Optional(Type.Array(statusCodePatternSchema)),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
};
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,6 @@ export interface ResolvedHttpTarget extends ResolvedTargetBase {
|
||||
http: ResolvedHttpConfig;
|
||||
intervalMs: number;
|
||||
name: null | string;
|
||||
rawExpect?: RawHttpExpectConfig;
|
||||
timeoutMs: number;
|
||||
type: "http";
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ function validateHttpExpect(target: Record<string, unknown>, path: string): Conf
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const expectPath = joinPath(path, "expect");
|
||||
|
||||
if (isPlainRecord(expect["headers"])) {
|
||||
if (expect["headers"] !== undefined) {
|
||||
issues.push(
|
||||
...validateRawKeyedExpectations(expect["headers"], joinPath(expectPath, "headers"), targetName, {
|
||||
caseInsensitive: true,
|
||||
|
||||
@@ -2,16 +2,10 @@ import { isError } from "es-toolkit";
|
||||
|
||||
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type {
|
||||
PingStats,
|
||||
PingTargetConfig,
|
||||
RawIcmpExpectConfig,
|
||||
ResolvedIcmpExpectConfig,
|
||||
ResolvedPingTarget,
|
||||
} from "./types";
|
||||
import type { PingStats, PingTargetConfig, ResolvedIcmpExpectConfig, ResolvedPingTarget } from "./types";
|
||||
|
||||
import { errorFailure } from "../../expect/failure";
|
||||
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value";
|
||||
import { checkValueExpectation } from "../../expect/value";
|
||||
import { buildPingCommand } from "./command";
|
||||
import { checkAlive, checkAvgLatency, checkMaxLatency, checkPacketLoss } from "./expect";
|
||||
import { parsePingOutput } from "./parse";
|
||||
@@ -162,14 +156,11 @@ 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
|
||||
const expect = target.expect as ResolvedIcmpExpectConfig | undefined;
|
||||
const resolvedExpect: ResolvedIcmpExpectConfig = expect
|
||||
? {
|
||||
alive: rawExpect.alive ?? true,
|
||||
avgLatencyMs: resolveValueExpectation(rawExpect.avgLatencyMs),
|
||||
durationMs: resolveValueExpectation(rawExpect.durationMs),
|
||||
maxLatencyMs: resolveValueExpectation(rawExpect.maxLatencyMs),
|
||||
packetLossPercent: resolveValueExpectation(rawExpect.packetLossPercent),
|
||||
...expect,
|
||||
alive: expect.alive ?? true,
|
||||
}
|
||||
: { alive: true };
|
||||
|
||||
@@ -185,7 +176,6 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
|
||||
id: t.id,
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
name: t.name ?? null,
|
||||
rawExpect,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
type: "icmp",
|
||||
} satisfies ResolvedPingTarget;
|
||||
|
||||
@@ -2,25 +2,54 @@ import { Type } from "@sinclair/typebox";
|
||||
|
||||
import type { CheckerSchemas } from "../types";
|
||||
|
||||
import { createRawValueExpectationSchema } from "../../schema/fragments";
|
||||
import {
|
||||
createAuthoringFieldSchema,
|
||||
createAuthoringValueExpectationSchema,
|
||||
createNormalizedValueExpectationSchema,
|
||||
} from "../../schema/fragments";
|
||||
|
||||
export const icmpCheckerSchemas: CheckerSchemas = {
|
||||
config: Type.Object(
|
||||
{
|
||||
count: Type.Optional(Type.Integer({ maximum: 100, minimum: 1 })),
|
||||
host: Type.String({ minLength: 1 }),
|
||||
packetSize: Type.Optional(Type.Integer({ maximum: 65500, minimum: 1 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
expect: Type.Object(
|
||||
{
|
||||
alive: Type.Optional(Type.Boolean()),
|
||||
avgLatencyMs: Type.Optional(createRawValueExpectationSchema()),
|
||||
durationMs: Type.Optional(createRawValueExpectationSchema()),
|
||||
maxLatencyMs: Type.Optional(createRawValueExpectationSchema()),
|
||||
packetLossPercent: Type.Optional(createRawValueExpectationSchema()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
authoring: {
|
||||
config: createIcmpConfigSchema("authoring"),
|
||||
expect: createIcmpExpectSchema("authoring"),
|
||||
},
|
||||
normalized: {
|
||||
config: createIcmpConfigSchema("normalized"),
|
||||
expect: createIcmpExpectSchema("normalized"),
|
||||
},
|
||||
};
|
||||
|
||||
function createIcmpConfigSchema(kind: "authoring" | "normalized") {
|
||||
const count = Type.Integer({ maximum: 100, minimum: 1 });
|
||||
const packetSize = Type.Integer({ maximum: 65500, minimum: 1 });
|
||||
return Type.Object(
|
||||
{
|
||||
count: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(count) : count),
|
||||
host: Type.String({ minLength: 1 }),
|
||||
packetSize: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(packetSize) : packetSize),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
}
|
||||
|
||||
function createIcmpExpectSchema(kind: "authoring" | "normalized") {
|
||||
const bool = Type.Boolean();
|
||||
return Type.Object(
|
||||
{
|
||||
alive: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(bool) : bool),
|
||||
avgLatencyMs: Type.Optional(
|
||||
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
|
||||
),
|
||||
durationMs: Type.Optional(
|
||||
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
|
||||
),
|
||||
maxLatencyMs: Type.Optional(
|
||||
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
|
||||
),
|
||||
packetLossPercent: Type.Optional(
|
||||
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ export interface ResolvedPingTarget extends ResolvedTargetBase {
|
||||
icmp: ResolvedPingConfig;
|
||||
intervalMs: number;
|
||||
name: null | string;
|
||||
rawExpect?: RawIcmpExpectConfig;
|
||||
timeoutMs: number;
|
||||
type: "icmp";
|
||||
}
|
||||
|
||||
@@ -3,12 +3,10 @@ import { isError } from "es-toolkit";
|
||||
|
||||
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type { LlmTargetConfig, RawLlmExpectConfig, ResolvedLlmExpectConfig, ResolvedLlmTarget } from "./types";
|
||||
import type { LlmTargetConfig, ResolvedLlmExpectConfig, ResolvedLlmTarget } from "./types";
|
||||
|
||||
import { resolveContentExpectations } from "../../expect/content";
|
||||
import { errorFailure } from "../../expect/failure";
|
||||
import { resolveKeyedExpectations } from "../../expect/keyed";
|
||||
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value";
|
||||
import { checkValueExpectation } from "../../expect/value";
|
||||
import { runExpects } from "./expect";
|
||||
import {
|
||||
buildObservationFromApiCallError,
|
||||
@@ -155,26 +153,15 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
url: t.llm.url,
|
||||
};
|
||||
|
||||
const rawExpect = target.expect as RawLlmExpectConfig | undefined;
|
||||
const resolvedExpect: ResolvedLlmExpectConfig = rawExpect
|
||||
const expect = target.expect as ResolvedLlmExpectConfig | undefined;
|
||||
const resolvedExpect: ResolvedLlmExpectConfig = expect
|
||||
? {
|
||||
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
|
||||
...expect,
|
||||
status: expect.status ?? [200],
|
||||
stream: expect.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),
|
||||
...expect.stream,
|
||||
completed: expect.stream.completed ?? true,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
@@ -188,7 +175,6 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
llm: resolvedConfig,
|
||||
name: (target.name as null | string) ?? null,
|
||||
rawExpect,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
type: "llm",
|
||||
} satisfies ResolvedLlmTarget;
|
||||
|
||||
@@ -3,18 +3,26 @@ import { Type } from "@sinclair/typebox";
|
||||
import type { CheckerSchemas } from "../types";
|
||||
|
||||
import {
|
||||
createRawContentExpectationsSchema,
|
||||
createRawKeyedExpectationsSchema,
|
||||
createRawValueExpectationSchema,
|
||||
createAuthoringContentExpectationsSchema,
|
||||
createAuthoringFieldSchema,
|
||||
createAuthoringKeyedExpectationsSchema,
|
||||
createAuthoringStringMapSchema,
|
||||
createAuthoringValueExpectationSchema,
|
||||
createNormalizedContentExpectationsSchema,
|
||||
createNormalizedKeyedExpectationsSchema,
|
||||
createNormalizedValueExpectationSchema,
|
||||
statusCodePatternSchema,
|
||||
stringMapSchema,
|
||||
} from "../../schema/fragments";
|
||||
|
||||
function createLlmOptionsSchema() {
|
||||
function createLlmOptionsSchema(kind: "authoring" | "normalized") {
|
||||
const maxOutputTokens = Type.Integer({ minimum: 1 });
|
||||
return Type.Object(
|
||||
{
|
||||
frequencyPenalty: Type.Optional(Type.Number()),
|
||||
maxOutputTokens: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
maxOutputTokens: Type.Optional(
|
||||
kind === "authoring" ? createAuthoringFieldSchema(maxOutputTokens) : maxOutputTokens,
|
||||
),
|
||||
presencePenalty: Type.Optional(Type.Number()),
|
||||
seed: Type.Optional(Type.Number()),
|
||||
stopSequences: Type.Optional(Type.Array(Type.String())),
|
||||
@@ -27,35 +35,59 @@ function createLlmOptionsSchema() {
|
||||
}
|
||||
|
||||
export const llmCheckerSchemas: CheckerSchemas = {
|
||||
config: Type.Object(
|
||||
authoring: {
|
||||
config: createLlmConfigSchema("authoring"),
|
||||
expect: createLlmExpectSchema("authoring"),
|
||||
},
|
||||
normalized: {
|
||||
config: createLlmConfigSchema("normalized"),
|
||||
expect: createLlmExpectSchema("normalized"),
|
||||
},
|
||||
};
|
||||
|
||||
function createLlmConfigSchema(kind: "authoring" | "normalized") {
|
||||
const bool = Type.Boolean();
|
||||
const mode = createLlmModeSchema();
|
||||
const provider = createLlmProviderSchema();
|
||||
return Type.Object(
|
||||
{
|
||||
authToken: Type.Optional(Type.String()),
|
||||
headers: Type.Optional(stringMapSchema),
|
||||
ignoreSSL: Type.Optional(Type.Boolean()),
|
||||
headers: Type.Optional(kind === "authoring" ? createAuthoringStringMapSchema() : stringMapSchema),
|
||||
ignoreSSL: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(bool) : bool),
|
||||
key: Type.Optional(Type.String()),
|
||||
mode: Type.Optional(Type.Union([Type.Literal("http"), Type.Literal("stream")])),
|
||||
mode: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(mode) : mode),
|
||||
model: Type.String({ minLength: 1 }),
|
||||
options: Type.Optional(createLlmOptionsSchema()),
|
||||
options: Type.Optional(createLlmOptionsSchema(kind)),
|
||||
prompt: Type.String({ minLength: 1 }),
|
||||
provider: Type.Union([Type.Literal("openai"), Type.Literal("openai-responses"), Type.Literal("anthropic")]),
|
||||
provider: kind === "authoring" ? createAuthoringFieldSchema(provider) : provider,
|
||||
providerOptions: Type.Optional(Type.Record(Type.String(), Type.Object({}, { additionalProperties: true }))),
|
||||
url: Type.String({ minLength: 1 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
expect: Type.Object(
|
||||
);
|
||||
}
|
||||
|
||||
function createLlmExpectSchema(kind: "authoring" | "normalized") {
|
||||
const bool = Type.Boolean();
|
||||
const valueExpectation =
|
||||
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema();
|
||||
return Type.Object(
|
||||
{
|
||||
durationMs: Type.Optional(createRawValueExpectationSchema()),
|
||||
finishReason: Type.Optional(createRawValueExpectationSchema()),
|
||||
headers: Type.Optional(createRawKeyedExpectationsSchema()),
|
||||
output: Type.Optional(createRawContentExpectationsSchema()),
|
||||
rawFinishReason: Type.Optional(createRawValueExpectationSchema()),
|
||||
durationMs: Type.Optional(valueExpectation),
|
||||
finishReason: Type.Optional(valueExpectation),
|
||||
headers: Type.Optional(
|
||||
kind === "authoring" ? createAuthoringKeyedExpectationsSchema() : createNormalizedKeyedExpectationsSchema(),
|
||||
),
|
||||
output: Type.Optional(
|
||||
kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(),
|
||||
),
|
||||
rawFinishReason: Type.Optional(valueExpectation),
|
||||
status: Type.Optional(Type.Array(statusCodePatternSchema)),
|
||||
stream: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
completed: Type.Optional(Type.Boolean()),
|
||||
firstTokenMs: Type.Optional(createRawValueExpectationSchema()),
|
||||
completed: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(bool) : bool),
|
||||
firstTokenMs: Type.Optional(valueExpectation),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
@@ -63,14 +95,22 @@ export const llmCheckerSchemas: CheckerSchemas = {
|
||||
usage: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
inputTokens: Type.Optional(createRawValueExpectationSchema()),
|
||||
outputTokens: Type.Optional(createRawValueExpectationSchema()),
|
||||
totalTokens: Type.Optional(createRawValueExpectationSchema()),
|
||||
inputTokens: Type.Optional(valueExpectation),
|
||||
outputTokens: Type.Optional(valueExpectation),
|
||||
totalTokens: Type.Optional(valueExpectation),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
};
|
||||
);
|
||||
}
|
||||
|
||||
function createLlmModeSchema() {
|
||||
return Type.Union([Type.Literal("http"), Type.Literal("stream")]);
|
||||
}
|
||||
|
||||
function createLlmProviderSchema() {
|
||||
return Type.Union([Type.Literal("openai"), Type.Literal("openai-responses"), Type.Literal("anthropic")]);
|
||||
}
|
||||
|
||||
@@ -152,7 +152,6 @@ export interface ResolvedLlmTarget extends ResolvedTargetBase {
|
||||
intervalMs: number;
|
||||
llm: ResolvedLlmConfig;
|
||||
name: null | string;
|
||||
rawExpect?: RawLlmExpectConfig;
|
||||
timeoutMs: number;
|
||||
type: "llm";
|
||||
}
|
||||
|
||||
@@ -2,11 +2,10 @@ import { isError } from "es-toolkit";
|
||||
|
||||
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type { RawTcpExpectConfig, ResolvedTcpExpectConfig, ResolvedTcpTarget, TcpTargetConfig } from "./types";
|
||||
import type { ResolvedTcpExpectConfig, ResolvedTcpTarget, TcpTargetConfig } from "./types";
|
||||
|
||||
import { resolveContentExpectations } from "../../expect/content";
|
||||
import { errorFailure } from "../../expect/failure";
|
||||
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value";
|
||||
import { checkValueExpectation } from "../../expect/value";
|
||||
import { parseSize } from "../../utils";
|
||||
import { checkBanner, checkConnected } from "./expect";
|
||||
import { tcpCheckerSchemas } from "./schema";
|
||||
@@ -210,12 +209,11 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
|
||||
const maxBannerBytes = parseSize(t.tcp.maxBannerBytes ?? DEFAULT_MAX_BANNER_BYTES);
|
||||
const bannerReadTimeout = t.tcp.bannerReadTimeout ?? DEFAULT_BANNER_READ_TIMEOUT;
|
||||
|
||||
const rawExpect = target.expect as RawTcpExpectConfig | undefined;
|
||||
const resolvedExpect: ResolvedTcpExpectConfig = rawExpect
|
||||
const expect = target.expect as ResolvedTcpExpectConfig | undefined;
|
||||
const resolvedExpect: ResolvedTcpExpectConfig = expect
|
||||
? {
|
||||
banner: resolveContentExpectations(rawExpect.banner),
|
||||
connected: rawExpect.connected ?? true,
|
||||
durationMs: resolveValueExpectation(rawExpect.durationMs),
|
||||
...expect,
|
||||
connected: expect.connected ?? true,
|
||||
}
|
||||
: { connected: true };
|
||||
|
||||
@@ -226,7 +224,6 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
|
||||
id: t.id,
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
name: t.name ?? null,
|
||||
rawExpect,
|
||||
tcp: {
|
||||
bannerReadTimeout,
|
||||
host: t.tcp.host,
|
||||
|
||||
@@ -3,28 +3,52 @@ import { Type } from "@sinclair/typebox";
|
||||
import type { CheckerSchemas } from "../types";
|
||||
|
||||
import {
|
||||
createRawContentExpectationsSchema,
|
||||
createRawValueExpectationSchema,
|
||||
createAuthoringContentExpectationsSchema,
|
||||
createAuthoringFieldSchema,
|
||||
createAuthoringValueExpectationSchema,
|
||||
createNormalizedContentExpectationsSchema,
|
||||
createNormalizedValueExpectationSchema,
|
||||
sizeSchema,
|
||||
} from "../../schema/fragments";
|
||||
|
||||
export const tcpCheckerSchemas: CheckerSchemas = {
|
||||
config: Type.Object(
|
||||
authoring: {
|
||||
config: createTcpConfigSchema("authoring"),
|
||||
expect: createTcpExpectSchema("authoring"),
|
||||
},
|
||||
normalized: {
|
||||
config: createTcpConfigSchema("normalized"),
|
||||
expect: createTcpExpectSchema("normalized"),
|
||||
},
|
||||
};
|
||||
|
||||
function createTcpConfigSchema(kind: "authoring" | "normalized") {
|
||||
const port = Type.Integer({ maximum: 65535, minimum: 1 });
|
||||
const readBanner = Type.Boolean();
|
||||
return Type.Object(
|
||||
{
|
||||
bannerReadTimeout: Type.Optional(Type.Number({ minimum: 0 })),
|
||||
host: Type.String({ minLength: 1 }),
|
||||
maxBannerBytes: Type.Optional(sizeSchema),
|
||||
port: Type.Integer({ maximum: 65535, minimum: 1 }),
|
||||
readBanner: Type.Optional(Type.Boolean()),
|
||||
port: kind === "authoring" ? createAuthoringFieldSchema(port) : port,
|
||||
readBanner: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(readBanner) : readBanner),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
expect: Type.Object(
|
||||
);
|
||||
}
|
||||
|
||||
function createTcpExpectSchema(kind: "authoring" | "normalized") {
|
||||
const connected = Type.Boolean();
|
||||
return Type.Object(
|
||||
{
|
||||
banner: Type.Optional(createRawContentExpectationsSchema()),
|
||||
connected: Type.Optional(Type.Boolean()),
|
||||
durationMs: Type.Optional(createRawValueExpectationSchema()),
|
||||
banner: Type.Optional(
|
||||
kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(),
|
||||
),
|
||||
connected: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(connected) : connected),
|
||||
durationMs: Type.Optional(
|
||||
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
};
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ export interface ResolvedTcpTarget extends ResolvedTargetBase {
|
||||
group: string;
|
||||
intervalMs: number;
|
||||
name: null | string;
|
||||
rawExpect?: RawTcpExpectConfig;
|
||||
tcp: ResolvedTcpConfig;
|
||||
timeoutMs: number;
|
||||
type: "tcp";
|
||||
|
||||
@@ -20,11 +20,16 @@ export interface CheckerDefinition<TResolved extends ResolvedTargetBase = Resolv
|
||||
validate(input: CheckerValidationInput): ConfigValidationIssue[];
|
||||
}
|
||||
|
||||
export interface CheckerSchemas {
|
||||
export interface CheckerSchemaPair {
|
||||
config: TSchema;
|
||||
expect: TSchema;
|
||||
}
|
||||
|
||||
export interface CheckerSchemas {
|
||||
authoring: CheckerSchemaPair;
|
||||
normalized: CheckerSchemaPair;
|
||||
}
|
||||
|
||||
export interface CheckerValidationInput {
|
||||
targets: RawTargetConfig[];
|
||||
}
|
||||
|
||||
@@ -2,11 +2,10 @@ import { isError } from "es-toolkit";
|
||||
|
||||
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type { RawUdpExpectConfig, ResolvedUdpExpectConfig, ResolvedUdpTarget, UdpTargetConfig } from "./types";
|
||||
import type { ResolvedUdpExpectConfig, ResolvedUdpTarget, UdpTargetConfig } from "./types";
|
||||
|
||||
import { resolveContentExpectations } from "../../expect/content";
|
||||
import { errorFailure } from "../../expect/failure";
|
||||
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value";
|
||||
import { checkValueExpectation } from "../../expect/value";
|
||||
import { parseSize } from "../../utils";
|
||||
import { decodePayload, encodeResponse } from "./encoding";
|
||||
import { checkResponded, checkResponseSize, checkResponseText, checkSourceHost, checkSourcePort } from "./expect";
|
||||
@@ -303,15 +302,11 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
const responseEncoding = t.udp.responseEncoding ?? "text";
|
||||
const maxResponseBytes = parseSize(t.udp.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES);
|
||||
|
||||
const rawExpect = target.expect as RawUdpExpectConfig | undefined;
|
||||
const resolvedExpect: ResolvedUdpExpectConfig = rawExpect
|
||||
const expect = target.expect as ResolvedUdpExpectConfig | undefined;
|
||||
const resolvedExpect: ResolvedUdpExpectConfig = expect
|
||||
? {
|
||||
durationMs: resolveValueExpectation(rawExpect.durationMs),
|
||||
responded: rawExpect.responded ?? true,
|
||||
response: resolveContentExpectations(rawExpect.response),
|
||||
responseSize: resolveValueExpectation(rawExpect.responseSize),
|
||||
sourceHost: resolveValueExpectation(rawExpect.sourceHost),
|
||||
sourcePort: resolveValueExpectation(rawExpect.sourcePort),
|
||||
...expect,
|
||||
responded: expect.responded ?? true,
|
||||
}
|
||||
: { responded: true };
|
||||
|
||||
@@ -322,7 +317,6 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
id: t.id,
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
name: t.name ?? null,
|
||||
rawExpect,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
type: "udp",
|
||||
udp: {
|
||||
|
||||
@@ -3,32 +3,69 @@ import { Type } from "@sinclair/typebox";
|
||||
import type { CheckerSchemas } from "../types";
|
||||
|
||||
import {
|
||||
createRawContentExpectationsSchema,
|
||||
createRawValueExpectationSchema,
|
||||
createAuthoringContentExpectationsSchema,
|
||||
createAuthoringFieldSchema,
|
||||
createAuthoringValueExpectationSchema,
|
||||
createNormalizedContentExpectationsSchema,
|
||||
createNormalizedValueExpectationSchema,
|
||||
sizeSchema,
|
||||
} from "../../schema/fragments";
|
||||
|
||||
export const udpCheckerSchemas: CheckerSchemas = {
|
||||
config: Type.Object(
|
||||
authoring: {
|
||||
config: createUdpConfigSchema("authoring"),
|
||||
expect: createUdpExpectSchema("authoring"),
|
||||
},
|
||||
normalized: {
|
||||
config: createUdpConfigSchema("normalized"),
|
||||
expect: createUdpExpectSchema("normalized"),
|
||||
},
|
||||
};
|
||||
|
||||
function createEncodingSchema() {
|
||||
return Type.Union([Type.Literal("text"), Type.Literal("hex"), Type.Literal("base64")]);
|
||||
}
|
||||
|
||||
function createUdpConfigSchema(kind: "authoring" | "normalized") {
|
||||
const port = Type.Integer({ maximum: 65535, minimum: 1 });
|
||||
const encoding = createEncodingSchema();
|
||||
const responseEncoding = createEncodingSchema();
|
||||
return Type.Object(
|
||||
{
|
||||
encoding: Type.Optional(Type.Union([Type.Literal("text"), Type.Literal("hex"), Type.Literal("base64")])),
|
||||
encoding: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(encoding) : encoding),
|
||||
host: Type.String({ minLength: 1 }),
|
||||
maxResponseBytes: Type.Optional(sizeSchema),
|
||||
payload: Type.Optional(Type.String()),
|
||||
port: Type.Integer({ maximum: 65535, minimum: 1 }),
|
||||
responseEncoding: Type.Optional(Type.Union([Type.Literal("text"), Type.Literal("hex"), Type.Literal("base64")])),
|
||||
port: kind === "authoring" ? createAuthoringFieldSchema(port) : port,
|
||||
responseEncoding: Type.Optional(
|
||||
kind === "authoring" ? createAuthoringFieldSchema(responseEncoding) : responseEncoding,
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
expect: Type.Object(
|
||||
);
|
||||
}
|
||||
|
||||
function createUdpExpectSchema(kind: "authoring" | "normalized") {
|
||||
const responded = Type.Boolean();
|
||||
return Type.Object(
|
||||
{
|
||||
durationMs: Type.Optional(createRawValueExpectationSchema()),
|
||||
responded: Type.Optional(Type.Boolean()),
|
||||
response: Type.Optional(createRawContentExpectationsSchema()),
|
||||
responseSize: Type.Optional(createRawValueExpectationSchema()),
|
||||
sourceHost: Type.Optional(createRawValueExpectationSchema()),
|
||||
sourcePort: Type.Optional(createRawValueExpectationSchema()),
|
||||
durationMs: Type.Optional(
|
||||
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
|
||||
),
|
||||
responded: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(responded) : responded),
|
||||
response: Type.Optional(
|
||||
kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(),
|
||||
),
|
||||
responseSize: Type.Optional(
|
||||
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
|
||||
),
|
||||
sourceHost: Type.Optional(
|
||||
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
|
||||
),
|
||||
sourcePort: Type.Optional(
|
||||
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
};
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ export interface ResolvedUdpTarget extends ResolvedTargetBase {
|
||||
group: string;
|
||||
intervalMs: number;
|
||||
name: null | string;
|
||||
rawExpect?: RawUdpExpectConfig;
|
||||
timeoutMs: number;
|
||||
type: "udp";
|
||||
udp: ResolvedUdpConfig;
|
||||
|
||||
@@ -5,9 +5,10 @@ import { Type } from "@sinclair/typebox";
|
||||
import type { CheckerDefinition } from "../runner/types";
|
||||
|
||||
import {
|
||||
createRawContentExpectationsSchema,
|
||||
createRawKeyedExpectationsSchema,
|
||||
createRawValueExpectationSchema,
|
||||
createAuthoringContentExpectationsSchema,
|
||||
createAuthoringFieldSchema,
|
||||
createAuthoringKeyedExpectationsSchema,
|
||||
createAuthoringValueExpectationSchema,
|
||||
createValueMatcherObjectSchema,
|
||||
durationSchema,
|
||||
sizeSchema,
|
||||
@@ -16,62 +17,58 @@ import {
|
||||
|
||||
const LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "fatal"] as const;
|
||||
const ROTATION_FREQUENCIES = ["hourly", "daily", "weekly"] as const;
|
||||
type SchemaKind = "authoring" | "normalized";
|
||||
|
||||
export function createAuthoringProbeConfigSchema(checkers: CheckerDefinition[], external = false): TSchema {
|
||||
return createProbeConfigSchemaForKind(checkers, "authoring", external);
|
||||
}
|
||||
|
||||
export function createAuthoringTargetSchema(checker: CheckerDefinition): TSchema {
|
||||
return createTargetSchemaForKind(checker, "authoring");
|
||||
}
|
||||
|
||||
export function createExternalProbeConfigSchema(checkers: CheckerDefinition[]): Record<string, unknown> {
|
||||
return {
|
||||
...cloneSchema(createProbeConfigSchema(checkers, true)),
|
||||
...cloneSchema(createAuthoringProbeConfigSchema(checkers, true)),
|
||||
$id: "https://dial.local/probe-config.schema.json",
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
definitions: {
|
||||
ContentExpectations: cloneSchema(createRawContentExpectationsSchema()),
|
||||
KeyedExpectations: cloneSchema(createRawKeyedExpectationsSchema()),
|
||||
ValueExpectation: cloneSchema(createRawValueExpectationSchema()),
|
||||
ContentExpectations: cloneSchema(createAuthoringContentExpectationsSchema()),
|
||||
KeyedExpectations: cloneSchema(createAuthoringKeyedExpectationsSchema()),
|
||||
ValueExpectation: cloneSchema(createAuthoringValueExpectationSchema()),
|
||||
ValueMatcher: cloneSchema(createValueMatcherObjectSchema()),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createNormalizedProbeConfigSchema(checkers: CheckerDefinition[], external = false): TSchema {
|
||||
return createProbeConfigSchemaForKind(checkers, "normalized", external);
|
||||
}
|
||||
|
||||
export function createNormalizedTargetSchema(checker: CheckerDefinition): TSchema {
|
||||
return createTargetSchemaForKind(checker, "normalized");
|
||||
}
|
||||
|
||||
export function createProbeConfigSchema(checkers: CheckerDefinition[], external = false): TSchema {
|
||||
return Type.Object(
|
||||
{
|
||||
probes: Type.Optional(createProbesSchema()),
|
||||
server: Type.Optional(createServerSchema()),
|
||||
targets: Type.Array(external ? createExternalTargetSchema(checkers) : createBaseTargetSchema(checkers), {
|
||||
minItems: 1,
|
||||
}),
|
||||
variables: Type.Optional(Type.Record(Type.String({ pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$" }), variableValueSchema)),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
return createNormalizedProbeConfigSchema(checkers, external);
|
||||
}
|
||||
|
||||
export function createTargetSchema(checker: CheckerDefinition): TSchema {
|
||||
const properties: Record<string, TSchema> = {
|
||||
description: Type.Optional(Type.Union([Type.Null(), Type.String({ maxLength: 500 })])),
|
||||
expect: Type.Optional(checker.schemas.expect),
|
||||
group: Type.Optional(Type.String()),
|
||||
id: Type.String({ maxLength: 30, minLength: 1 }),
|
||||
interval: Type.Optional(durationSchema),
|
||||
name: Type.Optional(Type.Union([Type.Null(), Type.String({ maxLength: 30, minLength: 1 })])),
|
||||
timeout: Type.Optional(durationSchema),
|
||||
type: Type.Literal(checker.type),
|
||||
};
|
||||
properties[checker.configKey] = checker.schemas.config;
|
||||
return Type.Object(properties, { additionalProperties: false });
|
||||
return createNormalizedTargetSchema(checker);
|
||||
}
|
||||
|
||||
function cloneSchema(schema: TSchema): Record<string, unknown> {
|
||||
return JSON.parse(JSON.stringify(schema)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function createBaseTargetSchema(checkers: CheckerDefinition[]): TSchema {
|
||||
function createBaseTargetSchema(checkers: CheckerDefinition[], kind: SchemaKind): TSchema {
|
||||
return Type.Object(
|
||||
{
|
||||
description: Type.Optional(Type.Union([Type.Null(), Type.String({ maxLength: 500 })])),
|
||||
description: Type.Optional(Type.Union([Type.Null(), stringForKind(kind, { maxLength: 500 })])),
|
||||
group: Type.Optional(Type.String()),
|
||||
id: Type.String({ maxLength: 30, minLength: 1 }),
|
||||
interval: Type.Optional(durationSchema),
|
||||
name: Type.Optional(Type.Union([Type.Null(), Type.String({ maxLength: 30, minLength: 1 })])),
|
||||
name: Type.Optional(Type.Union([Type.Null(), stringForKind(kind, { maxLength: 30, minLength: 1 })])),
|
||||
timeout: Type.Optional(durationSchema),
|
||||
type: Type.Union(checkers.map((checker) => Type.Literal(checker.type)) as unknown as [TSchema, ...TSchema[]]),
|
||||
},
|
||||
@@ -79,27 +76,30 @@ function createBaseTargetSchema(checkers: CheckerDefinition[]): TSchema {
|
||||
);
|
||||
}
|
||||
|
||||
function createExternalTargetSchema(checkers: CheckerDefinition[]): TSchema {
|
||||
return Type.Union(checkers.map((checker) => createTargetSchema(checker)) as [TSchema, ...TSchema[]]);
|
||||
function createExternalTargetSchema(checkers: CheckerDefinition[], kind: SchemaKind): TSchema {
|
||||
return Type.Union(checkers.map((checker) => createTargetSchemaForKind(checker, kind)) as [TSchema, ...TSchema[]]);
|
||||
}
|
||||
|
||||
function createLoggingSchema(): TSchema {
|
||||
function createLoggingSchema(kind: SchemaKind): TSchema {
|
||||
const logLevelSchema = Type.Union(LOG_LEVELS.map((l) => Type.Literal(l)) as unknown as [TSchema, ...TSchema[]]);
|
||||
const logLevel = enumForKind(kind, logLevelSchema);
|
||||
const frequency = enumForKind(
|
||||
kind,
|
||||
Type.Union(ROTATION_FREQUENCIES.map((f) => Type.Literal(f)) as unknown as [TSchema, ...TSchema[]]),
|
||||
);
|
||||
return Type.Object(
|
||||
{
|
||||
console: Type.Optional(Type.Object({ level: Type.Optional(logLevelSchema) }, { additionalProperties: false })),
|
||||
console: Type.Optional(Type.Object({ level: Type.Optional(logLevel) }, { additionalProperties: false })),
|
||||
file: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
level: Type.Optional(logLevelSchema),
|
||||
level: Type.Optional(logLevel),
|
||||
path: Type.Optional(Type.String({ minLength: 1 })),
|
||||
rotation: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
frequency: Type.Optional(
|
||||
Type.Union(ROTATION_FREQUENCIES.map((f) => Type.Literal(f)) as unknown as [TSchema, ...TSchema[]]),
|
||||
),
|
||||
maxFiles: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
frequency: Type.Optional(frequency),
|
||||
maxFiles: Type.Optional(integerForKind(kind, { minimum: 1 })),
|
||||
size: Type.Optional(sizeSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
@@ -109,19 +109,38 @@ function createLoggingSchema(): TSchema {
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
level: Type.Optional(logLevelSchema),
|
||||
level: Type.Optional(logLevel),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
}
|
||||
|
||||
function createProbesSchema(): TSchema {
|
||||
function createProbeConfigSchemaForKind(checkers: CheckerDefinition[], kind: SchemaKind, external: boolean): TSchema {
|
||||
const properties: Record<string, TSchema> = {
|
||||
probes: Type.Optional(createProbesSchema(kind)),
|
||||
server: Type.Optional(createServerSchema(kind)),
|
||||
targets: Type.Array(
|
||||
external ? createExternalTargetSchema(checkers, kind) : createBaseTargetSchema(checkers, kind),
|
||||
{
|
||||
minItems: 1,
|
||||
},
|
||||
),
|
||||
};
|
||||
if (kind === "authoring") {
|
||||
properties["variables"] = Type.Optional(
|
||||
Type.Record(Type.String({ pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$" }), variableValueSchema),
|
||||
);
|
||||
}
|
||||
return Type.Object(properties, { additionalProperties: false });
|
||||
}
|
||||
|
||||
function createProbesSchema(kind: SchemaKind): TSchema {
|
||||
return Type.Object(
|
||||
{
|
||||
execution: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
maxConcurrentChecks: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
maxConcurrentChecks: Type.Optional(integerForKind(kind, { minimum: 1 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
@@ -131,19 +150,19 @@ function createProbesSchema(): TSchema {
|
||||
);
|
||||
}
|
||||
|
||||
function createServerSchema(): TSchema {
|
||||
function createServerSchema(kind: SchemaKind): TSchema {
|
||||
return Type.Object(
|
||||
{
|
||||
listen: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
host: Type.Optional(Type.String()),
|
||||
port: Type.Optional(Type.Integer({ maximum: 65535, minimum: 0 })),
|
||||
port: Type.Optional(integerForKind(kind, { maximum: 65535, minimum: 0 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
logging: Type.Optional(createLoggingSchema()),
|
||||
logging: Type.Optional(createLoggingSchema(kind)),
|
||||
storage: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
@@ -157,3 +176,32 @@ function createServerSchema(): TSchema {
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
}
|
||||
|
||||
function createTargetSchemaForKind(checker: CheckerDefinition, kind: SchemaKind): TSchema {
|
||||
const properties: Record<string, TSchema> = {
|
||||
description: Type.Optional(Type.Union([Type.Null(), stringForKind(kind, { maxLength: 500 })])),
|
||||
expect: Type.Optional(checker.schemas[kind].expect),
|
||||
group: Type.Optional(Type.String()),
|
||||
id: Type.String({ maxLength: 30, minLength: 1 }),
|
||||
interval: Type.Optional(durationSchema),
|
||||
name: Type.Optional(Type.Union([Type.Null(), stringForKind(kind, { maxLength: 30, minLength: 1 })])),
|
||||
timeout: Type.Optional(durationSchema),
|
||||
type: Type.Literal(checker.type),
|
||||
};
|
||||
properties[checker.configKey] = checker.schemas[kind].config;
|
||||
return Type.Object(properties, { additionalProperties: false });
|
||||
}
|
||||
|
||||
function enumForKind(kind: SchemaKind, schema: TSchema): TSchema {
|
||||
return kind === "authoring" ? createAuthoringFieldSchema(schema) : schema;
|
||||
}
|
||||
|
||||
function integerForKind(kind: SchemaKind, options?: Parameters<typeof Type.Integer>[0]): TSchema {
|
||||
const schema = Type.Integer(options);
|
||||
return kind === "authoring" ? createAuthoringFieldSchema(schema) : schema;
|
||||
}
|
||||
|
||||
function stringForKind(kind: SchemaKind, options?: Parameters<typeof Type.String>[0]): TSchema {
|
||||
const schema = Type.String(options);
|
||||
return kind === "authoring" ? createAuthoringFieldSchema(schema) : schema;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ export const sizeSchema = Type.Union([Type.String(), Type.Integer({ minimum: 0 }
|
||||
|
||||
export const variableValueSchema = Type.Union([Type.String(), Type.Number(), Type.Boolean()]);
|
||||
|
||||
export const variableReferenceSchema = Type.String({ pattern: "^\\$\\{[^}]+\\}$" });
|
||||
|
||||
export const statusCodePatternSchema = Type.Union([
|
||||
Type.Integer({ maximum: 599, minimum: 100 }),
|
||||
Type.String({ pattern: "^[1-5]xx$" }),
|
||||
@@ -41,6 +43,81 @@ export const stringMapSchema = Type.Unsafe<Record<string, string>>({
|
||||
type: "object",
|
||||
});
|
||||
|
||||
export function createAuthoringContentExpectationsSchema(): TSchema {
|
||||
return createRawContentExpectationsSchema();
|
||||
}
|
||||
|
||||
export function createAuthoringFieldSchema(schema: TSchema): TSchema {
|
||||
return Type.Unsafe({ anyOf: [schema, variableReferenceSchema] });
|
||||
}
|
||||
|
||||
export function createAuthoringKeyedExpectationsSchema(): TSchema {
|
||||
return createRawKeyedExpectationsSchema();
|
||||
}
|
||||
|
||||
export function createAuthoringStringMapSchema(): TSchema {
|
||||
return Type.Unsafe<Record<string, string>>({
|
||||
additionalProperties: { anyOf: [{ type: "string" }, variableReferenceSchema] },
|
||||
type: "object",
|
||||
});
|
||||
}
|
||||
|
||||
export function createAuthoringValueExpectationSchema(): TSchema {
|
||||
return createRawValueExpectationSchema();
|
||||
}
|
||||
|
||||
export function createNormalizedContentExpectationsSchema(): TSchema {
|
||||
const valueExpectation = Type.Object(
|
||||
{
|
||||
kind: Type.Literal("value"),
|
||||
matcher: createValueMatcherObjectSchema(),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
const jsonExpectation = Type.Object(
|
||||
{
|
||||
kind: Type.Literal("json"),
|
||||
matcher: createValueMatcherObjectSchema(),
|
||||
path: Type.String(),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
const cssExpectation = Type.Object(
|
||||
{
|
||||
attr: Type.Optional(Type.String()),
|
||||
kind: Type.Literal("css"),
|
||||
matcher: createValueMatcherObjectSchema(),
|
||||
selector: Type.String({ minLength: 1 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
const xpathExpectation = Type.Object(
|
||||
{
|
||||
kind: Type.Literal("xpath"),
|
||||
matcher: createValueMatcherObjectSchema(),
|
||||
path: Type.String({ minLength: 1 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
return Type.Array(Type.Union([valueExpectation, jsonExpectation, cssExpectation, xpathExpectation]));
|
||||
}
|
||||
|
||||
export function createNormalizedKeyedExpectationsSchema(): TSchema {
|
||||
return Type.Array(
|
||||
Type.Object(
|
||||
{
|
||||
key: Type.String(),
|
||||
matcher: createValueMatcherObjectSchema(),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function createNormalizedValueExpectationSchema(): TSchema {
|
||||
return createValueMatcherObjectSchema();
|
||||
}
|
||||
|
||||
export function createRawContentExpectationsSchema(): TSchema {
|
||||
return Type.Array(
|
||||
Type.Object(
|
||||
|
||||
@@ -2,12 +2,16 @@ import type { ProbeConfig } from "../types";
|
||||
|
||||
declare const validatedConfigBrand: unique symbol;
|
||||
|
||||
export type AuthoringProbeConfig = ProbeConfig;
|
||||
|
||||
export type JsonValue = boolean | JsonValue[] | null | number | string | { [key: string]: JsonValue };
|
||||
|
||||
export type RawProbeConfig = ProbeConfig;
|
||||
export type NormalizedProbeConfig = Omit<ProbeConfig, "variables">;
|
||||
|
||||
export type ValidatedProbeConfig = RawProbeConfig & { readonly [validatedConfigBrand]: true };
|
||||
export type RawProbeConfig = AuthoringProbeConfig;
|
||||
|
||||
export function asValidatedConfig(config: RawProbeConfig): ValidatedProbeConfig {
|
||||
export type ValidatedProbeConfig = NormalizedProbeConfig & { readonly [validatedConfigBrand]: true };
|
||||
|
||||
export function asValidatedConfig(config: NormalizedProbeConfig): ValidatedProbeConfig {
|
||||
return config as ValidatedProbeConfig;
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ import { isPlainObject, isString } from "es-toolkit";
|
||||
|
||||
import type { CheckerRegistry } from "../runner/registry";
|
||||
import type { ConfigValidationIssue } from "./issues";
|
||||
import type { RawProbeConfig } from "./types";
|
||||
import type { NormalizedProbeConfig } from "./types";
|
||||
|
||||
import { createProbeConfigSchema, createTargetSchema } from "./builder";
|
||||
import { createNormalizedProbeConfigSchema, createNormalizedTargetSchema } from "./builder";
|
||||
import { issue } from "./issues";
|
||||
|
||||
export function createConfigAjv(): Ajv {
|
||||
@@ -21,11 +21,11 @@ export function issuesFromAjvErrors(errors: ErrorObject[], root: unknown, basePa
|
||||
export function validateProbeConfigContract(
|
||||
config: unknown,
|
||||
registry: CheckerRegistry,
|
||||
): { config: null; issues: ConfigValidationIssue[] } | { config: RawProbeConfig; issues: [] } {
|
||||
): { config: NormalizedProbeConfig; issues: [] } | { config: null; issues: ConfigValidationIssue[] } {
|
||||
const ajv = createConfigAjv();
|
||||
const checkers = registry.definitions;
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const rootValidate = ajv.compile(createProbeConfigSchema(checkers));
|
||||
const rootValidate = ajv.compile(createNormalizedProbeConfigSchema(checkers));
|
||||
if (!rootValidate(config)) {
|
||||
issues.push(...issuesFromAjvErrors(rootValidate.errors ?? [], config));
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export function validateProbeConfigContract(
|
||||
const configRecord = config as Record<string, unknown>;
|
||||
const targetsValue: unknown = configRecord["targets"];
|
||||
if (!Array.isArray(targetsValue))
|
||||
return issues.length > 0 ? { config: null, issues } : { config: config as RawProbeConfig, issues: [] };
|
||||
return issues.length > 0 ? { config: null, issues } : { config: config as NormalizedProbeConfig, issues: [] };
|
||||
const targets = targetsValue;
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
const target: unknown = targets[i];
|
||||
@@ -44,14 +44,14 @@ export function validateProbeConfigContract(
|
||||
if (!isString(targetType)) continue;
|
||||
const checker = registry.tryGet(targetType);
|
||||
if (!checker) continue;
|
||||
const targetValidate = ajv.compile(createTargetSchema(checker));
|
||||
const targetValidate = ajv.compile(createNormalizedTargetSchema(checker));
|
||||
if (!targetValidate(target)) {
|
||||
issues.push(...issuesFromAjvErrors(targetValidate.errors ?? [], config, `targets[${i}]`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues.length > 0 ? { config: null, issues } : { config: config as RawProbeConfig, issues: [] };
|
||||
return issues.length > 0 ? { config: null, issues } : { config: config as NormalizedProbeConfig, issues: [] };
|
||||
}
|
||||
|
||||
function buildIssuePath(basePath: string, error: ErrorObject): 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.rawExpect ? JSON.stringify(t.rawExpect) : null;
|
||||
const expect = null;
|
||||
|
||||
if (existingIds.has(t.id)) {
|
||||
updateStmt.run(t.name, t.description, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group, t.id);
|
||||
|
||||
@@ -75,7 +75,6 @@ export interface ResolvedTargetBase {
|
||||
id: string;
|
||||
intervalMs: number;
|
||||
name: null | string;
|
||||
rawExpect?: unknown;
|
||||
timeoutMs: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user