1
0

refactor: 统一 expect 断言体系,引入共享 ValueMatcher/ContentRules/KeyValueExpect 模型

- 引入共享 ValueMatcher(equals/contains/regex/exists/empty/gt/gte/lt/lte)
- 引入共享 ContentRules 数组(direct/json/css/xpath 提取器)
- 引入共享 KeyValueExpect(动态键值断言,字面量等价 equals)
- maxDurationMs → durationMs: ValueMatcher(所有 checker)
- match → regex(固定无 flags)
- Ping max* → packetLossPercent/avgLatencyMs/maxLatencyMs(ValueMatcher)
- LLM finishReason/rawFinishReason → ValueMatcher
- DB 新增 result: ContentRules
- TCP banner → ContentRules 数组
- 删除旧模块:operator.ts、validate-operator.ts、duration.ts、body.ts、text.ts、output.ts
- 更新全部 checker schema/validate/expect/execute
- 更新 probe-config.schema.json、probes.example.yaml
- 更新 README.md、DEVELOPMENT.md(含 expect 字段选择规范)
- 同步 10 个 delta specs 到主 specs,归档 change
This commit is contained in:
2026-05-19 14:24:27 +08:00
parent 349896bd02
commit 7a635a0a9f
85 changed files with 4290 additions and 2028 deletions

View File

