1
0

feat: ValueMatcher 支持 primitive 原始值简写,等价于 { equals: value }

This commit is contained in:
2026-05-19 17:07:47 +08:00
parent 8d8549d07f
commit 12cd05b04e
37 changed files with 1836 additions and 1022 deletions

View File

@@ -62,6 +62,28 @@ describe("config contract", () => {
).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("Ajv 错误转换为中文结构化 issue", () => {
const result = validateProbeConfigContract(
{

View File

@@ -3,12 +3,14 @@ import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ValueMatcher } from "../../../src/server/checker/expect/types";
import type { ResolvedCommandTarget } from "../../../src/server/checker/runner/cmd/types";
import type { ResolvedHttpTarget } from "../../../src/server/checker/runner/http/types";
import type { ResolvedPingTarget } from "../../../src/server/checker/runner/icmp/types";
import type { ResolvedTcpTarget } from "../../../src/server/checker/runner/tcp/types";
import { loadConfig, parseDuration } from "../../../src/server/checker/config-loader";
import { checkValueMatcher } from "../../../src/server/checker/expect/matcher";
import { checkerRegistry } from "../../../src/server/checker/runner";
import { CommandChecker } from "../../../src/server/checker/runner/cmd/execute";
import { HttpChecker } from "../../../src/server/checker/runner/http/execute";
@@ -288,6 +290,32 @@ targets:
expect(config.targets[0]!.name).toBeNull();
});
test("ValueMatcher primitive 简写在加载时归一化后可运行期匹配", async () => {
const configPath = join(tempDir, "matcher-shorthand.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
type: http
http:
url: "http://example.com"
expect:
durationMs: 123
`,
);
const config = await loadConfig(configPath);
const target = config.targets[0]! as ResolvedHttpTarget;
expect(target.expect?.durationMs).toEqual({ equals: 123 });
expect(
checkValueMatcher(123, target.expect?.durationMs as ValueMatcher, {
path: "durationMs",
phase: "duration",
}).matched,
).toBe(true);
});
test("name 为空字符串抛出错误", async () => {
const configPath = join(tempDir, "empty-name.yaml");
await writeFile(
@@ -1076,8 +1104,8 @@ targets:
await expect(loadConfig(configPath)).rejects.toThrow("5xx");
});
test("expect.durationMs 非 matcher 抛出错误", async () => {
const configPath = join(tempDir, "neg-duration.yaml");
test("expect.durationMs 对象简写抛出错误", async () => {
const configPath = join(tempDir, "bad-duration-object.yaml");
await writeFile(
configPath,
`targets:
@@ -1087,11 +1115,12 @@ targets:
http:
url: "http://example.com"
expect:
durationMs: -100
durationMs:
foo: "bar"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("expect.durationMs 必须为 matcher 对象");
await expect(loadConfig(configPath)).rejects.toThrow("expect.durationMs.foo 是未知 matcher");
});
test("expect.body 非数组抛出错误", async () => {

View File

@@ -0,0 +1,31 @@
import { describe, expect, test } from "bun:test";
import { normalizeExpectMatchers, normalizeValueMatcher } from "../../../../src/server/checker/expect/normalize";
describe("normalizeValueMatcher", () => {
test("normalizes primitive values to equals matcher", () => {
expect(normalizeValueMatcher("stop")).toEqual({ equals: "stop" });
expect(normalizeValueMatcher(1)).toEqual({ equals: 1 });
expect(normalizeValueMatcher(true)).toEqual({ equals: true });
expect(normalizeValueMatcher(null)).toEqual({ equals: null });
});
test("leaves undefined, matcher objects, arrays, and plain objects unchanged", () => {
const matcher = { lte: 5000 };
const array = [1, 2];
const object = { foo: "bar" };
expect(normalizeValueMatcher(undefined)).toBeUndefined();
expect(normalizeValueMatcher(matcher)).toBe(matcher);
expect(normalizeValueMatcher(array)).toBe(array);
expect(normalizeValueMatcher(object)).toBe(object);
});
test("normalizes only selected expect keys", () => {
const expectConfig: Record<string, unknown> = { durationMs: 100, responded: true };
normalizeExpectMatchers(expectConfig, ["durationMs"]);
expect(expectConfig).toEqual({ durationMs: { equals: 100 }, responded: true });
});
});

View File

@@ -49,13 +49,13 @@ describe("validateDbConfig", () => {
expect(unknownError!.code).toBe("unknown-field");
});
test("expect.durationMs 非 matcher 返回错误", () => {
test("expect.durationMs 数组简写返回错误", () => {
const result = validateDbConfig({
defaults: {},
targets: [
{
db: { url: "sqlite://:memory:" },
expect: { durationMs: "invalid" },
expect: { durationMs: [1, 2] },
id: "test",
name: "test",
type: "db",

View File

@@ -48,19 +48,19 @@ describe("validatePingConfig", () => {
expect(issues.some((item) => item.path.endsWith("expect.maxPacketLoss"))).toBe(true);
});
test("durationMs 类型非法", () => {
const issues = validate({ expect: { durationMs: -1 }, id: "ping", ping: { host: "127.0.0.1" }, type: "ping" });
test("durationMs 数组简写非法", () => {
const issues = validate({ expect: { durationMs: [1, 2] }, id: "ping", ping: { host: "127.0.0.1" }, type: "ping" });
expect(issues.some((item) => item.path.endsWith("expect.durationMs"))).toBe(true);
});
test("avgLatencyMs 类型非法", () => {
test("avgLatencyMs 对象简写非法", () => {
const issues = validate({
expect: { avgLatencyMs: "slow" },
expect: { avgLatencyMs: { foo: "bar" } },
id: "ping",
ping: { host: "127.0.0.1" },
type: "ping",
});
expect(issues.some((item) => item.path.endsWith("expect.avgLatencyMs"))).toBe(true);
expect(issues.some((item) => item.path.endsWith("expect.avgLatencyMs.foo"))).toBe(true);
});
test("host 为空字符串", () => {

View File

@@ -0,0 +1,115 @@
import { describe, expect, test } from "bun:test";
import type { CheckerValidationInput } from "../../../../../src/server/checker/runner/types";
import { validateCommandConfig } from "../../../../../src/server/checker/runner/cmd/validate";
import { validateDbConfig } from "../../../../../src/server/checker/runner/db/validate";
import { validateHttpConfig } from "../../../../../src/server/checker/runner/http/validate";
import { validatePingConfig } from "../../../../../src/server/checker/runner/icmp/validate";
import { validateLlmConfig } from "../../../../../src/server/checker/runner/llm/validate";
import { validateTcpConfig } from "../../../../../src/server/checker/runner/tcp/validate";
import { validateUdpConfig } from "../../../../../src/server/checker/runner/udp/validate";
function input(target: Record<string, unknown>): CheckerValidationInput {
return { defaults: {}, targets: [target as CheckerValidationInput["targets"][number]] };
}
describe("ValueMatcher primitive shorthand in checker validators", () => {
test("normalizes shorthand for all checker ValueMatcher fields", () => {
const targets = [
{
expect: { durationMs: 100 },
http: { url: "https://example.com" },
id: "http",
type: "http",
validate: validateHttpConfig,
},
{
expect: { durationMs: 100 },
id: "tcp",
tcp: { host: "127.0.0.1", port: 80 },
type: "tcp",
validate: validateTcpConfig,
},
{
expect: { durationMs: 100, responseSize: 1, sourceHost: "127.0.0.1", sourcePort: 53 },
id: "udp",
type: "udp",
udp: { host: "127.0.0.1", port: 53 },
validate: validateUdpConfig,
},
{
expect: { avgLatencyMs: 1, durationMs: 100, maxLatencyMs: 2, packetLossPercent: 0 },
id: "ping",
ping: { host: "127.0.0.1" },
type: "ping",
validate: validatePingConfig,
},
{
cmd: { exec: "true" },
expect: { durationMs: 100 },
id: "cmd",
type: "cmd",
validate: validateCommandConfig,
},
{
db: { url: "sqlite://:memory:" },
expect: { durationMs: 100, rowCount: 1 },
id: "db",
type: "db",
validate: validateDbConfig,
},
{
expect: {
durationMs: 100,
finishReason: "stop",
rawFinishReason: null,
stream: { firstTokenMs: 10 },
usage: { inputTokens: 1, outputTokens: 2, totalTokens: 3 },
},
id: "llm",
llm: {
mode: "stream",
model: "test-model",
prompt: "hello",
provider: "openai",
url: "https://example.com/v1/chat/completions",
},
type: "llm",
validate: validateLlmConfig,
},
];
for (const target of targets) {
const { validate, ...config } = target;
expect(validate(input(config))).toHaveLength(0);
expect((config.expect as Record<string, unknown>)["durationMs"]).toEqual({ equals: 100 });
}
});
test("rejects array and object shorthand while accepting explicit equals", () => {
const arrayTarget = {
expect: { durationMs: [1, 2] },
http: { url: "https://example.com" },
id: "array",
type: "http",
};
const objectTarget = {
expect: { durationMs: { foo: "bar" } },
http: { url: "https://example.com" },
id: "object",
type: "http",
};
const equalsObjectTarget = {
expect: { durationMs: { equals: { foo: "bar" } } },
http: { url: "https://example.com" },
id: "equals-object",
type: "http",
};
expect(validateHttpConfig(input(arrayTarget)).some((issue) => issue.path.includes("durationMs"))).toBe(true);
expect(validateHttpConfig(input(objectTarget)).some((issue) => issue.code === "unknown-matcher")).toBe(true);
expect(validateHttpConfig(input(equalsObjectTarget))).toHaveLength(0);
});
});

View File

@@ -106,18 +106,18 @@ describe("validateTcpConfig", () => {
expect(issues.some((i) => i.path.includes("connected"))).toBe(true);
});
test("expect durationMs 非 matcher", () => {
test("expect durationMs 数组简写非法", () => {
const issues = validateTcpConfig(
makeInput([
{
expect: { durationMs: "slow" },
expect: { durationMs: [1, 2] },
id: "t1",
tcp: { host: "127.0.0.1", port: 80 },
type: "tcp",
},
]),
);
expect(issues.some((i) => i.path.includes("durationMs"))).toBe(true);
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("durationMs"))).toBe(true);
});
test("expect 未知字段", () => {

View File

@@ -213,12 +213,12 @@ describe("validateUdpConfig", () => {
expect(issues.some((i) => i.path.includes("responded") && i.message.includes("响应来源"))).toBe(true);
});
it("reports invalid-type for non-matcher expect.durationMs", () => {
it("reports invalid-type for array shorthand expect.durationMs", () => {
const issues = validateUdpConfig(
makeInput({
targets: [
{
expect: { durationMs: -100 },
expect: { durationMs: [1, 2] },
id: "test",
type: "udp",
udp: { host: "127.0.0.1", port: 53 },