1
0
Files
DiAL/tests/server/checker/config-contract/validate.test.ts
lanyuanxiaoyao 77c6015b3a refactor: 将 checker normalize 职责下沉到各 runner 目录
- 新增 CheckerDefinition.normalize 必需方法,typecheck 兜底遗漏实现
- 新增 expect/normalize.ts 共享 helper(compactExpect、normalizeValue、
  normalizeContent、normalizeKeyed)
- 为 HTTP、Cmd、DB、TCP、UDP、ICMP、LLM、WS、DNS 各新增独立 normalize.ts
- 简化 normalizer.ts:删除所有 checker type switch,改为 registry 委托
- 修复 DNS authoring 简写 bug:durationMs、valueCount、result 等字段
  现可通过完整加载链路
- 新增 DNS 回归测试和 registry 级合同测试
- 更新 docs/development/checker.md:补充 normalize 规范、文件结构、
  测试要求和 checklist
2026-05-25 16:16:41 +08:00

390 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Ajv from "ajv";
import { describe, expect, test } from "bun:test";
import { normalizeAuthoringConfig } from "../../../../src/server/checker/normalizer";
import { createDefaultCheckerRegistry } from "../../../../src/server/checker/runner";
import { createProbeConfigJsonSchema } from "../../../../src/server/checker/schema/export";
import { formatConfigIssues, issue } from "../../../../src/server/checker/schema/issues";
import { validateProbeConfigContract } from "../../../../src/server/checker/schema/validate";
describe("config contract", () => {
test("导出的 probe-config.schema.json 与 fragments 生成结果一致", async () => {
const expected = `${JSON.stringify(createProbeConfigJsonSchema(createDefaultCheckerRegistry()), null, 2)}\n`;
const actual = await Bun.file("probe-config.schema.json").text();
expect(actual).toBe(expected);
});
test("导出 schema 拒绝未知字段和小写 HTTP method", () => {
const ajv = new Ajv({
allErrors: true,
coerceTypes: false,
removeAdditional: false,
strict: true,
useDefaults: false,
});
const validate = ajv.compile(createProbeConfigJsonSchema(createDefaultCheckerRegistry()));
expect(
validate({
targets: [
{
http: { method: "get", unknownHttpField: true, url: "https://example.com" },
id: "api",
name: "api",
type: "http",
},
],
}),
).toBe(false);
});
test("导出 schema 支持 variables 且要求 target id", () => {
const ajv = new Ajv({
allErrors: true,
coerceTypes: false,
removeAdditional: false,
strict: true,
useDefaults: false,
});
const validate = ajv.compile(createProbeConfigJsonSchema(createDefaultCheckerRegistry()));
expect(
validate({
targets: [{ http: { url: "https://example.com" }, id: "api", type: "http" }],
variables: { base_url: "https://example.com", enabled: true, port: 443 },
}),
).toBe(true);
expect(
validate({
targets: [{ http: { url: "https://example.com" }, type: "http" }],
variables: { bad: null },
}),
).toBe(false);
});
test("导出 schema 支持 ValueMatcher primitive 简写且拒绝数组对象简写", () => {
const ajv = new Ajv({
allErrors: true,
coerceTypes: false,
removeAdditional: false,
strict: true,
useDefaults: false,
});
const validate = ajv.compile(createProbeConfigJsonSchema(createDefaultCheckerRegistry()));
const target = (durationMs: unknown) => ({
targets: [{ expect: { durationMs }, http: { url: "https://example.com" }, id: "api", type: "http" }],
});
expect(validate(target(5000))).toBe(true);
expect(validate(target("5000"))).toBe(true);
expect(validate(target(null))).toBe(true);
expect(validate(target([1, 2]))).toBe(false);
expect(validate(target({ foo: "bar" }))).toBe(false);
expect(validate(target({ equals: [1, 2] }))).toBe(true);
expect(validate(target({ equals: { status: "ok" } }))).toBe(true);
});
test("Authoring schema 接受变量引用Normalized schema 拒绝变量引用和 primitive 简写", () => {
const ajv = new Ajv({
allErrors: true,
coerceTypes: false,
removeAdditional: false,
strict: true,
useDefaults: false,
});
const registry = createDefaultCheckerRegistry();
const authoring = ajv.compile(createProbeConfigJsonSchema(registry));
const normalizedConfig = {
targets: [
{
expect: { durationMs: 5000 },
http: { maxRedirects: "${MAX|5}", url: "https://example.com" },
id: "api",
type: "http",
},
],
};
expect(
authoring({
targets: [
{
expect: { durationMs: 5000, status: ["${STATUS}"] },
http: { ignoreSSL: "${SSL|false}", maxRedirects: "${MAX|5}", url: "https://example.com" },
id: "api",
type: "http",
},
],
}),
).toBe(false);
expect(
authoring({
targets: [
{
expect: { durationMs: 5000 },
http: {
ignoreSSL: "${SSL|false}",
maxRedirects: "${MAX|5}",
method: "${METHOD|GET}",
url: "https://example.com",
},
id: "api",
type: "http",
},
{
id: "llm-api",
llm: {
model: "gpt-4o-mini",
prompt: "ping",
provider: "${P|openai}",
url: "https://example.com/v1/chat/completions",
},
type: "llm",
},
],
}),
).toBe(true);
expect(validateProbeConfigContract(normalizedConfig, registry).config).toBeNull();
});
test("导出 schema 拒绝 KeyedExpectations 的数组和对象简写", () => {
const ajv = new Ajv({
allErrors: true,
coerceTypes: false,
removeAdditional: false,
strict: true,
useDefaults: false,
});
const validate = ajv.compile(createProbeConfigJsonSchema(createDefaultCheckerRegistry()));
const target = (headerValue: unknown) => ({
targets: [
{
expect: { headers: { "x-test": headerValue } },
http: { url: "https://example.com" },
id: "api",
type: "http",
},
],
});
expect(validate(target("ok"))).toBe(true);
expect(validate(target({ contains: "ok" }))).toBe(true);
expect(validate(target(["ok"]))).toBe(false);
expect(validate(target({ nested: "ok" }))).toBe(false);
expect(validate(target({ equals: { nested: "ok" } }))).toBe(true);
});
test("Ajv 错误转换为中文结构化 issue", () => {
const result = validateProbeConfigContract(
{
targets: [
{
group: 123,
http: { extra: true },
id: "api",
name: "api",
type: "http",
},
],
unknownRoot: true,
},
createDefaultCheckerRegistry(),
);
expect(result.config).toBeNull();
const message = formatConfigIssues(result.issues);
expect(message).toContain("unknownRoot 是未知字段");
expect(message).toContain('target "api" 的 group 类型不合法');
expect(message).toContain('target "api" 的 http.url 缺少必填字段');
expect(message).toContain('target "api" 的 http.extra 是未知字段');
});
test("ConfigValidationIssue 聚合渲染保留契约和语义错误", () => {
const message = formatConfigIssues([
issue("unknown-field", "targets[0].http.extra", "是未知字段", "api"),
issue("invalid-regex", "targets[0].expect.body[0].regex", "正则不合法", "api"),
]);
expect(message).toBe('target "api" 的 http.extra 是未知字段\ntarget "api" 的 expect.body[0].regex 正则不合法');
});
test("Authoring schema 接受 server.listen.port、llm.options.maxOutputTokens 变量引用", () => {
const ajv = new Ajv({
allErrors: true,
coerceTypes: false,
removeAdditional: false,
strict: true,
useDefaults: false,
});
const validate = ajv.compile(createProbeConfigJsonSchema(createDefaultCheckerRegistry()));
expect(
validate({
server: { listen: { port: "${SERVER_PORT|3000}" } },
targets: [
{
id: "llm",
llm: {
model: "gpt-4o-mini",
options: { maxOutputTokens: "${MAX_TOKENS|100}" },
prompt: "ping",
provider: "openai",
url: "https://example.com/v1/chat/completions",
},
type: "llm",
},
],
}),
).toBe(true);
});
test("Authoring schema 拒绝 expect.status 和 expect.exitCode 数组内的变量引用", () => {
const ajv = new Ajv({
allErrors: true,
coerceTypes: false,
removeAdditional: false,
strict: true,
useDefaults: false,
});
const validate = ajv.compile(createProbeConfigJsonSchema(createDefaultCheckerRegistry()));
expect(
validate({
targets: [
{
expect: { status: ["${STATUS}"] },
http: { url: "https://example.com" },
id: "http",
type: "http",
},
],
}),
).toBe(false);
expect(
validate({
targets: [
{
cmd: { exec: "true" },
expect: { exitCode: ["${CODE}"] },
id: "cmd",
type: "cmd",
},
],
}),
).toBe(false);
});
test("所有 checker 的 authoring ValueMatcher 简写经 normalize 后通过 normalized contract 校验", () => {
const authoringShorthandExamples: Record<string, object> = {
cmd: {
targets: [
{
cmd: { exec: "echo hello" },
expect: { durationMs: 1000 },
id: "cmd-test",
type: "cmd",
},
],
},
db: {
targets: [
{
db: { url: "sqlite://:memory:" },
expect: { durationMs: 2000 },
id: "db-test",
type: "db",
},
],
},
dns: {
targets: [
{
dns: { name: "example.com", resolver: "system" },
expect: { durationMs: 500 },
id: "dns-test",
type: "dns",
},
],
},
http: {
targets: [
{
expect: { durationMs: 5000 },
http: { url: "https://example.com" },
id: "http-test",
type: "http",
},
],
},
icmp: {
targets: [
{
expect: { packetLossPercent: 0 },
icmp: { host: "example.com" },
id: "icmp-test",
type: "icmp",
},
],
},
llm: {
targets: [
{
expect: { durationMs: 10000 },
id: "llm-test",
llm: {
model: "gpt-4o-mini",
prompt: "ping",
provider: "openai",
url: "https://example.com/v1/chat/completions",
},
type: "llm",
},
],
},
tcp: {
targets: [
{
expect: { durationMs: 3000 },
id: "tcp-test",
tcp: { host: "example.com", port: 80 },
type: "tcp",
},
],
},
udp: {
targets: [
{
expect: { durationMs: 1000 },
id: "udp-test",
type: "udp",
udp: { host: "example.com", port: 53 },
},
],
},
ws: {
targets: [
{
expect: { durationMs: 5000 },
id: "ws-test",
type: "ws",
ws: { url: "wss://example.com/ws" },
},
],
},
};
for (const [type, config] of Object.entries(authoringShorthandExamples)) {
const normalizeResult = normalizeAuthoringConfig(config, createDefaultCheckerRegistry());
expect(normalizeResult.issues).toHaveLength(0);
const contract = validateProbeConfigContract(normalizeResult.config, createDefaultCheckerRegistry());
expect(contract.config).not.toBeNull();
expect(
contract.issues,
`Checker "${type}" authoring shorthand should pass normalized contract, got issues: ${JSON.stringify(contract.issues.map((i) => `${i.path}: ${i.message}`))}`,
).toHaveLength(0);
}
});
});