@@ -824,7 +824,8 @@ targets:
- json:
path: "$.status"
equals: "ok"
maxDurationMs: 3000
durationMs:
lte: 3000
`,
);
@@ -833,7 +834,7 @@ targets:
if (t.type === "http") {
expect(t.expect).toEqual({
body: [{ contains: "ok" }, { json: { equals: "ok", path: "$.status" } }],
maxDurationMs: 3000,
durationMs: { lte: 3000 },
status: [200, 201],
});
}
@@ -853,10 +854,11 @@ targets:
exitCode: [0, 2]
stdout:
- contains: "ok"
- match: "done"
- regex: "done"
stderr:
- empty: true
maxDurationMs: 5000
durationMs:
lte: 5000
`,
);
@@ -864,10 +866,10 @@ targets:
const t = config.targets[0]!;
if (t.type === "cmd") {
expect(t.expect).toEqual({
durationMs: { lte: 5000 },
exitCode: [0, 2],
maxDurationMs: 5000,
stderr: [{ empty: true }],
stdout: [{ contains: "ok" }, { match: "done" }],
stdout: [{ contains: "ok" }, { regex: "done" }],
});
}
});
@@ -1074,7 +1076,7 @@ targets:
await expect(loadConfig(configPath)).rejects.toThrow("5xx");
});
test("expect.maxDurationMs 负数抛出错误", async () => {
test("expect.durationMs 非 matcher 抛出错误", async () => {
const configPath = join(tempDir, "neg-duration.yaml");
await writeFile(
configPath,
@@ -1085,11 +1087,11 @@ targets:
http:
url: "http://example.com"
expect:
maxDurationMs: -100
durationMs: -100
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("maxDurationMs 必须为非负有限数字");
await expect(loadConfig(configPath)).rejects.toThrow("expect.durationMs 必须为 matcher 对象");
});
test("expect.body 非数组抛出错误", async () => {
@@ -1126,7 +1128,7 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("缺少支持的规则类型");
await expect(loadConfig(configPath)).rejects.toThrow("foo 是未知字段");
});
test("body rule 使用 match 字段(非支持)抛出错误", async () => {
@@ -1145,10 +1147,10 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("缺少支持的规则类型");
await expect(loadConfig(configPath)).rejects.toThrow("match 是未知字段");
});
test("body rule 多个支持字段抛出错误", async () => {
test("body rule 直接 matcher 混入 extractor 抛出错误", async () => {
const configPath = join(tempDir, "bad-body-rule-multi.yaml");
await writeFile(
configPath,
@@ -1161,11 +1163,12 @@ targets:
expect:
body:
- contains: "ok"
regex: "ok"
json:
path: "$.status"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("只能配置一种规则类型");
await expect(loadConfig(configPath)).rejects.toThrow("直接 matcher 不能与 extractor 混用");
});
test("body regex 非法正则抛出错误", async () => {
@@ -1228,7 +1231,7 @@ targets:
await expect(loadConfig(configPath)).rejects.toThrow("css.selector 必须为非空字符串");
});
test("operator match 非法正则抛出错误", async () => {
test("旧 match matcher 抛出错误", async () => {
const configPath = join(tempDir, "bad-op-match.yaml");
await writeFile(
configPath,
@@ -1245,7 +1248,7 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("match 正则不合法");
await expect(loadConfig(configPath)).rejects.toThrow("match 是未知 matcher");
});
test("operator gte 非数字抛出错误", async () => {
@@ -1531,7 +1534,7 @@ targets:
stdout:
- {}
`,
"stdout[0] 必须包含至少一个合法 operator",
"stdout[0] 必须包含至少一个合法 matcher",
);
});
@@ -1552,7 +1555,7 @@ targets:
);
});
test("cmd stdout match 正则非法", async () => {
test("cmd stdout match 字段非法", async () => {
await expectConfigError(
"bad-cmd-stdout-regex.yaml",
`targets:
@@ -1565,7 +1568,7 @@ targets:
stdout:
- match: "[invalid"
`,
"stdout[0].match 正则不合法",
"stdout[0].match 是未知字段",
);
});
@@ -1878,14 +1881,14 @@ targets:
readBanner: true
expect:
banner:
contains: "ESMTP"
- contains: "ESMTP"
`,
);
const config = await loadConfig(configPath);
const t = config.targets[0]! as ResolvedTcpTarget;
expect(t.tcp.readBanner).toBe(true);
expect(t.expect?.banner).toEqual({ contains: "ESMTP" });
expect(t.expect?.banner).toEqual([{ contains: "ESMTP" }]);
});
test("tcp expect.banner 未开启 readBanner 抛出错误", async () => {
@@ -1954,7 +1957,7 @@ targets:
);
});
test("tcp expect connected 和 maxDurationMs", async () => {
test("tcp expect connected 和 durationMs", async () => {
const configPath = join(tempDir, "tcp-expect-connected.yaml");
await writeFile(
configPath,
@@ -1966,14 +1969,15 @@ targets:
port: 80
expect:
connected: false
maxDurationMs: 5000
durationMs:
lte: 5000
`,
);
const config = await loadConfig(configPath);
const t = config.targets[0]! as ResolvedTcpTarget;
expect(t.expect?.connected).toBe(false);
expect(t.expect?.maxDurationMs).toBe(5000);
expect(t.expect?.durationMs).toEqual({ lte: 5000 });
});
test("解析最简 ping 配置", async () => {
@@ -2011,10 +2015,14 @@ targets:
packetSize: 1472
expect:
alive: true
maxPacketLoss: 10
maxAvgLatencyMs: 200
maxMaxLatencyMs: 500
maxDurationMs: 5000
packetLossPercent:
lte: 10
avgLatencyMs:
lte: 200
maxLatencyMs:
lte: 500
durationMs:
lte: 5000
`,
);
@@ -2023,10 +2031,10 @@ targets:
expect(t.ping).toEqual({ count: 5, host: "10.0.0.1", packetSize: 1472 });
expect(t.expect).toEqual({
alive: true,
maxAvgLatencyMs: 200,
maxDurationMs: 5000,
maxMaxLatencyMs: 500,
maxPacketLoss: 10,
avgLatencyMs: { lte: 200 },
durationMs: { lte: 5000 },
maxLatencyMs: { lte: 500 },
packetLossPercent: { lte: 10 },
});
});

View File

@@ -74,9 +74,9 @@ describe("DbChecker", () => {
expect(result.failure!.message).toBeTruthy();
});
test("maxDurationMs 超时返回失败", async () => {
test("durationMs 超时返回失败", async () => {
const result = await checker.execute(
makeTarget({ query: "SELECT 1" }, { expect: { maxDurationMs: -1 } }),
makeTarget({ query: "SELECT 1" }, { expect: { durationMs: { lt: 0 } } }),
makeCtx(),
);
expect(result.matched).toBe(false);

View File

@@ -4,35 +4,35 @@ import { checkRowCount, checkRows } from "../../../../../src/server/checker/runn
describe("checkRowCount", () => {
test("空数组通过 rowCount gte 0", () => {
const result = checkRowCount([], { gte: 0 });
const result = checkRowCount(0, { gte: 0 });
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
test("非数组视为 0 行", () => {
const result = checkRowCount(null, { gte: 0 });
test("0 行通过 gte 0", () => {
const result = checkRowCount(0, { gte: 0 });
expect(result.matched).toBe(true);
});
test("rowCount gte 通过", () => {
const result = checkRowCount([1, 2, 3], { gte: 3 });
const result = checkRowCount(3, { gte: 3 });
expect(result.matched).toBe(true);
});
test("rowCount gte 失败", () => {
const result = checkRowCount([1, 2], { gte: 3 });
const result = checkRowCount(2, { gte: 3 });
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("rowCount");
expect(result.failure!.path).toBe("rowCount");
});
test("rowCount equals 通过", () => {
const result = checkRowCount([1, 2, 3], { equals: 3 });
const result = checkRowCount(3, { equals: 3 });
expect(result.matched).toBe(true);
});
test("rowCount equals 失败", () => {
const result = checkRowCount([1, 2, 3], { equals: 5 });
const result = checkRowCount(3, { equals: 5 });
expect(result.matched).toBe(false);
});
});
@@ -117,8 +117,8 @@ describe("checkRows", () => {
expect(result.matched).toBe(true);
});
test("match 正则匹配", () => {
const result = checkRows([{ code: "ABC-123" }], [{ code: { match: "^ABC-" } }]);
test("regex 正则匹配", () => {
const result = checkRows([{ code: "ABC-123" }], [{ code: { regex: "^ABC-" } }]);
expect(result.matched).toBe(true);
});

View File

@@ -49,20 +49,20 @@ describe("validateDbConfig", () => {
expect(unknownError!.code).toBe("unknown-field");
});
test("expect.maxDurationMs 非数字返回错误", () => {
test("expect.durationMs 非 matcher 返回错误", () => {
const result = validateDbConfig({
defaults: {},
targets: [
{
db: { url: "sqlite://:memory:" },
expect: { maxDurationMs: "invalid" },
expect: { durationMs: "invalid" },
id: "test",
name: "test",
type: "db",
},
],
});
const durationError = result.find((e) => e.path.includes("expect.maxDurationMs"));
const durationError = result.find((e) => e.path.includes("expect.durationMs"));
expect(durationError).toBeDefined();
expect(durationError!.code).toBe("invalid-type");
});
@@ -76,7 +76,7 @@ describe("validateDbConfig", () => {
});
const rowCountError = result.find((e) => e.path.includes("expect.rowCount"));
expect(rowCountError).toBeDefined();
expect(rowCountError!.code).toBe("unknown-operator");
expect(rowCountError!.code).toBe("unknown-matcher");
});
test("expect.rows 不是数组返回错误", () => {
@@ -103,13 +103,13 @@ describe("validateDbConfig", () => {
expect(rowError!.code).toBe("invalid-type");
});
test("expect.rows 中 match 正则非法返回错误", () => {
test("expect.rows 中 regex 正则非法返回错误", () => {
const result = validateDbConfig({
defaults: {},
targets: [
{
db: { url: "sqlite://:memory:" },
expect: { rows: [{ name: { match: "[invalid" } }] },
expect: { rows: [{ name: { regex: "[invalid" } }] },
id: "test",
name: "test",
type: "db",
@@ -137,7 +137,7 @@ describe("validateDbConfig", () => {
targets: [
{
db: { query: "SELECT 1", url: "sqlite://:memory:" },
expect: { maxDurationMs: 5000, rowCount: { gte: 1 }, rows: [{ cnt: { gte: 1 } }] },
expect: { durationMs: { lte: 5000 }, rowCount: { gte: 1 }, rows: [{ cnt: { gte: 1 } }] },
id: "test",
name: "test",
type: "db",

View File

@@ -26,7 +26,7 @@ describe("checkHeaders", () => {
const headers = { "content-type": "application/json" };
expect(checkHeaders(headers, { "content-type": { contains: "json" } }).matched).toBe(true);
expect(checkHeaders(headers, { "content-type": { match: "^application/" } }).matched).toBe(true);
expect(checkHeaders(headers, { "content-type": { regex: "^application/" } }).matched).toBe(true);
expect(checkHeaders(headers, { "content-type": { contains: "xml" } }).matched).toBe(false);
});

View File

@@ -487,7 +487,7 @@ describe("HttpChecker", () => {
expect(result.durationMs!).toBeGreaterThanOrEqual(0);
});
test("expect.maxDurationMs 使用完整耗时", async () => {
test("expect.durationMs 使用完整耗时", async () => {
const slowServer = Bun.serve({
async fetch() {
await new Promise((resolve) => setTimeout(resolve, 50));
@@ -498,7 +498,7 @@ describe("HttpChecker", () => {
try {
const result = await checker.execute(
makeTarget({
expect: { body: [{ contains: "x" }], maxDurationMs: 10 },
expect: { body: [{ contains: "x" }], durationMs: { lte: 10 } },
url: `http://localhost:${slowServer.port}/`,
}),
makeCtx(),
@@ -521,7 +521,7 @@ describe("HttpChecker", () => {
test("body 失败优先于 duration 检查", async () => {
const result = await checker.execute(
makeTarget({
expect: { body: [{ contains: "nonexistent" }], maxDurationMs: 999999 },
expect: { body: [{ contains: "nonexistent" }], durationMs: { lte: 999999 } },
url: `${baseUrl}/ok`,
}),
makeCtx(),
@@ -689,7 +689,7 @@ describe("HttpChecker", () => {
name: "test",
type: "http",
});
expect(errors).toContain("缺少支持的规则类型");
expect(errors).toContain("match 是未知字段");
});
test("非法 regex 启动校验失败", () => {
@@ -722,19 +722,19 @@ describe("HttpChecker", () => {
expect(errors).toContain("json.path");
});
test("非法 operator match 启动校验失败", () => {
test("旧 match matcher 启动校验失败", () => {
const errors = validateHttpTarget({
expect: { headers: { "x-test": { match: "[invalid" } } },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("match 正则不合法");
expect(errors).toContain("match 是未知 matcher");
});
test("ReDoS operator match 启动校验失败", () => {
test("ReDoS regex matcher 启动校验失败", () => {
const errors = validateHttpTarget({
expect: { headers: { "x-test": { match: "(\\d+)*x" } } },
expect: { headers: { "x-test": { regex: "(\\d+)*x" } } },
http: { url: "https://example.com" },
name: "test",
type: "http",
@@ -769,17 +769,17 @@ describe("HttpChecker", () => {
name: "test",
type: "http",
});
expect(errors).toContain("必须包含至少一个合法 operator");
expect(errors).toContain("必须包含至少一个合法 matcher");
});
test("body rule 多个支持字段启动失败", () => {
const errors = validateHttpTarget({
expect: { body: [{ contains: "ok", regex: "ok" }] },
expect: { body: [{ contains: "ok", json: { path: "$.status" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("只能配置一种规则类型");
expect(errors).toContain("直接 matcher 不能与 extractor 混用");
});
test("body rule 缺少支持字段启动失败", () => {
@@ -789,7 +789,7 @@ describe("HttpChecker", () => {
name: "test",
type: "http",
});
expect(errors).toContain("缺少支持的规则类型");
expect(errors).toContain("foo 是未知字段");
});
test("css selector 为空启动失败", () => {

View File

@@ -60,7 +60,10 @@ rtt min/avg/max/mdev = 1.234/2.345/3.456/0.567 ms`);
test("alive 失败短路", async () => {
mockSpawn(`3 packets transmitted, 0 received, 100% packet loss, time 2003ms`);
const result = await checker.execute(makeTarget({ expect: { alive: true, maxAvgLatencyMs: 100 } }), makeCtx());
const result = await checker.execute(
makeTarget({ expect: { alive: true, avgLatencyMs: { lte: 100 } } }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("alive");
expect(result.statusDetail).toBe("unreachable (0/3 received)");
@@ -75,7 +78,7 @@ rtt min/avg/max/mdev = 1.234/2.345/3.456/0.567 ms`);
test("packetLoss 断言失败", async () => {
mockSpawn(`3 packets transmitted, 2 received, 33% packet loss, time 2003ms
rtt min/avg/max/mdev = 1.234/156.000/340.000/0.567 ms`);
const result = await checker.execute(makeTarget({ expect: { maxPacketLoss: 10 } }), makeCtx());
const result = await checker.execute(makeTarget({ expect: { packetLossPercent: { lte: 10 } } }), makeCtx());
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("packetLoss");
expect(result.statusDetail).toContain("max 340ms");

View File

@@ -16,22 +16,22 @@ describe("ping expect", () => {
});
test("packetLoss 通过和失败", () => {
expect(checkPacketLoss(0, 10).matched).toBe(true);
const result = checkPacketLoss(33, 10);
expect(checkPacketLoss(0, { lte: 10 }).matched).toBe(true);
const result = checkPacketLoss(33, { lte: 10 });
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("packetLoss");
});
test("avgLatency 通过和失败", () => {
expect(checkAvgLatency(12, 200).matched).toBe(true);
const result = checkAvgLatency(156, 100);
expect(checkAvgLatency(12, { lte: 200 }).matched).toBe(true);
const result = checkAvgLatency(156, { lte: 100 });
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("avgLatency");
});
test("maxLatency 通过和失败", () => {
expect(checkMaxLatency(340, 500).matched).toBe(true);
const result = checkMaxLatency(340, 200);
expect(checkMaxLatency(340, { lte: 500 }).matched).toBe(true);
const result = checkMaxLatency(340, { lte: 200 });
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("maxLatency");
});

View File

@@ -43,24 +43,24 @@ describe("validatePingConfig", () => {
expect(issues.some((item) => item.path.endsWith("expect.status"))).toBe(true);
});
test("expect 数值非法", () => {
test("expect 数值旧字段非法", () => {
const issues = validate({ expect: { maxPacketLoss: 101 }, id: "ping", ping: { host: "127.0.0.1" }, type: "ping" });
expect(issues.some((item) => item.path.endsWith("expect.maxPacketLoss"))).toBe(true);
});
test("maxDurationMs 类型非法", () => {
const issues = validate({ expect: { maxDurationMs: -1 }, id: "ping", ping: { host: "127.0.0.1" }, type: "ping" });
expect(issues.some((item) => item.path.endsWith("expect.maxDurationMs"))).toBe(true);
test("durationMs 类型非法", () => {
const issues = validate({ expect: { durationMs: -1 }, id: "ping", ping: { host: "127.0.0.1" }, type: "ping" });
expect(issues.some((item) => item.path.endsWith("expect.durationMs"))).toBe(true);
});
test("maxAvgLatencyMs 类型非法", () => {
test("avgLatencyMs 类型非法", () => {
const issues = validate({
expect: { maxAvgLatencyMs: "slow" },
expect: { avgLatencyMs: "slow" },
id: "ping",
ping: { host: "127.0.0.1" },
type: "ping",
});
expect(issues.some((item) => item.path.endsWith("expect.maxAvgLatencyMs"))).toBe(true);
expect(issues.some((item) => item.path.endsWith("expect.avgLatencyMs"))).toBe(true);
});
test("host 为空字符串", () => {

View File

@@ -135,7 +135,7 @@ describe("LlmChecker execute - 非流式", () => {
});
test("finishReason expect 不匹配", async () => {
const result = await checker.execute(makeTarget(undefined, { finishReason: "length" }), makeCtx());
const result = await checker.execute(makeTarget(undefined, { finishReason: { equals: "length" } }), makeCtx());
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("finishReason");
});

View File

@@ -2,8 +2,12 @@ import { describe, expect, test } from "bun:test";
import type { LlmCheckObservation } from "../../../../../src/server/checker/runner/llm/types";
import { checkContentRules } from "../../../../../src/server/checker/expect/content";
import { runExpects } from "../../../../../src/server/checker/runner/llm/expect";
import { checkOutputRules } from "../../../../../src/server/checker/runner/llm/output";
function checkOutputRules(outputText: null | string, rules: Parameters<typeof checkContentRules>[1]) {
return checkContentRules(outputText, rules, { path: "output", phase: "output" });
}
function makeObservation(overrides?: Partial<LlmCheckObservation>): LlmCheckObservation {
return {
@@ -72,7 +76,7 @@ describe("LLM runExpects", () => {
test("全部 expect 通过", () => {
const observation = makeObservation();
const result = runExpects(observation, {
finishReason: "stop",
finishReason: { equals: "stop" },
output: [{ contains: "OK" }],
status: [200],
});
@@ -95,14 +99,14 @@ describe("LLM runExpects", () => {
test("finishReason 不匹配失败", () => {
const observation = makeObservation();
const result = runExpects(observation, { finishReason: "length" });
const result = runExpects(observation, { finishReason: { equals: "length" } });
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("finishReason");
});
test("rawFinishReason 不匹配失败", () => {
const observation = makeObservation();
const result = runExpects(observation, { rawFinishReason: "end_turn" });
const result = runExpects(observation, { rawFinishReason: { equals: "end_turn" } });
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("rawFinishReason");
});

View File

@@ -206,15 +206,15 @@ describe("LlmChecker validate", () => {
defaults: {},
targets: [makeRawTarget({ expect: { output: [{}] } })],
});
expect(issues.some((i) => i.code === "missing-body-rule")).toBe(true);
expect(issues.some((i) => i.code === "empty-matcher")).toBe(true);
});
test("expect.output 同时多种规则类型报错", () => {
test("expect.output 直接 matcher 混入 extractor 报错", () => {
const issues = validateLlmConfig({
defaults: {},
targets: [makeRawTarget({ expect: { output: [{ contains: "y", equals: "x" }] } })],
targets: [makeRawTarget({ expect: { output: [{ contains: "y", json: { path: "$.status" } }] } })],
});
expect(issues.some((i) => i.code === "multiple-body-rules")).toBe(true);
expect(issues.some((i) => i.code === "invalid-content-rule")).toBe(true);
});
test("expect.output regex ReDoS 报错", () => {

View File

@@ -1,6 +1,10 @@
import { describe, expect, test } from "bun:test";
import { checkBodyExpect } from "../../../../../src/server/checker/runner/http/body";
import { checkContentRules } from "../../../../../src/server/checker/expect/content";
function checkBodyExpect(body: string, rules?: Parameters<typeof checkContentRules>[1]) {
return checkContentRules(body, rules, { path: "body", phase: "body" });
}
describe("checkBodyExpect (BodyRule[])", () => {
test("无规则返回匹配成功", () => {
@@ -57,7 +61,7 @@ describe("checkBodyExpect (BodyRule[])", () => {
test("json 操作符匹配", () => {
const body = JSON.stringify({ count: 42, version: "v2.1.0" });
expect(checkBodyExpect(body, [{ json: { gte: 10, path: "$.count" } }]).matched).toBe(true);
expect(checkBodyExpect(body, [{ json: { match: "\\d+\\.\\d+\\.\\d+", path: "$.version" } }]).matched).toBe(true);
expect(checkBodyExpect(body, [{ json: { path: "$.version", regex: "\\d+\\.\\d+\\.\\d+" } }]).matched).toBe(true);
expect(checkBodyExpect(body, [{ json: { gte: 100, path: "$.count" } }]).matched).toBe(false);
});

View File

@@ -1,6 +1,13 @@
import { describe, expect, test } from "bun:test";
import { checkDuration } from "../../../../../src/server/checker/expect/duration";
import { checkValueMatcher } from "../../../../../src/server/checker/expect/matcher";
function checkDuration(durationMs: number, maxDurationMs?: number) {
return checkValueMatcher(durationMs, maxDurationMs === undefined ? undefined : { lte: maxDurationMs }, {
path: "durationMs",
phase: "duration",
});
}
describe("checkDuration", () => {
test("未配置 maxDurationMs 返回匹配成功", () => {

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { applyOperator, checkExpectValue, evaluateJsonPath } from "../../../../../src/server/checker/expect/operator";
import { applyMatcher, checkExpectValue, evaluateJsonPath } from "../../../../../src/server/checker/expect/matcher";
describe("evaluateJsonPath", () => {
const obj = {
@@ -55,81 +55,83 @@ describe("evaluateJsonPath", () => {
});
});
describe("applyOperator", () => {
describe("applyMatcher", () => {
test("equals 操作符", () => {
expect(applyOperator("ok", { equals: "ok" })).toBe(true);
expect(applyOperator("ok", { equals: "error" })).toBe(false);
expect(applyOperator(42, { equals: 42 })).toBe(true);
expect(applyOperator(42, { equals: 41 })).toBe(false);
expect(applyOperator(null, { equals: null })).toBe(true);
expect(applyOperator(true, { equals: true })).toBe(true);
expect(applyMatcher("ok", { equals: "ok" })).toBe(true);
expect(applyMatcher("ok", { equals: "error" })).toBe(false);
expect(applyMatcher(42, { equals: 42 })).toBe(true);
expect(applyMatcher(42, { equals: 41 })).toBe(false);
expect(applyMatcher(null, { equals: null })).toBe(true);
expect(applyMatcher(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);
expect(applyMatcher({ status: "ok" }, { equals: { status: "ok" } })).toBe(true);
expect(applyMatcher({ status: "ok" }, { equals: { status: "fail" } })).toBe(false);
expect(applyMatcher(["a", "b"], { equals: ["a", "b"] })).toBe(true);
expect(applyMatcher(["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);
expect(applyOperator(12345, { contains: "23" })).toBe(true);
expect(applyMatcher("hello world", { contains: "hello" })).toBe(true);
expect(applyMatcher("hello world", { contains: "missing" })).toBe(false);
expect(applyMatcher(12345, { contains: "23" })).toBe(true);
});
test("match 操作符", () => {
expect(applyOperator("v2.1.0", { match: "\\d+\\.\\d+\\.\\d+" })).toBe(true);
expect(applyOperator("v2.1", { match: "\\d+\\.\\d+\\.\\d+" })).toBe(false);
expect(applyOperator("abc123", { match: "^\\w+\\d+$" })).toBe(true);
test("regex matcher", () => {
expect(applyMatcher("v2.1.0", { regex: "\\d+\\.\\d+\\.\\d+" })).toBe(true);
expect(applyMatcher("v2.1", { regex: "\\d+\\.\\d+\\.\\d+" })).toBe(false);
expect(applyMatcher("abc123", { regex: "^\\w+\\d+$" })).toBe(true);
});
test("empty 操作符", () => {
expect(applyOperator("", { empty: true })).toBe(true);
expect(applyOperator(null, { empty: true })).toBe(true);
expect(applyOperator(undefined, { empty: true })).toBe(true);
expect(applyOperator([], { empty: true })).toBe(true);
expect(applyOperator({}, { empty: true })).toBe(true);
expect(applyOperator("ok", { empty: true })).toBe(false);
expect(applyOperator([1, 2], { empty: false })).toBe(true);
expect(applyOperator([], { empty: false })).toBe(false);
expect(applyMatcher("", { empty: true })).toBe(true);
expect(applyMatcher(null, { empty: true })).toBe(true);
expect(applyMatcher(undefined, { empty: true })).toBe(true);
expect(applyMatcher([], { empty: true })).toBe(true);
expect(applyMatcher({}, { empty: true })).toBe(true);
expect(applyMatcher("ok", { empty: true })).toBe(false);
expect(applyMatcher(0, { empty: true })).toBe(false);
expect(applyMatcher(false, { empty: true })).toBe(false);
expect(applyMatcher([1, 2], { empty: false })).toBe(true);
expect(applyMatcher([], { empty: false })).toBe(false);
});
test("exists 操作符", () => {
expect(applyOperator("ok", { exists: true })).toBe(true);
expect(applyOperator(null, { exists: true })).toBe(true);
expect(applyOperator(undefined, { exists: true })).toBe(false);
expect(applyOperator(undefined, { exists: false })).toBe(true);
expect(applyOperator("ok", { exists: false })).toBe(false);
expect(applyMatcher("ok", { exists: true })).toBe(true);
expect(applyMatcher(null, { exists: true })).toBe(true);
expect(applyMatcher(undefined, { exists: true })).toBe(false);
expect(applyMatcher(undefined, { exists: false })).toBe(true);
expect(applyMatcher("ok", { exists: false })).toBe(false);
});
test("gte 操作符", () => {
expect(applyOperator(10, { gte: 5 })).toBe(true);
expect(applyOperator(5, { gte: 5 })).toBe(true);
expect(applyOperator(3, { gte: 5 })).toBe(false);
expect(applyOperator("10", { gte: 5 })).toBe(true);
expect(applyMatcher(10, { gte: 5 })).toBe(true);
expect(applyMatcher(5, { gte: 5 })).toBe(true);
expect(applyMatcher(3, { gte: 5 })).toBe(false);
expect(applyMatcher("10", { gte: 5 })).toBe(true);
});
test("lte 操作符", () => {
expect(applyOperator(3, { lte: 5 })).toBe(true);
expect(applyOperator(5, { lte: 5 })).toBe(true);
expect(applyOperator(10, { lte: 5 })).toBe(false);
expect(applyMatcher(3, { lte: 5 })).toBe(true);
expect(applyMatcher(5, { lte: 5 })).toBe(true);
expect(applyMatcher(10, { lte: 5 })).toBe(false);
});
test("gt 操作符", () => {
expect(applyOperator(10, { gt: 5 })).toBe(true);
expect(applyOperator(5, { gt: 5 })).toBe(false);
expect(applyMatcher(10, { gt: 5 })).toBe(true);
expect(applyMatcher(5, { gt: 5 })).toBe(false);
});
test("lt 操作符", () => {
expect(applyOperator(3, { lt: 5 })).toBe(true);
expect(applyOperator(5, { lt: 5 })).toBe(false);
expect(applyMatcher(3, { lt: 5 })).toBe(true);
expect(applyMatcher(5, { lt: 5 })).toBe(false);
});
test("多操作符 AND 组合", () => {
expect(applyOperator(7, { gte: 5, lte: 10 })).toBe(true);
expect(applyOperator(3, { gte: 5, lte: 10 })).toBe(false);
expect(applyOperator(15, { gte: 5, lte: 10 })).toBe(false);
expect(applyMatcher(7, { gte: 5, lte: 10 })).toBe(true);
expect(applyMatcher(3, { gte: 5, lte: 10 })).toBe(false);
expect(applyMatcher(15, { gte: 5, lte: 10 })).toBe(false);
});
});

View File

@@ -1,6 +1,10 @@
import { describe, expect, test } from "bun:test";
import { checkTextRules } from "../../../../../src/server/checker/runner/cmd/text";
import { checkContentRules } from "../../../../../src/server/checker/expect/content";
function checkTextRules(text: string, rules: Parameters<typeof checkContentRules>[1], phase: string) {
return checkContentRules(text, rules, { path: phase, phase });
}
describe("checkTextRules", () => {
test("无规则返回匹配成功", () => {
@@ -24,7 +28,7 @@ describe("checkTextRules", () => {
test("多条规则全部通过", () => {
const r = checkTextRules(
"version: 3.2.1, build: ok",
[{ contains: "version" }, { match: "\\d+\\.\\d+\\.\\d+" }],
[{ contains: "version" }, { regex: "\\d+\\.\\d+\\.\\d+" }],
"stdout",
);
expect(r.matched).toBe(true);

View File

@@ -155,8 +155,8 @@ describe("TcpChecker execute", () => {
expect(result.failure!.phase).toBe("connected");
});
test("maxDurationMs 超时返回失败", async () => {
const result = await checker.execute(makeTarget({}, { expect: { maxDurationMs: -1 } }), makeCtx());
test("durationMs 超时返回失败", async () => {
const result = await checker.execute(makeTarget({}, { expect: { durationMs: { lt: 0 } } }), makeCtx());
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("duration");
});
@@ -179,7 +179,7 @@ describe("TcpChecker execute", () => {
const result = await checker.execute(
makeTarget(
{ host: "127.0.0.1", port: bannerServerPort, readBanner: true },
{ expect: { banner: { contains: "ESMTP" } } },
{ expect: { banner: [{ contains: "ESMTP" }] } },
),
makeCtx(),
);
@@ -190,14 +190,14 @@ describe("TcpChecker execute", () => {
const result = await checker.execute(
makeTarget(
{ host: "127.0.0.1", port: bannerServerPort, readBanner: true },
{ expect: { banner: { contains: "POSTFIX" } } },
{ expect: { banner: [{ contains: "POSTFIX" }] } },
),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("banner");
expect(result.failure!.path).toBe("banner");
expect(result.failure!.path).toBe("banner[0]");
});
test("默认不读取 banner", async () => {
@@ -346,14 +346,14 @@ describe("TcpChecker resolve", () => {
test("expect 配置解析", () => {
const target = checker.resolve(
{
expect: { banner: { contains: "ESMTP" }, connected: false, maxDurationMs: 5000 },
expect: { banner: [{ contains: "ESMTP" }], connected: false, durationMs: { lte: 5000 } },
id: "t",
tcp: { host: "127.0.0.1", port: 80, readBanner: true },
type: "tcp",
},
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
);
expect(target.expect).toEqual({ banner: { contains: "ESMTP" }, connected: false, maxDurationMs: 5000 });
expect(target.expect).toEqual({ banner: [{ contains: "ESMTP" }], connected: false, durationMs: { lte: 5000 } });
});
test("name 和 group 解析", () => {

View File

@@ -32,34 +32,34 @@ describe("checkConnected", () => {
describe("checkBanner", () => {
test("contains 匹配", () => {
const result = checkBanner("220 smtp.example.com ESMTP", { contains: "ESMTP" });
const result = checkBanner("220 smtp.example.com ESMTP", [{ contains: "ESMTP" }]);
expect(result.matched).toBe(true);
});
test("contains 不匹配", () => {
const result = checkBanner("220 smtp.example.com ESMTP", { contains: "POSTFIX" });
const result = checkBanner("220 smtp.example.com ESMTP", [{ contains: "POSTFIX" }]);
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("banner");
});
test("match 正则匹配", () => {
const result = checkBanner("220 smtp.example.com ESMTP", { match: "^220" });
test("regex 正则匹配", () => {
const result = checkBanner("220 smtp.example.com ESMTP", [{ regex: "^220" }]);
expect(result.matched).toBe(true);
});
test("空 banner 与 contains 空字符串", () => {
const result = checkBanner("", { contains: "" });
const result = checkBanner("", [{ contains: "" }]);
expect(result.matched).toBe(true);
});
test("多 operator 同时匹配", () => {
const result = checkBanner("220 ESMTP", { contains: "ESMTP", match: "^220" });
const result = checkBanner("220 ESMTP", [{ contains: "ESMTP", regex: "^220" }]);
expect(result.matched).toBe(true);
});
test("多 operator 部分不匹配", () => {
const result = checkBanner("220 ESMTP", { contains: "ESMTP", match: "^250" });
const result = checkBanner("220 ESMTP", [{ contains: "ESMTP", regex: "^250" }]);
expect(result.matched).toBe(false);
});
});

View File

@@ -68,7 +68,7 @@ describe("validateTcpConfig", () => {
const issues = validateTcpConfig(
makeInput([
{
expect: { banner: { contains: "ESMTP" } },
expect: { banner: [{ contains: "ESMTP" }] },
id: "t1",
tcp: { host: "127.0.0.1", port: 25 },
type: "tcp",
@@ -82,7 +82,7 @@ describe("validateTcpConfig", () => {
const issues = validateTcpConfig(
makeInput([
{
expect: { banner: { contains: "ESMTP" } },
expect: { banner: [{ contains: "ESMTP" }] },
id: "t1",
tcp: { host: "127.0.0.1", port: 25, readBanner: true },
type: "tcp",
@@ -106,18 +106,18 @@ describe("validateTcpConfig", () => {
expect(issues.some((i) => i.path.includes("connected"))).toBe(true);
});
test("expect maxDurationMs 非数字", () => {
test("expect durationMs 非 matcher", () => {
const issues = validateTcpConfig(
makeInput([
{
expect: { maxDurationMs: "slow" },
expect: { durationMs: "slow" },
id: "t1",
tcp: { host: "127.0.0.1", port: 80 },
type: "tcp",
},
]),
);
expect(issues.some((i) => i.path.includes("maxDurationMs"))).toBe(true);
expect(issues.some((i) => i.path.includes("durationMs"))).toBe(true);
});
test("expect 未知字段", () => {
@@ -134,11 +134,11 @@ describe("validateTcpConfig", () => {
expect(issues.some((i) => i.message.includes("未知字段"))).toBe(true);
});
test("expect.banner match 正则非法", () => {
test("expect.banner regex 正则非法", () => {
const issues = validateTcpConfig(
makeInput([
{
expect: { banner: { match: "[invalid" } },
expect: { banner: [{ regex: "[invalid" }] },
id: "t1",
tcp: { host: "127.0.0.1", port: 25, readBanner: true },
type: "tcp",

View File

@@ -250,7 +250,7 @@ describe("UdpChecker execute", () => {
}
});
it("should fail when duration exceeds maxDurationMs", async () => {
it("should fail when duration exceeds durationMs", async () => {
const server = await Bun.udpSocket({
socket: {
data() {
@@ -266,7 +266,7 @@ describe("UdpChecker execute", () => {
});
try {
const checker = new UdpChecker();
const target = makeTarget({ port: server.port }, { maxDurationMs: 1, responded: false });
const target = makeTarget({ port: server.port }, { durationMs: { lte: 1 }, responded: false });
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 200);
const result = await checker.execute(target, { signal: controller.signal });

View File

@@ -76,13 +76,13 @@ describe("checkResponseText", () => {
});
it("多条规则全部匹配", () => {
const result = checkResponseText("hello world", [{ contains: "hello" }, { match: "^hello" }]);
const result = checkResponseText("hello world", [{ contains: "hello" }, { regex: "^hello" }]);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
it("多条规则第二条失败 → 不匹配", () => {
const result = checkResponseText("hello world", [{ contains: "hello" }, { match: "^world" }]);
const result = checkResponseText("hello world", [{ contains: "hello" }, { regex: "^world" }]);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("response");
expect(result.failure!.path).toBe("response[1]");

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 negative expect.maxDurationMs", () => {
it("reports invalid-type for non-matcher expect.durationMs", () => {
const issues = validateUdpConfig(
makeInput({
targets: [
{
expect: { maxDurationMs: -100 },
expect: { durationMs: -100 },
id: "test",
type: "udp",
udp: { host: "127.0.0.1", port: 53 },
@@ -226,7 +226,7 @@ describe("validateUdpConfig", () => {
],
}),
);
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("maxDurationMs"))).toBe(true);
expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("durationMs"))).toBe(true);
});
it("reports invalid-type for non-boolean expect.responded", () => {

View File

@@ -26,7 +26,7 @@ beforeAll(() => {
const httpTarget: ResolvedHttpTarget = {
description: null,
expect: { maxDurationMs: 3000, status: [200] },
expect: { durationMs: { lte: 3000 }, status: [200] },
group: "default",
http: {
headers: { Accept: "application/json" },
@@ -106,7 +106,7 @@ describe("ProbeStore", () => {
expect(config.maxRedirects).toBe(0);
expect(t.interval_ms).toBe(30000);
expect(t.timeout_ms).toBe(10000);
expect(JSON.parse(t.expect!)).toEqual({ maxDurationMs: 3000, status: [200] });
expect(JSON.parse(t.expect!)).toEqual({ durationMs: { lte: 3000 }, status: [200] });
});
test("cmd target 字段正确", () => {