1
0

refactor: expect 类型模型重构,Raw/Resolved 双层分离与断言基础设施内聚

- 重命名 ContentRules→ContentExpectations, KeyValueExpect→KeyedExpectations
- 新增 Raw/Resolved 双层模型:resolve 阶段物化为执行计划,store 持久化 Raw 快照
- HTTP body 按需读取:status/headers 失败或无 body expectation 时不读取 body
- 新增 displayValueExpectation() 解包 failure.expected 用户可读展示
- 修复 checkEarlyTimeout 独立 lte/lt 检查,修复 KeyedExpectations JSON Schema
- 新增 expect/value.ts(resolve/check/display)、keyed.ts、content.ts、headers.ts、status.ts
- 删除旧 normalize.ts/matcher.ts/validate-matcher.ts/key-value.ts
- 更新 DEVELOPMENT.md:expect 五层管线表、displayValueExpectation、1.7↔1.10 交叉引用
- 同步 13 个 main specs,归档 refactor-expect-type-model 变更(62/62 tasks)
This commit is contained in:
2026-05-20 16:12:48 +08:00
parent 6098be2d9e
commit 60a54b483f
90 changed files with 2487 additions and 1493 deletions

View File

@@ -84,6 +84,33 @@ describe("config contract", () => {
expect(validate(target({ equals: { status: "ok" } }))).toBe(true);
});
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(
{

View File

@@ -3,14 +3,13 @@ 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 { checkValueExpectation } from "../../../src/server/checker/expect/value";
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";
@@ -290,7 +289,7 @@ targets:
expect(config.targets[0]!.name).toBeNull();
});
test("ValueMatcher primitive 简写在加载时归一化后可运行期匹配", async () => {
test("ValueMatcher primitive 简写在 resolve 后可运行期匹配", async () => {
const configPath = join(tempDir, "matcher-shorthand.yaml");
await writeFile(
configPath,
@@ -309,7 +308,7 @@ targets:
expect(target.expect?.durationMs).toEqual({ equals: 123 });
expect(
checkValueMatcher(123, target.expect?.durationMs as ValueMatcher, {
checkValueExpectation(123, target.expect?.durationMs, {
path: "durationMs",
phase: "duration",
}).matched,
@@ -860,11 +859,19 @@ targets:
const config = await loadConfig(configPath);
const t = config.targets[0]!;
if (t.type === "http") {
expect(t.expect).toEqual({
expect(t.rawExpect).toEqual({
body: [{ contains: "ok" }, { json: { equals: "ok", path: "$.status" } }],
durationMs: { lte: 3000 },
status: [200, 201],
});
expect(t.expect).toEqual({
body: [
{ kind: "value", matcher: { contains: "ok" } },
{ kind: "json", matcher: { equals: "ok" }, path: "$.status" },
],
durationMs: { lte: 3000 },
status: [200, 201],
});
}
});
@@ -893,12 +900,21 @@ targets:
const config = await loadConfig(configPath);
const t = config.targets[0]!;
if (t.type === "cmd") {
expect(t.expect).toEqual({
expect(t.rawExpect).toEqual({
durationMs: { lte: 5000 },
exitCode: [0, 2],
stderr: [{ empty: true }],
stdout: [{ contains: "ok" }, { regex: "done" }],
});
expect(t.expect).toEqual({
durationMs: { lte: 5000 },
exitCode: [0, 2],
stderr: [{ kind: "value", matcher: { empty: true } }],
stdout: [
{ kind: "value", matcher: { contains: "ok" } },
{ kind: "value", matcher: { regex: "done" } },
],
});
}
});
@@ -1917,7 +1933,7 @@ targets:
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([{ kind: "value", matcher: { contains: "ESMTP" } }]);
});
test("tcp expect.banner 未开启 readBanner 抛出错误", async () => {

View File

@@ -1,31 +0,0 @@
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

@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test";
import type { ResolvedCommandTarget } from "../../../../../src/server/checker/runner/cmd/types";
import type { CheckerContext } from "../../../../../src/server/checker/runner/types";
import type { CheckerContext, ResolveContext } from "../../../../../src/server/checker/runner/types";
import { CommandChecker } from "../../../../../src/server/checker/runner/cmd/execute";
@@ -17,6 +17,10 @@ function makeCtx(timeoutMs = 5000): CheckerContext {
return { signal: controller.signal };
}
function makeResolveContext(): ResolveContext {
return { configDir: process.cwd(), defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 };
}
function makeTarget(
cmd: Partial<ResolvedCommandTarget["cmd"]>,
overrides?: Partial<ResolvedCommandTarget>,
@@ -94,7 +98,7 @@ describe("CommandChecker", () => {
const result = await checker.execute(
makeTarget(
{ args: ["-e", "console.log('hello')"], exec: "bun" },
{ expect: { stdout: [{ contains: "hello" }] } },
{ expect: { exitCode: [0], stdout: [{ kind: "value", matcher: { contains: "hello" } }] } },
),
makeCtx(),
);
@@ -105,7 +109,7 @@ describe("CommandChecker", () => {
const result = await checker.execute(
makeTarget(
{ args: ["-e", "console.log('hello')"], exec: "bun" },
{ expect: { stdout: [{ contains: "nonexistent" }] } },
{ expect: { exitCode: [0], stdout: [{ kind: "value", matcher: { contains: "nonexistent" } }] } },
),
makeCtx(),
);
@@ -117,7 +121,7 @@ describe("CommandChecker", () => {
const result = await checker.execute(
makeTarget(
{ args: ["-e", "process.stderr.write('error\\n')"], exec: "bun" },
{ expect: { stderr: [{ contains: "error" }] } },
{ expect: { exitCode: [0], stderr: [{ kind: "value", matcher: { contains: "error" } }] } },
),
makeCtx(),
);
@@ -142,7 +146,10 @@ describe("CommandChecker", () => {
test("不使用 shell通配符不被展开", async () => {
const result = await checker.execute(
makeTarget({ args: ["-e", "console.log('*')"], exec: "bun" }, { expect: { stdout: [{ contains: "*" }] } }),
makeTarget(
{ args: ["-e", "console.log('*')"], exec: "bun" },
{ expect: { exitCode: [0], stdout: [{ kind: "value", matcher: { contains: "*" } }] } },
),
makeCtx(),
);
expect(result.matched).toBe(true);
@@ -156,7 +163,7 @@ describe("CommandChecker", () => {
env: { ...processEnv, DIAL_TEST_ENV: "resolved-env" },
exec: "bun",
},
{ expect: { stdout: [{ contains: "resolved-env" }] } },
{ expect: { exitCode: [0], stdout: [{ kind: "value", matcher: { contains: "resolved-env" } }] } },
),
makeCtx(),
);
@@ -172,4 +179,11 @@ describe("CommandChecker", () => {
expect(config.exec).toBe("bun");
expect(config.args).toEqual(["-e", "console.log('hello')"]);
});
test("resolve 未配置 expect 时物化默认 exitCode", () => {
const result = checker.resolve({ cmd: { exec: "true" }, id: "test", type: "cmd" }, makeResolveContext());
expect(result.rawExpect).toBeUndefined();
expect(result.expect).toEqual({ exitCode: [0] });
});
});

View File

@@ -1,8 +1,15 @@
import { describe, expect, test } from "bun:test";
import type { ResolvedDbTarget } from "../../../../../src/server/checker/runner/db/types";
import type {
RawDbExpectConfig,
ResolvedDbExpectConfig,
ResolvedDbTarget,
} from "../../../../../src/server/checker/runner/db/types";
import type { CheckerContext } from "../../../../../src/server/checker/runner/types";
import { resolveContentExpectations } from "../../../../../src/server/checker/expect/content";
import { resolveKeyedExpectations } from "../../../../../src/server/checker/expect/keyed";
import { resolveValueExpectation } from "../../../../../src/server/checker/expect/value";
import { DbChecker } from "../../../../../src/server/checker/runner/db/execute";
const checker = new DbChecker();
@@ -13,20 +20,31 @@ function makeCtx(timeoutMs = 5000): CheckerContext {
return { signal: controller.signal };
}
function makeTarget(db: Partial<ResolvedDbTarget["db"]>, overrides?: Partial<ResolvedDbTarget>): ResolvedDbTarget {
function makeTarget(db: Partial<ResolvedDbTarget["db"]>, overrides?: { expect?: RawDbExpectConfig }): ResolvedDbTarget {
const raw = overrides?.expect;
const resolvedExpect: ResolvedDbExpectConfig | undefined = raw
? {
durationMs: resolveValueExpectation(raw.durationMs),
result: resolveContentExpectations(raw.result),
rowCount: resolveValueExpectation(raw.rowCount),
rows: raw.rows?.map((row) => resolveKeyedExpectations(row) ?? []),
}
: undefined;
return {
db: {
url: "sqlite://:memory:",
...db,
},
description: null,
expect: resolvedExpect,
group: "default",
id: "test-db",
intervalMs: 60000,
name: "test-db",
rawExpect: raw,
timeoutMs: 5000,
type: "db",
...overrides,
};
}

View File

@@ -1,7 +1,14 @@
import { describe, expect, test } from "bun:test";
import type { KeyedExpectations, RawValueExpectation } from "../../../../../src/server/checker/expect/types";
import { resolveValueExpectation } from "../../../../../src/server/checker/expect/value";
import { checkRowCount, checkRows } from "../../../../../src/server/checker/runner/db/expect";
function row(record: Record<string, RawValueExpectation>): KeyedExpectations {
return Object.entries(record).map(([key, value]) => ({ key, matcher: resolveValueExpectation(value) }));
}
describe("checkRowCount", () => {
test("空数组通过 rowCount gte 0", () => {
const result = checkRowCount(0, { gte: 0 });
@@ -39,7 +46,7 @@ describe("checkRowCount", () => {
describe("checkRows", () => {
test("非数组返回失败", () => {
const result = checkRows(null, [{ col: 1 }]);
const result = checkRows(null, [row({ col: 1 })]);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("row");
expect(result.failure!.path).toBe("rows");
@@ -51,17 +58,17 @@ describe("checkRows", () => {
});
test("单行单列匹配(字面量)", () => {
const result = checkRows([{ col: "value" }], [{ col: "value" }]);
const result = checkRows([{ col: "value" }], [row({ col: "value" })]);
expect(result.matched).toBe(true);
});
test("单行单列匹配operator", () => {
const result = checkRows([{ col: 100 }], [{ col: { gte: 50 } }]);
const result = checkRows([{ col: 100 }], [row({ col: { gte: 50 } })]);
expect(result.matched).toBe(true);
});
test("单行单列不匹配", () => {
const result = checkRows([{ col: 10 }], [{ col: { gte: 50 } }]);
const result = checkRows([{ col: 10 }], [row({ col: { gte: 50 } })]);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("row");
expect(result.failure!.path).toBe("rows[0].col");
@@ -73,62 +80,61 @@ describe("checkRows", () => {
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
],
[{ id: { gte: 1 } }, { name: "Bob" }],
[row({ id: { gte: 1 } }), row({ name: "Bob" })],
);
expect(result.matched).toBe(true);
});
test("多行中有一行不匹配", () => {
const result = checkRows([{ col: 1 }, { col: 2 }], [{ col: { gte: 2 } }, { col: { gte: 3 } }]);
const result = checkRows([{ col: 1 }, { col: 2 }], [row({ col: { gte: 2 } }), row({ col: { gte: 3 } })]);
expect(result.matched).toBe(false);
// 第一行 { col: 1 } 不满足 { gte: 2 },所以失败在第一行
expect(result.failure!.path).toBe("rows[0].col");
});
test("结果行数不足", () => {
const result = checkRows([{ col: 1 }], [{ col: 1 }, { col: 2 }]);
const result = checkRows([{ col: 1 }], [row({ col: 1 }), row({ col: 2 })]);
expect(result.matched).toBe(false);
expect(result.failure!.message).toContain("行数不足");
});
test("只检查声明的列", () => {
const result = checkRows([{ col: 1, other: "ignored" }], [{ col: { gte: 0 } }]);
const result = checkRows([{ col: 1, other: "ignored" }], [row({ col: { gte: 0 } })]);
expect(result.matched).toBe(true);
});
test("行不是对象返回失败", () => {
const result = checkRows(["not-an-object"] as unknown[], [{ col: 1 }]);
const result = checkRows(["not-an-object"] as unknown[], [row({ col: 1 })]);
expect(result.matched).toBe(false);
expect(result.failure!.path).toBe("rows[0]");
});
test("列不存在视为 undefined", () => {
const result = checkRows([{}], [{ col: { exists: false } }]);
const result = checkRows([{}], [row({ col: { exists: false } })]);
expect(result.matched).toBe(true);
});
test("列存在且值为 null", () => {
const result = checkRows([{ col: null }], [{ col: { empty: true } }]);
const result = checkRows([{ col: null }], [row({ col: { empty: true } })]);
expect(result.matched).toBe(true);
});
test("contains 匹配字符串", () => {
const result = checkRows([{ text: "hello world" }], [{ text: { contains: "hello" } }]);
const result = checkRows([{ text: "hello world" }], [row({ text: { contains: "hello" } })]);
expect(result.matched).toBe(true);
});
test("regex 正则匹配", () => {
const result = checkRows([{ code: "ABC-123" }], [{ code: { regex: "^ABC-" } }]);
const result = checkRows([{ code: "ABC-123" }], [row({ code: { regex: "^ABC-" } })]);
expect(result.matched).toBe(true);
});
test("多个断言同时满足", () => {
const result = checkRows([{ val: 50 }], [{ val: { gte: 10, lte: 100 } }]);
const result = checkRows([{ val: 50 }], [row({ val: { gte: 10, lte: 100 } })]);
expect(result.matched).toBe(true);
});
test("多个断言中有一个不满足", () => {
const result = checkRows([{ val: 50 }], [{ val: { gte: 10, lte: 30 } }]);
const result = checkRows([{ val: 50 }], [row({ val: { gte: 10, lte: 30 } })]);
expect(result.matched).toBe(false);
});
});

View File

@@ -1,6 +1,14 @@
import { describe, expect, test } from "bun:test";
import { checkHeaders, checkStatus } from "../../../../../src/server/checker/runner/http/expect";
import type { RawKeyedExpectations } from "../../../../../src/server/checker/expect/types";
import { checkHeaderExpectations } from "../../../../../src/server/checker/expect/headers";
import { resolveKeyedExpectations } from "../../../../../src/server/checker/expect/keyed";
import { checkStatusCode } from "../../../../../src/server/checker/expect/status";
function checkHeaders(headers: Record<string, unknown>, raw?: RawKeyedExpectations) {
return checkHeaderExpectations(headers, resolveKeyedExpectations(raw));
}
describe("checkHeaders", () => {
test("未配置 headers expect 时匹配成功", () => {
@@ -46,15 +54,15 @@ describe("checkHeaders", () => {
});
});
describe("checkStatus 范围匹配", () => {
test("无 expect 配置时默认 status [200] 可由调用方使用 checkStatus 表达", () => {
const result = checkStatus(200, [200]);
describe("checkStatusCode 范围匹配", () => {
test("无 expect 配置时默认 status [200] 可由调用方使用 checkStatusCode 表达", () => {
const result = checkStatusCode(200, [200]);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
test("status 不匹配返回 phase=status 的失败", () => {
const result = checkStatus(503, [200]);
const result = checkStatusCode(503, [200]);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("status");
expect(result.failure!.expected).toEqual([200]);
@@ -62,47 +70,47 @@ describe("checkStatus 范围匹配", () => {
});
test("2xx 范围匹配 200", () => {
expect(checkStatus(200, ["2xx"]).matched).toBe(true);
expect(checkStatusCode(200, ["2xx"]).matched).toBe(true);
});
test("2xx 范围匹配 204", () => {
expect(checkStatus(204, ["2xx"]).matched).toBe(true);
expect(checkStatusCode(204, ["2xx"]).matched).toBe(true);
});
test("2xx 范围不匹配 301", () => {
expect(checkStatus(301, ["2xx"]).matched).toBe(false);
expect(checkStatusCode(301, ["2xx"]).matched).toBe(false);
});
test("5xx 范围匹配 503", () => {
expect(checkStatus(503, ["5xx"]).matched).toBe(true);
expect(checkStatusCode(503, ["5xx"]).matched).toBe(true);
});
test("混合精确值与范围模式命中精确值", () => {
expect(checkStatus(301, ["2xx", 301]).matched).toBe(true);
expect(checkStatusCode(301, ["2xx", 301]).matched).toBe(true);
});
test("混合精确值与范围模式命中范围", () => {
expect(checkStatus(204, ["2xx", 301]).matched).toBe(true);
expect(checkStatusCode(204, ["2xx", 301]).matched).toBe(true);
});
test("混合模式都不匹配", () => {
expect(checkStatus(404, ["2xx", 301]).matched).toBe(false);
expect(checkStatusCode(404, ["2xx", 301]).matched).toBe(false);
});
test("纯精确值仍正常工作", () => {
expect(checkStatus(200, [200, 201]).matched).toBe(true);
expect(checkStatus(404, [200, 201]).matched).toBe(false);
expect(checkStatusCode(200, [200, 201]).matched).toBe(true);
expect(checkStatusCode(404, [200, 201]).matched).toBe(false);
});
test("1xx 范围匹配 101", () => {
expect(checkStatus(101, ["1xx"]).matched).toBe(true);
expect(checkStatusCode(101, ["1xx"]).matched).toBe(true);
});
test("3xx 范围匹配 301", () => {
expect(checkStatus(301, ["3xx"]).matched).toBe(true);
expect(checkStatusCode(301, ["3xx"]).matched).toBe(true);
});
test("4xx 范围匹配 404", () => {
expect(checkStatus(404, ["4xx"]).matched).toBe(true);
expect(checkStatusCode(404, ["4xx"]).matched).toBe(true);
});
});

View File

@@ -1,13 +1,22 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { ResolvedHttpTarget } from "../../../../../src/server/checker/runner/http/types";
import type {
RawHttpExpectConfig,
ResolvedHttpExpectConfig,
ResolvedHttpTarget,
} from "../../../../../src/server/checker/runner/http/types";
import type { CheckerContext, ResolveContext } from "../../../../../src/server/checker/runner/types";
import { resolveContentExpectations } from "../../../../../src/server/checker/expect/content";
import { resolveKeyedExpectations } from "../../../../../src/server/checker/expect/keyed";
import { checkStatusCode } from "../../../../../src/server/checker/expect/status";
import { resolveValueExpectation } from "../../../../../src/server/checker/expect/value";
import { HttpChecker } from "../../../../../src/server/checker/runner/http/execute";
import { checkStatus } from "../../../../../src/server/checker/runner/http/expect";
import { formatConfigIssues } from "../../../../../src/server/checker/schema/issues";
const checker = new HttpChecker();
const SLOW_BODY_DELAY_MS = 1000;
const FAST_RESPONSE_LIMIT_MS = 500;
function validateHttpTarget(target: unknown): string {
return formatConfigIssues(checker.validate({ defaults: {}, targets: [target as never] }));
@@ -145,7 +154,7 @@ describe("HttpChecker", () => {
function makeTarget(overrides: {
body?: string;
expect?: Record<string, unknown>;
expect?: RawHttpExpectConfig;
headers?: Record<string, string>;
ignoreSSL?: boolean;
maxBodyBytes?: number;
@@ -154,9 +163,19 @@ describe("HttpChecker", () => {
timeoutMs?: number;
url?: string;
}): ResolvedHttpTarget {
const raw = overrides.expect;
const resolvedExpect: ResolvedHttpExpectConfig | undefined = raw
? {
body: resolveContentExpectations(raw.body),
durationMs: resolveValueExpectation(raw.durationMs),
headers: resolveKeyedExpectations(raw.headers),
status: raw.status ?? [200],
}
: undefined;
return {
description: null,
expect: overrides.expect,
expect: resolvedExpect,
group: "default",
http: {
body: overrides.body,
@@ -170,6 +189,7 @@ describe("HttpChecker", () => {
id: "test-http",
intervalMs: 60000,
name: "test-http",
rawExpect: raw,
timeoutMs: overrides.timeoutMs ?? 5000,
type: "http",
};
@@ -181,6 +201,29 @@ describe("HttpChecker", () => {
return { signal: controller.signal };
}
function startSlowBodyServer(init: ResponseInit = {}) {
return Bun.serve({
fetch() {
let sentFirstChunk = false;
return new Response(
new ReadableStream<Uint8Array>({
async pull(controller) {
if (!sentFirstChunk) {
sentFirstChunk = true;
controller.enqueue(new TextEncoder().encode("slow body"));
return;
}
await new Promise((resolve) => setTimeout(resolve, SLOW_BODY_DELAY_MS));
controller.close();
},
}),
init,
);
},
port: 0,
});
}
test("成功请求 200", async () => {
const result = await checker.execute(makeTarget({ url: `${baseUrl}/ok` }), makeCtx());
expect(result.matched).toBe(true);
@@ -192,7 +235,7 @@ describe("HttpChecker", () => {
test("404 不匹配默认 status [200]", async () => {
const result = await checker.execute(makeTarget({ url: `${baseUrl}/notfound` }), makeCtx());
expect(result.matched).toBe(false);
expect(result.observation).toMatchObject({ bodyPreview: "not found", statusCode: 404 });
expect(result.observation).toMatchObject({ bodyPreview: null, statusCode: 404 });
expect(result.failure!.phase).toBe("status");
});
@@ -218,7 +261,7 @@ describe("HttpChecker", () => {
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.observation).toMatchObject({ bodyPreview: "hello world", statusCode: 200 });
expect(result.observation).toMatchObject({ bodyPreview: null, statusCode: 200 });
expect(result.failure!.phase).toBe("headers");
});
@@ -279,12 +322,43 @@ describe("HttpChecker", () => {
});
test("快速失败status 失败时不读取 body", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "something" }], status: [200] }, url: `${baseUrl}/notfound` }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("status");
const slowBodyServer = startSlowBodyServer({ status: 404 });
try {
const start = performance.now();
const result = await checker.execute(
makeTarget({
expect: { body: [{ contains: "something" }], status: [200] },
url: `http://localhost:${slowBodyServer.port}/`,
}),
makeCtx(),
);
expect(performance.now() - start).toBeLessThan(FAST_RESPONSE_LIMIT_MS);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("status");
expect(result.observation?.["bodyPreview"]).toBeNull();
} finally {
void slowBodyServer.stop();
}
});
test("快速失败headers 失败时不读取 body", async () => {
const slowBodyServer = startSlowBodyServer({ headers: { "x-custom": "actual" }, status: 200 });
try {
const start = performance.now();
const result = await checker.execute(
makeTarget({
expect: { body: [{ contains: "something" }], headers: { "x-custom": "expected" }, status: [200] },
url: `http://localhost:${slowBodyServer.port}/`,
}),
makeCtx(),
);
expect(performance.now() - start).toBeLessThan(FAST_RESPONSE_LIMIT_MS);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("headers");
expect(result.observation?.["bodyPreview"]).toBeNull();
} finally {
void slowBodyServer.stop();
}
});
test("status 通过但 body 失败", async () => {
@@ -511,12 +585,40 @@ describe("HttpChecker", () => {
}
});
test("无 body rules 时不读取 body", async () => {
const result = await checker.execute(
makeTarget({ expect: { status: [200] }, maxBodyBytes: 1, url: `${baseUrl}/large` }),
makeCtx(),
);
expect(result.matched).toBe(true);
test("无 body expectations 时不读取 body", async () => {
const slowBodyServer = startSlowBodyServer({ status: 200 });
try {
const start = performance.now();
const result = await checker.execute(
makeTarget({ expect: { status: [200] }, maxBodyBytes: 1, url: `http://localhost:${slowBodyServer.port}/` }),
makeCtx(),
);
expect(performance.now() - start).toBeLessThan(FAST_RESPONSE_LIMIT_MS);
expect(result.matched).toBe(true);
expect(result.observation?.["bodyPreview"]).toBeNull();
} finally {
void slowBodyServer.stop();
}
});
test("进入 body 前 durationMs 上界已失败时不读取 body", async () => {
const slowBodyServer = startSlowBodyServer({ status: 200 });
try {
const start = performance.now();
const result = await checker.execute(
makeTarget({
expect: { body: [{ contains: "slow" }], durationMs: { lte: 0 }, status: [200] },
url: `http://localhost:${slowBodyServer.port}/`,
}),
makeCtx(),
);
expect(performance.now() - start).toBeLessThan(FAST_RESPONSE_LIMIT_MS);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("duration");
expect(result.observation?.["bodyPreview"]).toBeNull();
} finally {
void slowBodyServer.stop();
}
});
test("body 失败优先于 duration 检查", async () => {
@@ -598,7 +700,7 @@ describe("HttpChecker", () => {
expect(result.observation).toMatchObject({ statusCode: 200 });
});
test("混合 body rules 集成检查", async () => {
test("混合 body expectations 集成检查", async () => {
const result = await checker.execute(
makeTarget({
expect: {
@@ -649,7 +751,7 @@ describe("HttpChecker", () => {
});
test("1xx 范围模式匹配 101", () => {
const r = checkStatus(101, ["1xx"]);
const r = checkStatusCode(101, ["1xx"]);
expect(r.matched).toBe(true);
});
@@ -868,6 +970,16 @@ describe("HttpChecker.resolve", () => {
expect(result.http.ignoreSSL).toBe(false);
});
test("未配置 expect 时在 Resolved 模型物化默认 status", () => {
const result = checker.resolve(
{ http: { url: "https://example.com" }, id: "test", name: "test", type: "http" },
makeResolveContext(),
);
expect(result.rawExpect).toBeUndefined();
expect(result.expect).toEqual({ status: [200] });
});
test("maxRedirects 默认值为 0", () => {
const result = checker.resolve(
{ http: { url: "https://example.com" }, id: "test", name: "test", type: "http" },

View File

@@ -84,7 +84,10 @@ 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: { packetLossPercent: { lte: 10 } } }), makeCtx());
const result = await checker.execute(
makeTarget({ expect: { alive: true, packetLossPercent: { lte: 10 } } }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("packetLoss");
expect(result.observation).toMatchObject({ alive: true, maxLatencyMs: 340 });
@@ -125,6 +128,8 @@ describe("IcmpChecker resolve", () => {
);
expect(target.icmp).toEqual({ count: 3, host: "10.0.0.1", packetSize: 56 });
expect(target.group).toBe("default");
expect(target.rawExpect).toBeUndefined();
expect(target.expect).toEqual({ alive: true });
});
test("serialize 返回摘要和配置", () => {

View File

@@ -1,8 +1,15 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { ResolvedLlmTarget } from "../../../../../src/server/checker/runner/llm/types";
import type {
RawLlmExpectConfig,
ResolvedLlmExpectConfig,
ResolvedLlmTarget,
} from "../../../../../src/server/checker/runner/llm/types";
import type { CheckerContext } from "../../../../../src/server/checker/runner/types";
import { resolveContentExpectations } from "../../../../../src/server/checker/expect/content";
import { resolveKeyedExpectations } from "../../../../../src/server/checker/expect/keyed";
import { resolveValueExpectation } from "../../../../../src/server/checker/expect/value";
import { LlmChecker } from "../../../../../src/server/checker/runner/llm/execute";
const MOCK_PORT = 18456;
@@ -14,13 +21,34 @@ function makeCtx(timeoutMs = 10000): CheckerContext {
return { signal: controller.signal };
}
function makeTarget(
overrides?: Partial<ResolvedLlmTarget["llm"]>,
expectOverrides?: Partial<ResolvedLlmTarget["expect"]>,
): ResolvedLlmTarget {
function makeTarget(overrides?: Partial<ResolvedLlmTarget["llm"]>, rawExpect?: RawLlmExpectConfig): ResolvedLlmTarget {
const resolvedExpect: ResolvedLlmExpectConfig | undefined = rawExpect
? {
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
? {
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),
}
: undefined,
}
: undefined;
return {
description: null,
expect: expectOverrides,
expect: resolvedExpect,
group: "default",
id: "test-llm",
intervalMs: 30000,
@@ -38,6 +66,7 @@ function makeTarget(
...overrides,
},
name: null,
rawExpect,
timeoutMs: 10000,
type: "llm",
};

View File

@@ -1,12 +1,32 @@
import { describe, expect, test } from "bun:test";
import type { LlmCheckObservation } from "../../../../../src/server/checker/runner/llm/types";
import type {
RawContentExpectations,
RawKeyedExpectations,
RawValueExpectation,
} from "../../../../../src/server/checker/expect/types";
import type { LlmCheckObservation, ResolvedLlmExpectConfig } from "../../../../../src/server/checker/runner/llm/types";
import { checkContentRules } from "../../../../../src/server/checker/expect/content";
import { checkContentExpectations, resolveContentExpectations } from "../../../../../src/server/checker/expect/content";
import { resolveKeyedExpectations } from "../../../../../src/server/checker/expect/keyed";
import { resolveValueExpectation } from "../../../../../src/server/checker/expect/value";
import { runExpects } from "../../../../../src/server/checker/runner/llm/expect";
function checkOutputRules(outputText: null | string, rules: Parameters<typeof checkContentRules>[1]) {
return checkContentRules(outputText, rules, { path: "output", phase: "output" });
interface RawLlmExpectInput {
finishReason?: RawValueExpectation;
headers?: RawKeyedExpectations;
output?: RawContentExpectations;
rawFinishReason?: RawValueExpectation;
status?: Array<number | string>;
stream?: { completed?: boolean; firstTokenMs?: RawValueExpectation };
usage?: { inputTokens?: RawValueExpectation; outputTokens?: RawValueExpectation; totalTokens?: RawValueExpectation };
}
function checkOutputRules(outputText: null | string, rawRules: RawContentExpectations | undefined) {
return checkContentExpectations(outputText, resolveContentExpectations(rawRules), {
path: "output",
phase: "output",
});
}
function makeObservation(overrides?: Partial<LlmCheckObservation>): LlmCheckObservation {
@@ -25,7 +45,31 @@ function makeObservation(overrides?: Partial<LlmCheckObservation>): LlmCheckObse
};
}
describe("LLM output rules", () => {
function resolveLlmExpect(raw: RawLlmExpectInput | undefined): ResolvedLlmExpectConfig | undefined {
if (raw === undefined) return undefined;
return {
finishReason: resolveValueExpectation(raw.finishReason),
headers: resolveKeyedExpectations(raw.headers),
output: resolveContentExpectations(raw.output),
rawFinishReason: resolveValueExpectation(raw.rawFinishReason),
status: raw.status ?? [200],
stream: raw.stream
? {
completed: raw.stream.completed ?? true,
firstTokenMs: resolveValueExpectation(raw.stream.firstTokenMs),
}
: undefined,
usage: raw.usage
? {
inputTokens: resolveValueExpectation(raw.usage.inputTokens),
outputTokens: resolveValueExpectation(raw.usage.outputTokens),
totalTokens: resolveValueExpectation(raw.usage.totalTokens),
}
: undefined,
};
}
describe("LLM output expectations", () => {
test("equals 严格匹配", () => {
expect(checkOutputRules("OK", [{ equals: "OK" }]).matched).toBe(true);
expect(checkOutputRules("OK\n", [{ equals: "OK" }]).matched).toBe(false);
@@ -66,7 +110,7 @@ describe("LLM output rules", () => {
expect(result.failure?.phase).toBe("output");
});
test("undefined rules 返回通过", () => {
test("undefined expectations 返回通过", () => {
expect(checkOutputRules("anything", undefined).matched).toBe(true);
expect(checkOutputRules(null, undefined).matched).toBe(true);
});
@@ -75,11 +119,14 @@ describe("LLM output rules", () => {
describe("LLM runExpects", () => {
test("全部 expect 通过", () => {
const observation = makeObservation();
const result = runExpects(observation, {
finishReason: { equals: "stop" },
output: [{ contains: "OK" }],
status: [200],
});
const result = runExpects(
observation,
resolveLlmExpect({
finishReason: { equals: "stop" },
output: [{ contains: "OK" }],
status: [200],
}),
);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
@@ -92,35 +139,35 @@ describe("LLM runExpects", () => {
test("status 不匹配失败", () => {
const observation = makeObservation();
const result = runExpects(observation, { status: [404] });
const result = runExpects(observation, resolveLlmExpect({ status: [404] }));
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("status");
});
test("finishReason 不匹配失败", () => {
const observation = makeObservation();
const result = runExpects(observation, { finishReason: { equals: "length" } });
const result = runExpects(observation, resolveLlmExpect({ finishReason: { equals: "length" } }));
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("finishReason");
});
test("rawFinishReason 不匹配失败", () => {
const observation = makeObservation();
const result = runExpects(observation, { rawFinishReason: { equals: "end_turn" } });
const result = runExpects(observation, resolveLlmExpect({ rawFinishReason: { equals: "end_turn" } }));
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("rawFinishReason");
});
test("usage 不匹配失败", () => {
const observation = makeObservation();
const result = runExpects(observation, { usage: { totalTokens: { gte: 100 } } });
const result = runExpects(observation, resolveLlmExpect({ usage: { totalTokens: { gte: 100 } } }));
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("usage");
});
test("usage 匹配通过", () => {
const observation = makeObservation();
const result = runExpects(observation, { usage: { totalTokens: { lte: 20 } } });
const result = runExpects(observation, resolveLlmExpect({ usage: { totalTokens: { lte: 20 } } }));
expect(result.matched).toBe(true);
});
@@ -129,9 +176,12 @@ describe("LLM runExpects", () => {
mode: "stream",
stream: { completed: true, firstTokenMs: 500 },
});
const result = runExpects(observation, {
stream: { completed: true },
});
const result = runExpects(
observation,
resolveLlmExpect({
stream: { completed: true },
}),
);
expect(result.matched).toBe(true);
});
@@ -140,9 +190,12 @@ describe("LLM runExpects", () => {
mode: "stream",
stream: { completed: true, firstTokenMs: 500 },
});
const result = runExpects(observation, {
stream: { firstTokenMs: { lte: 1000 } },
});
const result = runExpects(
observation,
resolveLlmExpect({
stream: { firstTokenMs: { lte: 1000 } },
}),
);
expect(result.matched).toBe(true);
});
@@ -151,9 +204,12 @@ describe("LLM runExpects", () => {
mode: "stream",
stream: { completed: true, firstTokenMs: null },
});
const result = runExpects(observation, {
stream: { firstTokenMs: { lte: 1000 } },
});
const result = runExpects(
observation,
resolveLlmExpect({
stream: { firstTokenMs: { lte: 1000 } },
}),
);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("stream");
});
@@ -162,9 +218,12 @@ describe("LLM runExpects", () => {
const observation = makeObservation({
http: { headers: { "content-type": "application/json" }, status: 200, statusText: "OK" },
});
const result = runExpects(observation, {
headers: { "content-type": "application/json" },
});
const result = runExpects(
observation,
resolveLlmExpect({
headers: { "content-type": "application/json" },
}),
);
expect(result.matched).toBe(true);
});
@@ -172,19 +231,25 @@ describe("LLM runExpects", () => {
const observation = makeObservation({
http: { headers: { "content-type": "text/plain" }, status: 200, statusText: "OK" },
});
const result = runExpects(observation, {
headers: { "content-type": "application/json" },
});
const result = runExpects(
observation,
resolveLlmExpect({
headers: { "content-type": "application/json" },
}),
);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("headers");
});
test("首个 expect 失败立即返回", () => {
const observation = makeObservation();
const result = runExpects(observation, {
output: [{ contains: "OK" }],
status: [404],
});
const result = runExpects(
observation,
resolveLlmExpect({
output: [{ contains: "OK" }],
status: [404],
}),
);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("status");
});
@@ -196,7 +261,7 @@ describe("LLM runExpects", () => {
outputText: null,
usage: null,
});
const result = runExpects(observation, { status: [401] });
const result = runExpects(observation, resolveLlmExpect({ status: [401] }));
expect(result.matched).toBe(true);
});
});

View File

@@ -214,7 +214,7 @@ describe("LlmChecker validate", () => {
defaults: {},
targets: [makeRawTarget({ expect: { output: [{ contains: "y", json: { path: "$.status" } }] } })],
});
expect(issues.some((i) => i.code === "invalid-content-rule")).toBe(true);
expect(issues.some((i) => i.code === "invalid-content-expectation")).toBe(true);
});
test("expect.output regex ReDoS 报错", () => {
@@ -284,6 +284,44 @@ describe("LlmChecker resolve", () => {
expect(resolved.group).toBe("default");
expect(resolved.intervalMs).toBe(30000);
expect(resolved.timeoutMs).toBe(10000);
expect(resolved.rawExpect).toBeUndefined();
expect(resolved.expect).toEqual({ status: [200] });
});
test("stream mode 未配置 expect.stream 时不物化 completed", () => {
const raw = makeRawTarget({
expect: { output: [{ contains: "OK" }] },
llm: {
mode: "stream",
model: "gpt-4o-mini",
prompt: "Say OK",
provider: "openai",
url: "https://api.openai.com/v1",
},
});
const resolved = asLlm(checker.resolve(raw, makeResolveContext()));
expect(resolved.rawExpect).toEqual({ output: [{ contains: "OK" }] });
expect(resolved.expect?.stream).toBeUndefined();
});
test("配置 expect.stream 但省略 completed 时默认 true", () => {
const raw = makeRawTarget({
expect: { stream: { firstTokenMs: 100 } },
llm: {
mode: "stream",
model: "gpt-4o-mini",
prompt: "Say OK",
provider: "openai",
url: "https://api.openai.com/v1",
},
});
const resolved = asLlm(checker.resolve(raw, makeResolveContext()));
expect(resolved.rawExpect).toEqual({ stream: { firstTokenMs: 100 } });
expect(resolved.expect?.stream).toEqual({ completed: true, firstTokenMs: { equals: 100 } });
});
test("defaults.llm 与 target.llm 浅合并", () => {

View File

@@ -1,12 +1,15 @@
import { describe, expect, test } from "bun:test";
import { checkContentRules } from "../../../../../src/server/checker/expect/content";
import type { RawContentExpectations } from "../../../../../src/server/checker/expect/types";
function checkBodyExpect(body: string, rules?: Parameters<typeof checkContentRules>[1]) {
return checkContentRules(body, rules, { path: "body", phase: "body" });
import { checkContentExpectations, resolveContentExpectations } from "../../../../../src/server/checker/expect/content";
function checkBodyExpect(body: string, rawExpectations?: RawContentExpectations) {
const resolved = resolveContentExpectations(rawExpectations);
return checkContentExpectations(body, resolved, { path: "body", phase: "body" });
}
describe("checkBodyExpect (BodyRule[])", () => {
describe("checkBodyExpect (ContentExpectations)", () => {
test("无规则返回匹配成功", () => {
const r = checkBodyExpect("anything");
expect(r.matched).toBe(true);
@@ -19,6 +22,22 @@ describe("checkBodyExpect (BodyRule[])", () => {
expect(r.failure).toBeNull();
});
test("resolve 输出显式 kind union 并物化 extractor 默认 exists", () => {
expect(
resolveContentExpectations([
{ contains: "ok" },
{ json: { path: "$.status" } },
{ css: { attr: "content", selector: "meta[name=status]" } },
{ xpath: { path: "/root/status" } },
]),
).toEqual([
{ kind: "value", matcher: { contains: "ok" } },
{ kind: "json", matcher: { exists: true }, path: "$.status" },
{ attr: "content", kind: "css", matcher: { exists: true }, selector: "meta[name=status]" },
{ kind: "xpath", matcher: { exists: true }, path: "/root/status" },
]);
});
test("contains 规则匹配成功", () => {
const r = checkBodyExpect("hello world", [{ contains: "hello" }]);
expect(r.matched).toBe(true);

View File

@@ -0,0 +1,65 @@
import { describe, expect, test } from "bun:test";
import type { CheckerValidationInput } from "../../../../../src/server/checker/runner/types";
import { validateDbConfig } from "../../../../../src/server/checker/runner/db/validate";
import { validateHttpConfig } from "../../../../../src/server/checker/runner/http/validate";
import { validateLlmConfig } from "../../../../../src/server/checker/runner/llm/validate";
function input(target: Record<string, unknown>): CheckerValidationInput {
return { defaults: {}, targets: [target as CheckerValidationInput["targets"][number]] };
}
describe("HTTP/LLM headers reject case-insensitive duplicate keys", () => {
test("HTTP headers 大小写不同的重复 key 报错", () => {
const target = {
expect: { headers: { "Content-Type": "application/json", "content-type": "text/plain" } },
http: { url: "https://example.com" },
id: "dup",
type: "http",
};
const issues = validateHttpConfig(input(target));
expect(issues.some((i) => i.code === "duplicate-key" && i.path.includes("headers"))).toBe(true);
});
test("LLM headers 大小写不同的重复 key 报错", () => {
const target = {
expect: { headers: { "X-Trace": "a", "x-trace": "b" } },
id: "dup",
llm: {
mode: "stream",
model: "test-model",
prompt: "hello",
provider: "openai",
url: "https://example.com/v1/chat/completions",
},
type: "llm",
};
const issues = validateLlmConfig(input(target));
expect(issues.some((i) => i.code === "duplicate-key" && i.path.includes("headers"))).toBe(true);
});
test("HTTP headers 不同 key 不触发 duplicate-key", () => {
const target = {
expect: { headers: { Accept: "application/json", "Content-Type": "application/json" } },
http: { url: "https://example.com" },
id: "ok",
type: "http",
};
expect(validateHttpConfig(input(target)).some((i) => i.code === "duplicate-key")).toBe(false);
});
test("DB rows 保留大小写敏感不触发 duplicate-key", () => {
const target = {
db: { query: "SELECT 1", url: "sqlite://:memory:" },
expect: { rows: [{ Name: "a", name: "b" }] },
id: "dup-rows",
type: "db",
};
expect(validateDbConfig(input(target)).some((i) => i.code === "duplicate-key")).toBe(false);
});
});

View File

@@ -1,9 +1,9 @@
import { describe, expect, test } from "bun:test";
import { checkValueMatcher } from "../../../../../src/server/checker/expect/matcher";
import { checkValueExpectation } from "../../../../../src/server/checker/expect/value";
function checkDuration(durationMs: number, maxDurationMs?: number) {
return checkValueMatcher(durationMs, maxDurationMs === undefined ? undefined : { lte: maxDurationMs }, {
return checkValueExpectation(durationMs, maxDurationMs === undefined ? undefined : { lte: maxDurationMs }, {
path: "durationMs",
phase: "duration",
});

View File

@@ -0,0 +1,34 @@
import { describe, expect, test } from "bun:test";
import { checkKeyedExpectations, resolveKeyedExpectations } from "../../../../../src/server/checker/expect/keyed";
describe("KeyedExpectations", () => {
test("resolve 将 Raw Record 转为保持顺序的有序数组", () => {
expect(resolveKeyedExpectations({ Count: { gte: 1 }, Name: "alice" })).toEqual([
{ key: "Count", matcher: { gte: 1 } },
{ key: "Name", matcher: { equals: "alice" } },
]);
});
test("failure.path 包含基础路径和原始 key", () => {
const result = checkKeyedExpectations(
{ "content-type": "text/plain" },
resolveKeyedExpectations({ "Content-Type": { contains: "json" } }),
{ normalizeKey: (key) => key.toLowerCase(), path: "headers", phase: "headers" },
);
expect(result.matched).toBe(false);
expect(result.failure?.path).toBe("headers.Content-Type");
});
test("DB rows 默认保持大小写敏感匹配", () => {
const expectations = resolveKeyedExpectations({ Name: "alice", name: "bob" });
expect(
checkKeyedExpectations({ Name: "alice", name: "bob" }, expectations, { path: "rows[0]", phase: "rows" }).matched,
).toBe(true);
expect(
checkKeyedExpectations({ Name: "bob", name: "alice" }, expectations, { path: "rows[0]", phase: "rows" }).matched,
).toBe(false);
});
});

View File

@@ -1,6 +1,12 @@
import { describe, expect, test } from "bun:test";
import { applyMatcher, checkExpectValue, evaluateJsonPath } from "../../../../../src/server/checker/expect/matcher";
import {
applyValueMatcher,
checkValueExpectation,
displayValueExpectation,
evaluateJsonPath,
resolveValueExpectation,
} from "../../../../../src/server/checker/expect/value";
describe("evaluateJsonPath", () => {
const obj = {
@@ -55,97 +61,115 @@ describe("evaluateJsonPath", () => {
});
});
describe("applyMatcher", () => {
describe("applyValueMatcher", () => {
test("equals 操作符", () => {
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);
expect(applyValueMatcher("ok", { equals: "ok" })).toBe(true);
expect(applyValueMatcher("ok", { equals: "error" })).toBe(false);
expect(applyValueMatcher(42, { equals: 42 })).toBe(true);
expect(applyValueMatcher(42, { equals: 41 })).toBe(false);
expect(applyValueMatcher(null, { equals: null })).toBe(true);
expect(applyValueMatcher(true, { equals: true })).toBe(true);
});
test("equals 支持 JSON 对象和数组", () => {
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);
expect(applyValueMatcher({ status: "ok" }, { equals: { status: "ok" } })).toBe(true);
expect(applyValueMatcher({ status: "ok" }, { equals: { status: "fail" } })).toBe(false);
expect(applyValueMatcher(["a", "b"], { equals: ["a", "b"] })).toBe(true);
expect(applyValueMatcher(["a", "b"], { equals: ["b", "a"] })).toBe(false);
});
test("contains 操作符", () => {
expect(applyMatcher("hello world", { contains: "hello" })).toBe(true);
expect(applyMatcher("hello world", { contains: "missing" })).toBe(false);
expect(applyMatcher(12345, { contains: "23" })).toBe(true);
expect(applyValueMatcher("hello world", { contains: "hello" })).toBe(true);
expect(applyValueMatcher("hello world", { contains: "missing" })).toBe(false);
expect(applyValueMatcher(12345, { contains: "23" })).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);
expect(applyValueMatcher("v2.1.0", { regex: "\\d+\\.\\d+\\.\\d+" })).toBe(true);
expect(applyValueMatcher("v2.1", { regex: "\\d+\\.\\d+\\.\\d+" })).toBe(false);
expect(applyValueMatcher("abc123", { regex: "^\\w+\\d+$" })).toBe(true);
});
test("empty 操作符", () => {
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);
expect(applyValueMatcher("", { empty: true })).toBe(true);
expect(applyValueMatcher(null, { empty: true })).toBe(true);
expect(applyValueMatcher(undefined, { empty: true })).toBe(true);
expect(applyValueMatcher([], { empty: true })).toBe(true);
expect(applyValueMatcher({}, { empty: true })).toBe(true);
expect(applyValueMatcher("ok", { empty: true })).toBe(false);
expect(applyValueMatcher(0, { empty: true })).toBe(false);
expect(applyValueMatcher(false, { empty: true })).toBe(false);
expect(applyValueMatcher([1, 2], { empty: false })).toBe(true);
expect(applyValueMatcher([], { empty: false })).toBe(false);
});
test("exists 操作符", () => {
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);
expect(applyValueMatcher("ok", { exists: true })).toBe(true);
expect(applyValueMatcher(null, { exists: true })).toBe(true);
expect(applyValueMatcher(undefined, { exists: true })).toBe(false);
expect(applyValueMatcher(undefined, { exists: false })).toBe(true);
expect(applyValueMatcher("ok", { exists: false })).toBe(false);
});
test("gte 操作符", () => {
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);
expect(applyValueMatcher(10, { gte: 5 })).toBe(true);
expect(applyValueMatcher(5, { gte: 5 })).toBe(true);
expect(applyValueMatcher(3, { gte: 5 })).toBe(false);
expect(applyValueMatcher("10", { gte: 5 })).toBe(true);
});
test("lte 操作符", () => {
expect(applyMatcher(3, { lte: 5 })).toBe(true);
expect(applyMatcher(5, { lte: 5 })).toBe(true);
expect(applyMatcher(10, { lte: 5 })).toBe(false);
expect(applyValueMatcher(3, { lte: 5 })).toBe(true);
expect(applyValueMatcher(5, { lte: 5 })).toBe(true);
expect(applyValueMatcher(10, { lte: 5 })).toBe(false);
});
test("gt 操作符", () => {
expect(applyMatcher(10, { gt: 5 })).toBe(true);
expect(applyMatcher(5, { gt: 5 })).toBe(false);
expect(applyValueMatcher(10, { gt: 5 })).toBe(true);
expect(applyValueMatcher(5, { gt: 5 })).toBe(false);
});
test("lt 操作符", () => {
expect(applyMatcher(3, { lt: 5 })).toBe(true);
expect(applyMatcher(5, { lt: 5 })).toBe(false);
expect(applyValueMatcher(3, { lt: 5 })).toBe(true);
expect(applyValueMatcher(5, { lt: 5 })).toBe(false);
});
test("多操作符 AND 组合", () => {
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);
expect(applyValueMatcher(7, { gte: 5, lte: 10 })).toBe(true);
expect(applyValueMatcher(3, { gte: 5, lte: 10 })).toBe(false);
expect(applyValueMatcher(15, { gte: 5, lte: 10 })).toBe(false);
});
});
describe("checkExpectValue", () => {
test("原始值直接比较", () => {
expect(checkExpectValue("ok", "ok")).toBe(true);
expect(checkExpectValue("ok", "error")).toBe(false);
expect(checkExpectValue(42, 42)).toBe(true);
expect(checkExpectValue(null, null)).toBe(true);
describe("resolveValueExpectation", () => {
test("原始值解析为 equals matcher", () => {
expect(resolveValueExpectation("ok")).toEqual({ equals: "ok" });
expect(resolveValueExpectation(42)).toEqual({ equals: 42 });
expect(resolveValueExpectation(null)).toEqual({ equals: null });
expect(resolveValueExpectation(true)).toEqual({ equals: true });
});
test("对象作为操作符", () => {
expect(checkExpectValue(42, { gte: 10 })).toBe(true);
expect(checkExpectValue(42, { gte: 100 })).toBe(false);
expect(checkExpectValue("hello", { contains: "ell" })).toBe(true);
test("对象 matcher 原样保留", () => {
const matcher = { gte: 10 };
expect(resolveValueExpectation(matcher)).toBe(matcher);
expect(resolveValueExpectation({ contains: "ell" })).toEqual({ contains: "ell" });
});
test("undefined 返回 undefined", () => {
expect(resolveValueExpectation(undefined)).toBeUndefined();
});
test("failure expected 使用用户可读的 equals 值", () => {
const result = checkValueExpectation("actual", resolveValueExpectation("expected"), {
path: "finishReason",
phase: "finishReason",
});
expect(result.matched).toBe(false);
expect(result.failure?.expected).toBe("expected");
});
test("displayValueExpectation 保留多 matcher 对象", () => {
expect(displayValueExpectation({ contains: "ok", regex: "^ok$" })).toEqual({ contains: "ok", regex: "^ok$" });
});
});

View File

@@ -1,9 +1,12 @@
import { describe, expect, test } from "bun:test";
import { checkContentRules } from "../../../../../src/server/checker/expect/content";
import type { RawContentExpectations } from "../../../../../src/server/checker/expect/types";
function checkTextRules(text: string, rules: Parameters<typeof checkContentRules>[1], phase: string) {
return checkContentRules(text, rules, { path: phase, phase });
import { checkContentExpectations, resolveContentExpectations } from "../../../../../src/server/checker/expect/content";
function checkTextRules(text: string, rawRules: RawContentExpectations, phase: string) {
const resolved = resolveContentExpectations(rawRules);
return checkContentExpectations(text, resolved, { path: phase, phase });
}
describe("checkTextRules", () => {

View File

@@ -15,7 +15,7 @@ function input(target: Record<string, unknown>): CheckerValidationInput {
}
describe("ValueMatcher primitive shorthand in checker validators", () => {
test("normalizes shorthand for all checker ValueMatcher fields", () => {
test("accepts shorthand for all checker ValueMatcher fields", () => {
const targets = [
{
expect: { durationMs: 100 },
@@ -82,9 +82,10 @@ describe("ValueMatcher primitive shorthand in checker validators", () => {
for (const target of targets) {
const { validate, ...config } = target;
const original = structuredClone(config);
expect(validate(input(config))).toHaveLength(0);
expect((config.expect as Record<string, unknown>)["durationMs"]).toEqual({ equals: 100 });
expect(config).toEqual(original);
}
});

View File

@@ -1,8 +1,14 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { ResolvedTcpTarget } from "../../../../../src/server/checker/runner/tcp/types";
import type {
RawTcpExpectConfig,
ResolvedTcpExpectConfig,
ResolvedTcpTarget,
} from "../../../../../src/server/checker/runner/tcp/types";
import type { CheckerContext } from "../../../../../src/server/checker/runner/types";
import { resolveContentExpectations } from "../../../../../src/server/checker/expect/content";
import { resolveValueExpectation } from "../../../../../src/server/checker/expect/value";
import { TcpChecker } from "../../../../../src/server/checker/runner/tcp/execute";
const checker = new TcpChecker();
@@ -20,13 +26,27 @@ function makeCtx(timeoutMs = 5000): CheckerContext {
return { signal: controller.signal };
}
function makeTarget(tcp: Partial<ResolvedTcpTarget["tcp"]>, overrides?: Partial<ResolvedTcpTarget>): ResolvedTcpTarget {
function makeTarget(
tcp: Partial<ResolvedTcpTarget["tcp"]>,
overrides?: { expect?: RawTcpExpectConfig },
): ResolvedTcpTarget {
const raw = overrides?.expect;
const resolvedExpect: ResolvedTcpExpectConfig | undefined = raw
? {
banner: resolveContentExpectations(raw.banner),
connected: raw.connected ?? true,
durationMs: resolveValueExpectation(raw.durationMs),
}
: undefined;
return {
description: null,
expect: resolvedExpect,
group: "default",
id: "test-tcp",
intervalMs: 60000,
name: "test-tcp",
rawExpect: raw,
tcp: {
bannerReadTimeout: 2000,
host: "127.0.0.1",
@@ -37,7 +57,6 @@ function makeTarget(tcp: Partial<ResolvedTcpTarget["tcp"]>, overrides?: Partial<
},
timeoutMs: 5000,
type: "tcp",
...overrides,
};
}
@@ -295,6 +314,8 @@ describe("TcpChecker resolve", () => {
expect(target.name).toBeNull();
expect(target.intervalMs).toBe(30000);
expect(target.timeoutMs).toBe(10000);
expect(target.rawExpect).toBeUndefined();
expect(target.expect).toEqual({ connected: true });
});
test("bannerReadTimeout 和 maxBannerBytes 支持 per-target 覆盖", () => {
@@ -356,7 +377,12 @@ describe("TcpChecker resolve", () => {
},
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
);
expect(target.expect).toEqual({ banner: [{ contains: "ESMTP" }], connected: false, durationMs: { lte: 5000 } });
expect(target.expect).toEqual({
banner: [{ kind: "value", matcher: { contains: "ESMTP" } }],
connected: false,
durationMs: { lte: 5000 },
});
expect(target.rawExpect).toEqual({ banner: [{ contains: "ESMTP" }], connected: false, durationMs: { lte: 5000 } });
});
test("name 和 group 解析", () => {

View File

@@ -1,7 +1,13 @@
import { describe, expect, test } from "bun:test";
import type { ContentExpectations } from "../../../../../src/server/checker/expect/types";
import { checkBanner, checkConnected } from "../../../../../src/server/checker/runner/tcp/expect";
function value(matcher: ContentExpectations[number]["matcher"]): ContentExpectations[number] {
return { kind: "value", matcher };
}
describe("checkConnected", () => {
test("connected=true 期望 true 匹配", () => {
const result = checkConnected(true, true);
@@ -32,34 +38,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", [value({ 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", [value({ contains: "POSTFIX" })]);
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("banner");
});
test("regex 正则匹配", () => {
const result = checkBanner("220 smtp.example.com ESMTP", [{ regex: "^220" }]);
const result = checkBanner("220 smtp.example.com ESMTP", [value({ regex: "^220" })]);
expect(result.matched).toBe(true);
});
test("空 banner 与 contains 空字符串", () => {
const result = checkBanner("", [{ contains: "" }]);
const result = checkBanner("", [value({ contains: "" })]);
expect(result.matched).toBe(true);
});
test("多 operator 同时匹配", () => {
const result = checkBanner("220 ESMTP", [{ contains: "ESMTP", regex: "^220" }]);
const result = checkBanner("220 ESMTP", [value({ contains: "ESMTP", regex: "^220" })]);
expect(result.matched).toBe(true);
});
test("多 operator 部分不匹配", () => {
const result = checkBanner("220 ESMTP", [{ contains: "ESMTP", regex: "^250" }]);
const result = checkBanner("220 ESMTP", [value({ contains: "ESMTP", regex: "^250" })]);
expect(result.matched).toBe(false);
});
});

View File

@@ -1,7 +1,13 @@
import { describe, expect, it } from "bun:test";
import type { ResolvedUdpTarget, UdpExpectConfig } from "../../../../../src/server/checker/runner/udp/types";
import type {
RawUdpExpectConfig,
ResolvedUdpExpectConfig,
ResolvedUdpTarget,
} from "../../../../../src/server/checker/runner/udp/types";
import { resolveContentExpectations } from "../../../../../src/server/checker/expect/content";
import { resolveValueExpectation } from "../../../../../src/server/checker/expect/value";
import { UdpChecker } from "../../../../../src/server/checker/runner/udp/execute";
async function createEchoServer(): Promise<{ close: () => void; port: number }> {
@@ -27,14 +33,26 @@ function makeSignal(timeoutMs: number): { cleanup: () => void; signal: AbortSign
return { cleanup: () => clearTimeout(timer), signal: controller.signal };
}
function makeTarget(overrides: Partial<ResolvedUdpTarget["udp"]> = {}, expect?: UdpExpectConfig): ResolvedUdpTarget {
function makeTarget(overrides: Partial<ResolvedUdpTarget["udp"]> = {}, raw?: RawUdpExpectConfig): ResolvedUdpTarget {
const resolvedExpect: ResolvedUdpExpectConfig | undefined = raw
? {
durationMs: resolveValueExpectation(raw.durationMs),
responded: raw.responded ?? true,
response: resolveContentExpectations(raw.response),
responseSize: resolveValueExpectation(raw.responseSize),
sourceHost: resolveValueExpectation(raw.sourceHost),
sourcePort: resolveValueExpectation(raw.sourcePort),
}
: undefined;
return {
description: null,
expect,
expect: resolvedExpect,
group: "default",
id: "test-udp",
intervalMs: 30000,
name: null,
rawExpect: raw,
timeoutMs: 10000,
type: "udp",
udp: {
@@ -322,6 +340,8 @@ describe("UdpChecker resolve", () => {
expect(target.udp.encoding).toBe("text");
expect(target.udp.responseEncoding).toBe("text");
expect(target.udp.maxResponseBytes).toBe(4096);
expect(target.rawExpect).toBeUndefined();
expect(target.expect).toEqual({ responded: true });
});
it("should use defaults.udp for missing fields", () => {

View File

@@ -1,5 +1,7 @@
import { describe, expect, it } from "bun:test";
import type { ContentExpectations } from "../../../../../src/server/checker/expect/types";
import {
checkResponded,
checkResponseSize,
@@ -8,6 +10,10 @@ import {
checkSourcePort,
} from "../../../../../src/server/checker/runner/udp/expect";
function value(matcher: ContentExpectations[number]["matcher"]): ContentExpectations[number] {
return { kind: "value", matcher };
}
describe("checkResponded", () => {
it("responded=true 期望 true → 匹配", () => {
const result = checkResponded(true, true);
@@ -63,26 +69,26 @@ describe("checkResponseSize", () => {
describe("checkResponseText", () => {
it("单条 contains 匹配", () => {
const result = checkResponseText("PONG", [{ contains: "PONG" }]);
const result = checkResponseText("PONG", [value({ contains: "PONG" })]);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
it("单条 contains 不匹配phase=response", () => {
const result = checkResponseText("PING", [{ contains: "PONG" }]);
const result = checkResponseText("PING", [value({ contains: "PONG" })]);
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("response");
});
it("多条规则全部匹配", () => {
const result = checkResponseText("hello world", [{ contains: "hello" }, { regex: "^hello" }]);
const result = checkResponseText("hello world", [value({ contains: "hello" }), value({ regex: "^hello" })]);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
it("多条规则第二条失败 → 不匹配", () => {
const result = checkResponseText("hello world", [{ contains: "hello" }, { regex: "^world" }]);
const result = checkResponseText("hello world", [value({ contains: "hello" }), value({ regex: "^world" })]);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("response");
expect(result.failure!.path).toBe("response[1]");

View File

@@ -26,7 +26,7 @@ beforeAll(() => {
const httpTarget: ResolvedHttpTarget = {
description: null,
expect: { durationMs: { lte: 3000 }, status: [200] },
expect: { body: [{ kind: "value", matcher: { contains: "ok" } }], durationMs: { equals: 3000 }, status: [200] },
group: "default",
http: {
headers: { Accept: "application/json" },
@@ -39,6 +39,7 @@ const httpTarget: ResolvedHttpTarget = {
id: "test-http",
intervalMs: 30000,
name: "test-http",
rawExpect: { body: [{ contains: "ok" }], durationMs: 3000 },
timeoutMs: 10000,
type: "http",
};
@@ -106,7 +107,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({ durationMs: { lte: 3000 }, status: [200] });
expect(JSON.parse(t.expect!)).toEqual({ body: [{ contains: "ok" }], durationMs: 3000 });
});
test("cmd target 字段正确", () => {