1
0

feat: 重构配置校验为 TypeBox + Ajv + semantic validator,严格禁止未知字段

- 新增 config-contract 模块(TypeBox fragments、Ajv 契约校验、ConfigValidationIssue)
- CheckerDefinition 扩展为含 configKey、schemas、validate 的完整插件接口
- HTTP/Command 各自维护 contract.ts + validate.ts,校验从 resolve 中分离
- resolve 不再承担校验,只做默认值合并和路径/单位解析
- config-loader 流程: unknown → RawProbeConfig → ValidatedProbeConfig → ResolvedConfig
- 导出 probe-config.schema.json,新增 schema/schema:check 脚本
- 更新 DEVELOPMENT.md 新增 1.7 开发新 Checker 完整指引
- 同步更新 4 个 main specs(probe-config、command-checker、expect-body-checkers、checker-runner-abstraction)
This commit is contained in:
2026-05-13 12:19:36 +08:00
parent bce0f8e7a8
commit 7b20b59b79
38 changed files with 3034 additions and 675 deletions

View File

@@ -0,0 +1,71 @@
import Ajv from "ajv";
import { describe, expect, test } from "bun:test";
import { createProbeConfigJsonSchema } from "../../../../src/server/checker/config-contract/export";
import { formatConfigIssues, issue } from "../../../../src/server/checker/config-contract/issues";
import { validateProbeConfigContract } from "../../../../src/server/checker/config-contract/validate";
import { createDefaultCheckerRegistry } from "../../../../src/server/checker/runner";
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" },
name: "api",
type: "http",
},
],
}),
).toBe(false);
});
test("Ajv 错误转换为中文结构化 issue", () => {
const result = validateProbeConfigContract(
{
targets: [
{
group: 123,
http: { extra: true },
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 正则不合法');
});
});

View File

@@ -40,6 +40,11 @@ describe("parseDuration", () => {
expect(parseDuration("1.5s")).toBe(1500);
});
test("拒绝非正整数毫秒结果", () => {
expect(() => parseDuration("0ms")).toThrow("正整数毫秒");
expect(() => parseDuration("1.5ms")).toThrow("正整数毫秒");
});
test("无效格式抛出错误", () => {
expect(() => parseDuration("30")).toThrow("无效的时长格式");
expect(() => parseDuration("abc")).toThrow("无效的时长格式");
@@ -70,6 +75,19 @@ describe("loadConfig", () => {
await rm(tempDir, { force: true, recursive: true });
});
async function expectConfigError(fileName: string, content: string, message: string): Promise<void> {
const configPath = join(tempDir, fileName);
await writeFile(configPath, content);
let error: unknown;
try {
await loadConfig(configPath);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain(message);
}
test("解析最简 HTTP 配置", async () => {
const configPath = join(tempDir, "minimal-http.yaml");
await writeFile(
@@ -310,7 +328,7 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("ignoreSSL 必须为布尔值");
await expect(loadConfig(configPath)).rejects.toThrow("http.ignoreSSL 类型不合法");
});
test("HTTP target maxRedirects 非负整数校验", async () => {
@@ -326,7 +344,7 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("maxRedirects 必须为非负整数");
await expect(loadConfig(configPath)).rejects.toThrow("http.maxRedirects 类型不合法");
});
test("HTTP target status 模式非法抛出错误", async () => {
@@ -413,7 +431,7 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("无效端口号");
await expect(loadConfig(configPath)).rejects.toThrow("server.port 数值范围不合法");
});
test("非法 maxConcurrentChecks 抛出错误", async () => {
@@ -430,7 +448,7 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("maxConcurrentChecks 必须为正整数");
await expect(loadConfig(configPath)).rejects.toThrow("runtime.maxConcurrentChecks 数值范围不合法");
});
test("非法 size 格式抛出错误", async () => {
@@ -623,7 +641,7 @@ targets:
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("group 字段必须为字符串");
await expect(loadConfig(configPath)).rejects.toThrow("group 必须为字符串");
});
test("HTTP headers 非字符串值抛出错误", async () => {
@@ -656,7 +674,7 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("http.body 必须为字符串");
await expect(loadConfig(configPath)).rejects.toThrow("http.body 类型不合法");
});
test("maxBodyBytes 负数抛出错误", async () => {
@@ -930,7 +948,7 @@ targets:
await expect(loadConfig(configPath)).rejects.toThrow("exists 必须为布尔值");
});
test("未知字段忽略不影响启动", async () => {
test("未知字段导致启动失败", async () => {
const configPath = join(tempDir, "unknown-fields.yaml");
await writeFile(
configPath,
@@ -948,12 +966,8 @@ targets:
note: "ignored"
`,
);
const config = await loadConfig(configPath);
expect(config.targets).toHaveLength(1);
const t = config.targets[0]!;
if (t.type === "http") {
expect(t.expect?.status).toEqual([200]);
}
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("unknownHttpField 是未知字段");
});
test("xpath path 非空字符串校验", async () => {
@@ -989,6 +1003,228 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("expect.headers 必须为对象");
await expect(loadConfig(configPath)).rejects.toThrow("expect.headers 类型不合法");
});
test("HTTP method 小写输入失败", async () => {
await expectConfigError(
"lowercase-method.yaml",
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
method: get
`,
"http.method 不在允许范围内",
);
});
test("defaults.http.method 小写输入失败", async () => {
await expectConfigError(
"lowercase-default-method.yaml",
`defaults:
http:
method: post
targets:
- name: "test"
type: http
http:
url: "http://example.com"
`,
"defaults.http.method 不在允许范围内",
);
});
test("HTTP method 大写输入通过", async () => {
const configPath = join(tempDir, "uppercase-method.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
method: POST
`,
);
const config = await loadConfig(configPath);
const target = config.targets[0]!;
expect(target.type).toBe("http");
if (target.type === "http") expect(target.http.method).toBe("POST");
});
test("动态 headers 和 env 允许任意键名", async () => {
const configPath = join(tempDir, "dynamic-maps.yaml");
await writeFile(
configPath,
`defaults:
http:
headers:
X-Default-Header: "default"
targets:
- name: "http-test"
type: http
http:
url: "http://example.com"
headers:
X-Custom-Header: "custom"
expect:
headers:
X-Response-Header:
contains: "ok"
- name: "cmd-test"
type: command
command:
exec: "true"
env:
CUSTOM_ENV_NAME: "custom"
`,
);
const config = await loadConfig(configPath);
const http = config.targets[0]!;
const command = config.targets[1]!;
expect(http.type).toBe("http");
expect(command.type).toBe("command");
if (http.type === "http") {
expect(http.http.headers["X-Default-Header"]).toBe("default");
expect(http.http.headers["X-Custom-Header"]).toBe("custom");
}
if (command.type === "command") expect(command.command.env["CUSTOM_ENV_NAME"]).toBe("custom");
});
test("command args 类型非法", async () => {
await expectConfigError(
"bad-command-args.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
args: "hello"
`,
"command.args 类型不合法",
);
});
test("command cwd 类型非法", async () => {
await expectConfigError(
"bad-command-cwd.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
cwd: 123
`,
"command.cwd 类型不合法",
);
});
test("command env 值类型非法", async () => {
await expectConfigError(
"bad-command-env.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
env:
COUNT: 123
`,
"command.env.COUNT 类型不合法",
);
});
test("command maxOutputBytes 非法", async () => {
await expectConfigError(
"bad-command-max-output.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
maxOutputBytes: "1TB"
`,
"maxOutputBytes 无效的 size 格式",
);
});
test("command expect exitCode 类型非法", async () => {
await expectConfigError(
"bad-command-exit-code.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
expect:
exitCode: [1.5]
`,
"expect.exitCode[0] 类型不合法",
);
});
test("command stdout 空 text rule 非法", async () => {
await expectConfigError(
"bad-command-stdout-empty.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
expect:
stdout:
- {}
`,
"stdout[0] 必须包含至少一个合法 operator",
);
});
test("command stderr 未知 operator 非法", async () => {
await expectConfigError(
"bad-command-stderr-operator.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
expect:
stderr:
- foo: "bar"
`,
"expect.stderr[0].foo 是未知字段",
);
});
test("command stdout match 正则非法", async () => {
await expectConfigError(
"bad-command-stdout-regex.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
expect:
stdout:
- match: "[invalid"
`,
"stdout[0].match 正则不合法",
);
});
test("command expect 未知字段失败", async () => {
await expectConfigError(
"bad-command-expect-unknown.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
expect:
status: [200]
`,
"expect.status 是未知字段",
);
});
});

View File

@@ -3,10 +3,16 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { CheckerContext, ResolveContext } from "../../../../../src/server/checker/runner/types";
import type { ResolvedHttpTarget } from "../../../../../src/server/checker/types";
import { formatConfigIssues } from "../../../../../src/server/checker/config-contract/issues";
import { checkStatus } from "../../../../../src/server/checker/runner/http/expect";
import { HttpChecker } from "../../../../../src/server/checker/runner/http/runner";
const checker = new HttpChecker();
function validateHttpTarget(target: unknown): string {
return formatConfigIssues(checker.validate({ defaults: {}, targets: [target as never] }));
}
const SELF_SIGNED_CERT = `-----BEGIN CERTIFICATE-----
MIIDJTCCAg2gAwIBAgIUTwQU8FzvnvxNYR7mMO0DLcnq+wQwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDUxMjE1NDAyOFoXDTM2MDUw
@@ -561,257 +567,168 @@ describe("HttpChecker", () => {
});
test("6xx 范围模式启动校验失败", () => {
expect(() =>
checker.resolve(
{ expect: { status: ["6xx"] }, http: { url: "https://example.com" }, name: "test", type: "http" },
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("5xx");
const errors = validateHttpTarget({
expect: { status: ["6xx"] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("status 模式必须为 1xx 到 5xx");
});
test("status 数字 99 启动校验失败", () => {
expect(() =>
checker.resolve(
{ expect: { status: [99] }, http: { url: "https://example.com" }, name: "test", type: "http" },
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("100-599");
const errors = validateHttpTarget({
expect: { status: [99] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("100-599");
});
test("body rule 忽略未知字段", () => {
const result = checker.resolve(
{
expect: { body: [{ contains: "ok", note: "ignored" }], status: [200] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
);
expect((result as ResolvedHttpTarget).expect?.body).toEqual([
{ contains: "ok", note: "ignored" },
] as unknown as Array<{ contains: string }>);
test("body rule 未知字段启动失败", () => {
const errors = validateHttpTarget({
expect: { body: [{ contains: "ok", note: "ignored" }], status: [200] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("note 是未知字段");
});
test("body rule 使用 match 字段启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ match: "ok" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("缺少支持的规则类型");
const errors = validateHttpTarget({
expect: { body: [{ match: "ok" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("缺少支持的规则类型");
});
test("非法 regex 启动校验失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ regex: "[invalid" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("regex 正则不合法");
const errors = validateHttpTarget({
expect: { body: [{ regex: "[invalid" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("regex 正则不合法");
});
test("非法 JSONPath 启动校验失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ json: { equals: "ok", path: "status" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
},
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("json.path");
const errors = validateHttpTarget({
expect: { body: [{ json: { equals: "ok", path: "status" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("json.path");
});
test("非法 operator match 启动校验失败", () => {
expect(() =>
checker.resolve(
{
expect: { headers: { "x-test": { match: "[invalid" } } },
http: { url: "https://example.com" },
name: "test",
type: "http",
},
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("match 正则不合法");
const errors = validateHttpTarget({
expect: { headers: { "x-test": { match: "[invalid" } } },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("match 正则不合法");
});
test("非法 operator gte 类型启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ json: { gte: "abc", path: "$.count" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("gte 必须为有限数字");
const errors = validateHttpTarget({
expect: { body: [{ json: { gte: "abc", path: "$.count" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("gte 必须为有限数字");
});
test("非法 operator exists 类型启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ json: { exists: "yes", path: "$.status" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("exists 必须为布尔值");
const errors = validateHttpTarget({
expect: { body: [{ json: { exists: "yes", path: "$.status" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("exists 必须为布尔值");
});
test("纯 operator 空对象启动失败", () => {
const errors = validateHttpTarget({
expect: { headers: { "x-test": {} } },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("必须包含至少一个合法 operator");
});
test("body rule 多个支持字段启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ contains: "ok", regex: "ok" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("只能配置一种规则类型");
const errors = validateHttpTarget({
expect: { body: [{ contains: "ok", regex: "ok" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("只能配置一种规则类型");
});
test("body rule 缺少支持字段启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ foo: "bar" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("缺少支持的规则类型");
const errors = validateHttpTarget({
expect: { body: [{ foo: "bar" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("缺少支持的规则类型");
});
test("css selector 为空启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ css: { selector: "" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("css.selector 必须为非空字符串");
const errors = validateHttpTarget({
expect: { body: [{ css: { selector: "" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("css.selector 必须为非空字符串");
});
test("xpath path 为空启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ xpath: { path: "" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("xpath.path 必须为非空字符串");
const errors = validateHttpTarget({
expect: { body: [{ xpath: { path: "" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("xpath.path 必须为非空字符串");
});
test("expect.headers 非对象启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { headers: "invalid" },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("expect.headers 必须为对象");
test("json rule 允许存在性语义", () => {
const errors = validateHttpTarget({
expect: { body: [{ json: { path: "$.status" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toBe("");
});
test("expect.body 非数组启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: "not-array" },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("expect.body 必须为数组");
});
test("maxDurationMs 负数启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { maxDurationMs: -100 },
http: { url: "https://example.com" },
name: "test",
type: "http",
},
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("maxDurationMs 必须为非负有限数字");
});
test("http.body 非字符串启动失败", () => {
expect(() =>
checker.resolve(
{
http: { body: 123, url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("http.body 必须为字符串");
});
test("http.headers 非字符串值启动失败", () => {
expect(() =>
checker.resolve(
{
http: { headers: { "X-Test": 123 }, url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("http.headers");
});
test("http.headers 非对象启动失败", () => {
expect(() =>
checker.resolve(
{
http: { headers: "invalid", url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("http.headers 必须为对象");
test("equals 支持对象和数组", () => {
const errors = validateHttpTarget({
expect: {
body: [
{ json: { equals: { status: "ok" }, path: "$.payload" } },
{ json: { equals: ["a", "b"], path: "$.items" } },
],
},
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toBe("");
});
});
@@ -825,60 +742,14 @@ describe("HttpChecker.resolve", () => {
};
}
test("method 非法抛出错误", () => {
expect(() =>
checker.resolve(
{ http: { method: "INVALID", url: "https://example.com" }, name: "test", type: "http" },
makeResolveContext(),
),
).toThrow("不合法");
});
test("URL 不以 http(s):// 开头抛出错误", () => {
expect(() =>
checker.resolve({ http: { url: "ftp://example.com" }, name: "test", type: "http" }, makeResolveContext()),
).toThrow("格式不合法");
});
test("maxRedirects 为负数抛出错误", () => {
expect(() =>
checker.resolve(
{ http: { maxRedirects: -1, url: "https://example.com" }, name: "test", type: "http" },
makeResolveContext(),
),
).toThrow("非负整数");
});
test("maxRedirects 非整数抛出错误", () => {
const target = {
http: { maxRedirects: 1.5, url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0];
expect(() => checker.resolve(target, makeResolveContext())).toThrow("非负整数");
});
test("ignoreSSL 非布尔值抛出错误", () => {
const target = {
http: { ignoreSSL: "true", url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0];
expect(() => checker.resolve(target, makeResolveContext())).toThrow("ignoreSSL 必须为布尔值");
});
test("缺少 http 分组抛出清晰错误", () => {
const target = { name: "test", type: "http" } as unknown as Parameters<HttpChecker["resolve"]>[0];
expect(() => checker.resolve(target, makeResolveContext())).toThrow("缺少 http.url 字段");
});
test("expect.status 非法模式抛出错误", () => {
expect(() =>
checker.resolve(
{ expect: { status: ["abc"] }, http: { url: "https://example.com" }, name: "test", type: "http" },
makeResolveContext(),
),
).toThrow("不合法");
const errors = validateHttpTarget({
expect: { status: ["abc"] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("status 模式必须为 1xx 到 5xx");
});
test("ignoreSSL 默认值为 false", () => {
@@ -897,14 +768,6 @@ describe("HttpChecker.resolve", () => {
expect((result as ResolvedHttpTarget).http.maxRedirects).toBe(0);
});
test("method 统一转大写", () => {
const result = checker.resolve(
{ http: { method: "get", url: "https://example.com" }, name: "test", type: "http" },
makeResolveContext(),
);
expect((result as ResolvedHttpTarget).http.method).toBe("GET");
});
test("合法 status 范围模式通过校验", () => {
const result = checker.resolve(
{ expect: { status: ["2xx", 301] }, http: { url: "https://example.com" }, name: "test", type: "http" },

View File

@@ -1,16 +1,25 @@
import { Type } from "@sinclair/typebox";
import { describe, expect, test } from "bun:test";
import type { Checker } from "../../../../src/server/checker/runner/types";
import type { CheckResult, ResolvedTarget } from "../../../../src/server/checker/types";
import { createDefaultCheckerRegistry } from "../../../../src/server/checker/runner";
import { CheckerRegistry } from "../../../../src/server/checker/runner/registry";
function createChecker(type: string): Checker {
return {
configKey: type,
execute: () => Promise.resolve<CheckResult>({} as unknown as CheckResult),
resolve: () => ({}) as unknown as ResolvedTarget,
schemas: {
config: Type.Object({}, { additionalProperties: false }),
defaults: Type.Object({}, { additionalProperties: false }),
expect: Type.Object({}, { additionalProperties: false }),
},
serialize: () => ({ config: "", target: "" }),
type,
validate: () => [],
};
}
@@ -39,4 +48,30 @@ describe("CheckerRegistry", () => {
registry.register(createChecker("command"));
expect(registry.supportedTypes).toEqual(["http", "command"]);
});
test("definitions 返回注册定义", () => {
const registry = new CheckerRegistry();
const checker = createChecker("http");
registry.register(checker);
expect(registry.definitions).toEqual([checker]);
});
test("tryGet 未注册返回 undefined", () => {
const registry = new CheckerRegistry();
expect(registry.tryGet("missing")).toBeUndefined();
});
test("默认 registry 创建 fresh 实例且互不污染", () => {
const first = createDefaultCheckerRegistry();
const second = createDefaultCheckerRegistry();
first.register(createChecker("custom"));
expect(first.supportedTypes).toEqual(["http", "command", "custom"]);
expect(second.supportedTypes).toEqual(["http", "command"]);
expect(
first.definitions.every(
(checker) => checker.schemas.config && checker.schemas.defaults && checker.schemas.expect,
),
).toBe(true);
});
});

View File

@@ -69,6 +69,13 @@ describe("applyOperator", () => {
expect(applyOperator(true, { equals: true })).toBe(true);
});
test("equals 支持 JSON 对象和数组", () => {
expect(applyOperator({ status: "ok" }, { equals: { status: "ok" } })).toBe(true);
expect(applyOperator({ status: "ok" }, { equals: { status: "fail" } })).toBe(false);
expect(applyOperator(["a", "b"], { equals: ["a", "b"] })).toBe(true);
expect(applyOperator(["a", "b"], { equals: ["b", "a"] })).toBe(false);
});
test("contains 操作符", () => {
expect(applyOperator("hello world", { contains: "hello" })).toBe(true);
expect(applyOperator("hello world", { contains: "missing" })).toBe(false);

View File

@@ -24,6 +24,7 @@ describe("parseSize", () => {
test("解析小数", () => {
expect(parseSize("1.5MB")).toBe(1572864);
expect(parseSize("1.5KB")).toBe(1536);
});
test("数字直接返回", () => {
@@ -48,4 +49,8 @@ describe("parseSize", () => {
expect(() => parseSize("abc")).toThrow("无效的 size 格式");
expect(() => parseSize("")).toThrow("无效的 size 格式");
});
test("字符串解析为非整数字节时抛出错误", () => {
expect(() => parseSize("1.5B")).toThrow("非负安全整数字节数");
});
});