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:
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 返回摘要和配置", () => {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 浅合并", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
34
tests/server/checker/runner/shared/keyed.test.ts
Normal file
34
tests/server/checker/runner/shared/keyed.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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$" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 解析", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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]");
|
||||
|
||||
@@ -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 字段正确", () => {
|
||||
|
||||
Reference in New Issue
Block a user