1
0
Files
DiAL/tests/server/checker/runner/http/runner.test.ts
lanyuanxiaoyao bb6b2bc20b refactor: checker 模块内聚化 — 每个 checker 自包含于独立目录
将 checker 架构重构为完全内聚模式:每个 checker 目录包含自身的
types、schema、validate、execute、expect 和 index,新增 checker
只需创建一个目录并在 runner/index.ts 添加一行注册。

主要变更:
- runner/shared/ 拆分:断言基础设施迁入 checker/expect/,
  body.ts 迁入 http/,text.ts 迁入 command/
- config-contract/ 重命名为 schema/,schema.ts → builder.ts
- size.ts + parseDuration 合并为 utils.ts
- 顶层 types.ts 改为 base interface + index signature,
  checker 专属类型下沉到各自 types.ts
- runner/index.ts 改为显式数组注册模式
- 更新 DEVELOPMENT.md 项目结构和开发新 Checker 指南
2026-05-13 14:38:21 +08:00

788 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { ResolvedHttpTarget } from "../../../../../src/server/checker/runner/http/types";
import type { CheckerContext, ResolveContext } from "../../../../../src/server/checker/runner/types";
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();
function validateHttpTarget(target: unknown): string {
return formatConfigIssues(checker.validate({ defaults: {}, targets: [target as never] }));
}
const SELF_SIGNED_CERT = `-----BEGIN CERTIFICATE-----
MIIDJTCCAg2gAwIBAgIUTwQU8FzvnvxNYR7mMO0DLcnq+wQwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDUxMjE1NDAyOFoXDTM2MDUw
OTE1NDAyOFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEArC0G46EXF8qPsCS2mtNwHzGFQvFQNcU8k7cZkCTwt4Cp
DlLOA2DbzR02LiVk/TA+d9qMUABAiXMndwebKv8EYxoKjwTY0jbVLKfEIIFxQS3F
uvKDgkYJz8P675p8fhR0Xa21+13b0/T8fperYC7fBZZsAqyo8+aF9QOUjy+kWRjr
lTTL1ez5L1nX0QCczTRaUDe51NTmcUYHJoiLqdKI2ZjXds7wnsaAfAgh7H9qr4wl
sUhCHV/Pg1LzBtfyLKZcImUJWWkj/KlgFgZ6aRyJHoGFmlZtXyaKhf3rEa+ZvKOy
MhcRmWC694PF+QjhrWS7oODLuY3XC5WKnLKxlBgfAwIDAQABo28wbTAdBgNVHQ4E
FgQUrHJEbBSDHOx/HAQ5nQp35v0Ljw8wHwYDVR0jBBgwFoAUrHJEbBSDHOx/HAQ5
nQp35v0Ljw8wDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SH
BH8AAAEwDQYJKoZIhvcNAQELBQADggEBAEJ0s/FJ6KZalSM0ntHxlMOB9taUa60I
A6zqrEMauU8BqZO3QLmX6a821geZntQtz77kGtW6rQWxELBNjN3rXTbUKfKXN/Au
ZLftNJLsQOjKF+1uFOF49D4/5Le9PGvwl79Qua/l6JO5HRJL9Dh545/zEr9W5Erb
l4JoKKfyCEYjrPg5tl7d2PrHUmzk+sGlxEqNeKIl272+3UMVCbkVHI/v6rtb4F7p
u77O0UYLNIRFZQOVqvE7A7rfYy93J8EEQcADKH/Nhx8clFxC5X187EakcVAfkeKX
SL7R1kmUiLPiHbVCqGyS2m3RH2XDM3MbA9WCCczbwXn5Lwp5HEz0wb0=
-----END CERTIFICATE-----`;
const SELF_SIGNED_KEY = `-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCsLQbjoRcXyo+w
JLaa03AfMYVC8VA1xTyTtxmQJPC3gKkOUs4DYNvNHTYuJWT9MD532oxQAECJcyd3
B5sq/wRjGgqPBNjSNtUsp8QggXFBLcW68oOCRgnPw/rvmnx+FHRdrbX7XdvT9Px+
l6tgLt8FlmwCrKjz5oX1A5SPL6RZGOuVNMvV7PkvWdfRAJzNNFpQN7nU1OZxRgcm
iIup0ojZmNd2zvCexoB8CCHsf2qvjCWxSEIdX8+DUvMG1/IsplwiZQlZaSP8qWAW
BnppHIkegYWaVm1fJoqF/esRr5m8o7IyFxGZYLr3g8X5COGtZLug4Mu5jdcLlYqc
srGUGB8DAgMBAAECggEAO91wDsedIu2QZlttjonD62SphCwpinio5md8oOznMbav
kUZjUTNlWX01sHfaFFqo7b10mgBscB4086MWZa3D1b1hPHcf+H+OQXeXrwGy4knK
/YSDC1HU6YOoBZV+gcwU5dmXc+4fmCQPguizcr75VpUFuyxTlnJp01ZKWjrjdwKs
IMU8a1CxHMT5clFf/3rU4U5o90cktsiRzjc83QFNpvRsF0rAn98Z70ocDWAATxUu
efLonMur5t2wlu2CLjZSXHvkkwwFQ2u7XUXuudDRAKeg77+RGuUGk8Z5269cHs22
Ff4cej7vOnoU0CuDaXL37vzUXkfImB6pSFTblfiHgQKBgQDX+s0MqtTZeho6F6Iy
qHFWqkEItfrTErMEVjgBrMl42+EfzsAKa+910NPdV3z5Z/u7fAp3ComtxJ1pjiNj
bVah4/xobsHIIS1/XfPuxOaqkdOhhgYvCe8IIC6Z4yCPdRD5pW6dN18fK338YF4s
lVll+E/DJx7R08tFwSLGYNt5QQKBgQDMFFn1vT4GMHeeF2/kVNYE2U1Lntsy/swT
VLCgaOJuUvbJiKMa+J1jdjAsudAmOJgjTkR4sco0Rpsen+x7StaGBzMbXKHONUf3
OLzQsP06JnA9oAftxsjg0IDH8JCAuQsQ2xKMN+f0d0+pggOzS/z7336a3bm1Zeee
wYqjtLOjQwKBgQCRoTzt06qd0aUpkpH9knKJu1cKppowBKXMwM4W4wkegzRzHBeF
b24RhPO2ha1xBlpI+sSbq/FVyANUD1FxU2Jc2rtxN21WonhpL1KxpvbaAGYwvYwh
35LbacfCX9GuqYL+sju5qoJrJApZSCl36mRTS3GM5y3y0dp4eFgYZ2rVgQKBgCLq
tH2cFFmgv0aYQfeyIDASMexnUJ/IAoioK9Q2Pc+ceEcBDs8VjHAxD4sHe7qeYkFg
KczwtmT9U5sIx8BMjKm/35ml3rVWXmrJFV0rexgQ7ZFNqS2gnkwAwJf06/RqNJ98
rA67nf8wzrt02Ec8EBvUIGhE2XpU5i0+dgcOatHbAoGBAJIutK961t5lJVF1g1M9
KC4rmCCMCTvJSbruQWDpoxYa7Cl6+TopU+xu4537FCzHUJ3EPg3KsYCeCM0LEtR4
GQjRzFM3qqWabzoAV3KLaONWbK1rI9mHZf8KyWYiJ9cRXwTJ4rGYNMM/6QIUGQSx
agwJojCQqS4f6AfCNdUOzaRp
-----END PRIVATE KEY-----`;
describe("HttpChecker", () => {
let server: ReturnType<typeof Bun.serve>;
let baseUrl: string;
beforeAll(() => {
server = Bun.serve({
fetch(req) {
const url = new URL(req.url);
switch (url.pathname) {
case "/echo":
return new Response(JSON.stringify({ body: req.body ? "present" : "empty", method: req.method }), {
headers: { "content-type": "application/json" },
});
case "/gbk": {
const gbkBytes = new Uint8Array([0xc4, 0xe3, 0xba, 0xc3]);
return new Response(gbkBytes, { headers: { "content-type": "text/plain; charset=gbk" } });
}
case "/gbk-quoted": {
const gbkBytes = new Uint8Array([0xc4, 0xe3, 0xba, 0xc3]);
return new Response(gbkBytes, { headers: { "content-type": 'text/plain; charset="gbk"' } });
}
case "/json":
return new Response(JSON.stringify({ status: "ok" }), {
headers: { "content-type": "application/json" },
});
case "/large":
return new Response("x".repeat(2000));
case "/notfound":
return new Response("not found", { status: 404 });
case "/ok":
return new Response("hello world", {
headers: { "content-type": "text/plain", "x-custom": "test-value" },
});
case "/redirect":
return new Response(null, { headers: { location: `${baseUrl}/ok` }, status: 301 });
case "/redirect-chain-1":
return new Response(null, { headers: { location: `${baseUrl}/redirect-chain-2` }, status: 302 });
case "/redirect-chain-2":
return new Response(null, { headers: { location: `${baseUrl}/ok` }, status: 302 });
case "/redirect-cross": {
const port = parseInt(url.searchParams.get("port") ?? "0");
return new Response(null, {
headers: { location: `http://127.0.0.1:${port}/ok` },
status: 302,
});
}
case "/redirect-post":
return new Response(null, { headers: { location: `${baseUrl}/echo` }, status: 301 });
case "/slow-body":
return new Response("x".repeat(2000));
case "/unknown-charset": {
return new Response("test", { headers: { "content-type": "text/plain; charset=bogus-encoding" } });
}
default:
return new Response("ok");
}
},
port: 0,
});
baseUrl = `http://localhost:${server.port}`;
});
afterAll(() => {
void server.stop();
});
function makeTarget(overrides: {
body?: string;
expect?: Record<string, unknown>;
headers?: Record<string, string>;
ignoreSSL?: boolean;
maxBodyBytes?: number;
maxRedirects?: number;
method?: string;
timeoutMs?: number;
url?: string;
}): ResolvedHttpTarget {
return {
expect: overrides.expect,
group: "default",
http: {
body: overrides.body,
headers: overrides.headers ?? {},
ignoreSSL: overrides.ignoreSSL ?? false,
maxBodyBytes: overrides.maxBodyBytes ?? 1024 * 1024,
maxRedirects: overrides.maxRedirects ?? 0,
method: overrides.method ?? "GET",
url: overrides.url ?? `${baseUrl}/ok`,
},
intervalMs: 60000,
name: "test-http",
timeoutMs: overrides.timeoutMs ?? 5000,
type: "http",
};
}
function makeCtx(timeoutMs = 5000): CheckerContext {
const controller = new AbortController();
setTimeout(() => controller.abort(), timeoutMs);
return { signal: controller.signal };
}
test("成功请求 200", async () => {
const result = await checker.execute(makeTarget({ url: `${baseUrl}/ok` }), makeCtx());
expect(result.matched).toBe(true);
expect(result.statusDetail).toBe("HTTP 200");
expect(result.durationMs).not.toBeNull();
expect(result.failure).toBeNull();
});
test("404 不匹配默认 status [200]", async () => {
const result = await checker.execute(makeTarget({ url: `${baseUrl}/notfound` }), makeCtx());
expect(result.matched).toBe(false);
expect(result.statusDetail).toBe("HTTP 404");
expect(result.failure!.phase).toBe("status");
});
test("404 匹配自定义 status [404]", async () => {
const result = await checker.execute(
makeTarget({ expect: { status: [404] }, url: `${baseUrl}/notfound` }),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("headers 检查通过", async () => {
const result = await checker.execute(
makeTarget({ expect: { headers: { "x-custom": "test-value" } }, url: `${baseUrl}/ok` }),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("headers 检查失败", async () => {
const result = await checker.execute(
makeTarget({ expect: { headers: { "x-custom": "wrong-value" } }, url: `${baseUrl}/ok` }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("headers");
});
test("body contains 检查", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "hello" }] }, url: `${baseUrl}/ok` }),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("body contains 失败", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "nonexistent" }] }, url: `${baseUrl}/ok` }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("body");
});
test("body json 检查", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ json: { equals: "ok", path: "$.status" } }] }, url: `${baseUrl}/json` }),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("响应体超过 maxBodyBytes", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "x" }] }, maxBodyBytes: 100, url: `${baseUrl}/large` }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("body");
expect(result.failure!.message).toContain("超过限制");
});
test("请求超时", async () => {
const timeoutServer = Bun.serve({
async fetch() {
await new Promise((resolve) => setTimeout(resolve, 10000));
return new Response("late");
},
port: 0,
});
try {
const result = await checker.execute(
makeTarget({ timeoutMs: 100, url: `http://localhost:${timeoutServer.port}/` }),
makeCtx(100),
);
expect(result.matched).toBe(false);
expect(result.failure!.message).toContain("超时");
} finally {
void timeoutServer.stop();
}
});
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");
});
test("status 通过但 body 失败", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "not-in-body" }], status: [200] }, url: `${baseUrl}/ok` }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("body");
});
test("无 expect 时默认检查 status 200", async () => {
const result = await checker.execute(makeTarget({ expect: undefined, url: `${baseUrl}/ok` }), makeCtx());
expect(result.matched).toBe(true);
});
test("POST 请求携带 body", async () => {
const result = await checker.execute(
makeTarget({
body: "test-body",
expect: { body: [{ json: { equals: "present", path: "$.body" } }], status: [200] },
headers: { "content-type": "text/plain" },
method: "POST",
url: `${baseUrl}/echo`,
}),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("serialize 返回 URL 和 config JSON", () => {
const target = makeTarget({});
const s = checker.serialize(target);
expect(s.target).toBe(target.http.url);
const config = JSON.parse(s.config) as { ignoreSSL: boolean; maxRedirects: number; method: string; url: string };
expect(config.url).toBe(target.http.url);
expect(config.method).toBe("GET");
expect(config.ignoreSSL).toBe(false);
expect(config.maxRedirects).toBe(0);
});
test("maxRedirects=0 不跟随重定向", async () => {
const result = await checker.execute(makeTarget({ maxRedirects: 0, url: `${baseUrl}/redirect` }), makeCtx());
expect(result.matched).toBe(false);
expect(result.statusDetail).toBe("HTTP 301");
});
test("maxRedirects>0 跟随重定向", async () => {
const result = await checker.execute(makeTarget({ maxRedirects: 5, url: `${baseUrl}/redirect` }), makeCtx());
expect(result.matched).toBe(true);
expect(result.statusDetail).toBe("HTTP 200");
});
test("maxRedirects 精确限制跟随次数", async () => {
const result = await checker.execute(
makeTarget({ maxRedirects: 1, url: `${baseUrl}/redirect-chain-1` }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.statusDetail).toBe("HTTP 302");
});
test("maxRedirects 允许足够次数时到达最终目标", async () => {
const result = await checker.execute(
makeTarget({ maxRedirects: 2, url: `${baseUrl}/redirect-chain-1` }),
makeCtx(),
);
expect(result.matched).toBe(true);
expect(result.statusDetail).toBe("HTTP 200");
});
test("ignoreSSL 跳过自签名证书校验", async () => {
const httpsServer = Bun.serve({
fetch() {
return new Response("secure ok");
},
port: 0,
tls: { cert: SELF_SIGNED_CERT, key: SELF_SIGNED_KEY },
});
try {
const strictResult = await checker.execute(
makeTarget({ ignoreSSL: false, url: `https://localhost:${httpsServer.port}/` }),
makeCtx(),
);
expect(strictResult.matched).toBe(false);
expect(strictResult.statusDetail).toBeNull();
const ignoredResult = await checker.execute(
makeTarget({ ignoreSSL: true, url: `https://localhost:${httpsServer.port}/` }),
makeCtx(),
);
expect(ignoredResult.matched).toBe(true);
expect(ignoredResult.statusDetail).toBe("HTTP 200");
} finally {
void httpsServer.stop();
}
});
test("响应体编码自动检测 GBK", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "你好" }] }, url: `${baseUrl}/gbk` }),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("响应体编码回退 UTF-8", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "hello" }] }, url: `${baseUrl}/ok` }),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("响应体编码 quoted charset", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "你好" }] }, url: `${baseUrl}/gbk-quoted` }),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("响应体不支持的编码返回结构化 body 错误", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "test" }] }, url: `${baseUrl}/unknown-charset` }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("body");
expect(result.failure!.kind).toBe("error");
expect(result.failure!.message).toContain("不支持的字符编码");
});
test("流式 body 等于上限允许通过", async () => {
const bodyLen = "hello world".length;
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "hello" }] }, maxBodyBytes: bodyLen, url: `${baseUrl}/ok` }),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("maxBodyBytes 为 0 且响应体非空时返回超限错误", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "hello" }] }, maxBodyBytes: 0, url: `${baseUrl}/ok` }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("body");
expect(result.failure!.kind).toBe("error");
expect(result.failure!.message).toContain("超过限制");
});
test("body 超限时错误归属 body phase", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "x" }] }, maxBodyBytes: 100, url: `${baseUrl}/large` }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("body");
expect(result.failure!.kind).toBe("error");
});
test("请求错误归属 request phase", async () => {
const result = await checker.execute(makeTarget({ url: "http://localhost:1/" }), makeCtx(500));
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("request");
expect(result.failure!.kind).toBe("error");
});
test("超时错误归属 request phase", async () => {
const timeoutServer = Bun.serve({
async fetch() {
await new Promise((resolve) => setTimeout(resolve, 10000));
return new Response("late");
},
port: 0,
});
try {
const result = await checker.execute(
makeTarget({ timeoutMs: 100, url: `http://localhost:${timeoutServer.port}/` }),
makeCtx(100),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("request");
expect(result.failure!.message).toContain("超时");
} finally {
void timeoutServer.stop();
}
});
test("durationMs 包含 body 读取耗时", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "x" }] }, maxBodyBytes: 10240, url: `${baseUrl}/large` }),
makeCtx(),
);
expect(result.matched).toBe(true);
expect(result.durationMs).not.toBeNull();
expect(result.durationMs!).toBeGreaterThanOrEqual(0);
});
test("expect.maxDurationMs 使用完整耗时", async () => {
const slowServer = Bun.serve({
async fetch() {
await new Promise((resolve) => setTimeout(resolve, 50));
return new Response("x".repeat(500));
},
port: 0,
});
try {
const result = await checker.execute(
makeTarget({
expect: { body: [{ contains: "x" }], maxDurationMs: 10 },
url: `http://localhost:${slowServer.port}/`,
}),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("duration");
} finally {
void slowServer.stop();
}
});
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 失败优先于 duration 检查", async () => {
const result = await checker.execute(
makeTarget({
expect: { body: [{ contains: "nonexistent" }], maxDurationMs: 999999 },
url: `${baseUrl}/ok`,
}),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("body");
expect(result.durationMs).not.toBeNull();
expect(result.durationMs!).toBeGreaterThanOrEqual(0);
});
test("POST 重定向改 GET 清理 body headers", async () => {
const result = await checker.execute(
makeTarget({
body: "test-body",
expect: { status: [200] },
headers: { authorization: "Bearer token123", "content-type": "text/plain" },
maxRedirects: 5,
method: "POST",
url: `${baseUrl}/redirect-post`,
}),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("跨 origin 重定向剥离敏感 headers", async () => {
const targetServer = Bun.serve({
fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/ok") {
const auth = req.headers.get("authorization");
if (auth) {
return new Response("auth leaked", { status: 200 });
}
return new Response("safe", { headers: { "content-type": "text/plain" } });
}
return new Response("ok");
},
port: 0,
});
try {
const result = await checker.execute(
makeTarget({
expect: { body: [{ contains: "safe" }] },
headers: { authorization: "Bearer secret" },
maxRedirects: 5,
url: `${baseUrl}/redirect-cross?port=${targetServer.port}`,
}),
makeCtx(),
);
expect(result.matched).toBe(true);
} finally {
void targetServer.stop();
}
});
test("1xx 范围模式匹配 101", () => {
const r = checkStatus(101, ["1xx"]);
expect(r.matched).toBe(true);
});
test("6xx 范围模式启动校验失败", () => {
const errors = validateHttpTarget({
expect: { status: ["6xx"] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("status 模式必须为 1xx 到 5xx");
});
test("status 数字 99 启动校验失败", () => {
const errors = validateHttpTarget({
expect: { status: [99] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("100-599");
});
test("body rule 未知字段启动失败", () => {
const errors = validateHttpTarget({
expect: { body: [{ contains: "ok", note: "ignored" }], status: [200] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("note 是未知字段");
});
test("body rule 使用 match 字段启动失败", () => {
const errors = validateHttpTarget({
expect: { body: [{ match: "ok" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("缺少支持的规则类型");
});
test("非法 regex 启动校验失败", () => {
const errors = validateHttpTarget({
expect: { body: [{ regex: "[invalid" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("regex 正则不合法");
});
test("非法 JSONPath 启动校验失败", () => {
const errors = validateHttpTarget({
expect: { body: [{ json: { equals: "ok", path: "status" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("json.path");
});
test("非法 operator match 启动校验失败", () => {
const errors = validateHttpTarget({
expect: { headers: { "x-test": { match: "[invalid" } } },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("match 正则不合法");
});
test("非法 operator gte 类型启动失败", () => {
const errors = validateHttpTarget({
expect: { body: [{ json: { gte: "abc", path: "$.count" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("gte 必须为有限数字");
});
test("非法 operator exists 类型启动失败", () => {
const errors = validateHttpTarget({
expect: { body: [{ json: { exists: "yes", path: "$.status" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("exists 必须为布尔值");
});
test("纯 operator 空对象启动失败", () => {
const errors = validateHttpTarget({
expect: { headers: { "x-test": {} } },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("必须包含至少一个合法 operator");
});
test("body rule 多个支持字段启动失败", () => {
const errors = validateHttpTarget({
expect: { body: [{ contains: "ok", regex: "ok" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("只能配置一种规则类型");
});
test("body rule 缺少支持字段启动失败", () => {
const errors = validateHttpTarget({
expect: { body: [{ foo: "bar" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("缺少支持的规则类型");
});
test("css selector 为空启动失败", () => {
const errors = validateHttpTarget({
expect: { body: [{ css: { selector: "" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("css.selector 必须为非空字符串");
});
test("xpath path 为空启动失败", () => {
const errors = validateHttpTarget({
expect: { body: [{ xpath: { path: "" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("xpath.path 必须为非空字符串");
});
test("json rule 允许存在性语义", () => {
const errors = validateHttpTarget({
expect: { body: [{ json: { path: "$.status" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toBe("");
});
test("equals 支持对象和数组", () => {
const errors = validateHttpTarget({
expect: {
body: [
{ json: { equals: { status: "ok" }, path: "$.payload" } },
{ json: { equals: ["a", "b"], path: "$.items" } },
],
},
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toBe("");
});
});
describe("HttpChecker.resolve", () => {
function makeResolveContext(): ResolveContext {
return {
configDir: ".",
defaultIntervalMs: 30000,
defaults: {},
defaultTimeoutMs: 10000,
};
}
test("expect.status 非法模式抛出错误", () => {
const errors = validateHttpTarget({
expect: { status: ["abc"] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("status 模式必须为 1xx 到 5xx");
});
test("ignoreSSL 默认值为 false", () => {
const result = checker.resolve(
{ http: { url: "https://example.com" }, name: "test", type: "http" },
makeResolveContext(),
);
expect((result as ResolvedHttpTarget).http.ignoreSSL).toBe(false);
});
test("maxRedirects 默认值为 0", () => {
const result = checker.resolve(
{ http: { url: "https://example.com" }, name: "test", type: "http" },
makeResolveContext(),
);
expect((result as ResolvedHttpTarget).http.maxRedirects).toBe(0);
});
test("合法 status 范围模式通过校验", () => {
const result = checker.resolve(
{ expect: { status: ["2xx", 301] }, http: { url: "https://example.com" }, name: "test", type: "http" },
makeResolveContext(),
);
expect((result as ResolvedHttpTarget).expect?.status).toEqual(["2xx", 301]);
});
test("显式 ignoreSSL 和 maxRedirects 正确解析", () => {
const result = checker.resolve(
{ http: { ignoreSSL: true, maxRedirects: 3, url: "https://example.com" }, name: "test", type: "http" },
makeResolveContext(),
);
expect((result as ResolvedHttpTarget).http.ignoreSSL).toBe(true);
expect((result as ResolvedHttpTarget).http.maxRedirects).toBe(3);
});
});