1
0

feat: 增强 HTTP checker 鲁棒性 — 严格配置校验、完整耗时、流式body、重定向与编码完善

启动期校验: 新增 validate.ts 对 HTTP config/expect/body rule/operator 全方位严格校验
执行语义: body 改为 Web Stream 流式超限中止,durationMs 覆盖完整执行
错误归属: status/header 失败不读 body,phase 分层 request/body,early duration skip body
重定向: 跟随前释放 body,POST/303 改 GET 清理 header,跨 origin 剥离敏感 header
编码: 支持 quoted charset,未知编码返回结构化解码错误
文档: README match→regex+durationMs,DEVELOPMENT 执行流程与错误归属
测试: +63 测试覆盖全部新增场景,325 pass 0 fail
规格: 同步 probe-config/probe-engine/expect-body-checkers 3 个 delta spec
This commit is contained in:
2026-05-13 08:00:05 +08:00
parent 2fd0f206be
commit bce0f8e7a8
14 changed files with 1543 additions and 104 deletions

View File

@@ -625,4 +625,370 @@ targets:
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("group 字段必须为字符串");
});
test("HTTP headers 非字符串值抛出错误", async () => {
const configPath = join(tempDir, "bad-headers-val.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
headers:
X-Custom: 123
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("http.headers");
});
test("HTTP body 非字符串抛出错误", async () => {
const configPath = join(tempDir, "bad-body.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
body: 123
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("http.body 必须为字符串");
});
test("maxBodyBytes 负数抛出错误", async () => {
const configPath = join(tempDir, "neg-bodybytes.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
maxBodyBytes: -1
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("非负安全整数");
});
test("maxBodyBytes 非整数抛出错误", async () => {
const configPath = join(tempDir, "float-bodybytes.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
maxBodyBytes: 1.5
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("非负安全整数");
});
test("expect.status 数字不在 100-599 范围抛出错误", async () => {
const configPath = join(tempDir, "bad-status-num.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
status: [999]
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("100-599");
});
test("expect.status 范围 6xx 抛出错误", async () => {
const configPath = join(tempDir, "bad-status-6xx.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
status: ["6xx"]
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("5xx");
});
test("expect.maxDurationMs 负数抛出错误", async () => {
const configPath = join(tempDir, "neg-duration.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
maxDurationMs: -100
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("maxDurationMs 必须为非负有限数字");
});
test("expect.body 非数组抛出错误", async () => {
const configPath = join(tempDir, "bad-expect-body.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body: "not-array"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("expect.body 必须为数组");
});
test("body rule 缺少支持字段抛出错误", async () => {
const configPath = join(tempDir, "bad-body-rule-nofield.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body:
- foo: "bar"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("缺少支持的规则类型");
});
test("body rule 使用 match 字段(非支持)抛出错误", async () => {
const configPath = join(tempDir, "bad-body-rule-match.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body:
- match: "ok"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("缺少支持的规则类型");
});
test("body rule 多个支持字段抛出错误", async () => {
const configPath = join(tempDir, "bad-body-rule-multi.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body:
- contains: "ok"
regex: "ok"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("只能配置一种规则类型");
});
test("body regex 非法正则抛出错误", async () => {
const configPath = join(tempDir, "bad-body-regex.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body:
- regex: "[invalid"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("regex 正则不合法");
});
test("body json path 不以 $. 开头抛出错误", async () => {
const configPath = join(tempDir, "bad-json-path.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body:
- json:
path: "status"
equals: "ok"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("json.path");
});
test("body css selector 为空抛出错误", async () => {
const configPath = join(tempDir, "bad-css-sel.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body:
- css:
selector: ""
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("css.selector 必须为非空字符串");
});
test("operator match 非法正则抛出错误", async () => {
const configPath = join(tempDir, "bad-op-match.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
headers:
X-Test:
match: "[invalid"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("match 正则不合法");
});
test("operator gte 非数字抛出错误", async () => {
const configPath = join(tempDir, "bad-op-gte.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body:
- json:
path: "$.count"
gte: "abc"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("gte 必须为有限数字");
});
test("operator exists 非布尔值抛出错误", async () => {
const configPath = join(tempDir, "bad-op-exists.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body:
- json:
path: "$.status"
exists: "yes"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("exists 必须为布尔值");
});
test("未知字段忽略不影响启动", async () => {
const configPath = join(tempDir, "unknown-fields.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
unknownHttpField: "value"
expect:
status: [200]
unknownExpectField: "value"
body:
- contains: "ok"
note: "ignored"
`,
);
const config = await loadConfig(configPath);
expect(config.targets).toHaveLength(1);
const t = config.targets[0]!;
if (t.type === "http") {
expect(t.expect?.status).toEqual([200]);
}
});
test("xpath path 非空字符串校验", async () => {
const configPath = join(tempDir, "bad-xpath-path.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body:
- xpath:
path: ""
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("xpath.path 必须为非空字符串");
});
test("expect headers 非对象抛出错误", async () => {
const configPath = join(tempDir, "bad-expect-headers.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
headers: "invalid"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("expect.headers 必须为对象");
});
});

View File

@@ -107,7 +107,7 @@ describe("checkHttpExpect", () => {
expect(r.failure!.kind).toBe("error");
});
test("完整流水线 status->duration->headers->body 全部通过", () => {
test("完整流水线 status->headers->body->duration 全部通过", () => {
const r = checkHttpExpect(200, { "content-type": "application/json" }, JSON.stringify({ status: "healthy" }), 50, {
body: [{ json: { equals: "healthy", path: "$.status" } }],
headers: { "content-type": { contains: "json" } },
@@ -118,13 +118,13 @@ describe("checkHttpExpect", () => {
expect(r.failure).toBeNull();
});
test("完整流水线 status 通过但 duration 失败", () => {
test("完整流水线 status 和 headers 通过但 duration 失败", () => {
const r = checkHttpExpect(200, {}, "", 500, { maxDurationMs: 100, status: [200] });
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("duration");
});
test("完整流水线 status 和 duration 通过但 headers 失败", () => {
test("完整流水线 status 通过但 headers 失败", () => {
const r = checkHttpExpect(200, { "x-api": "v1" }, "", 50, {
headers: { "x-api": "v2" },
maxDurationMs: 100,
@@ -134,7 +134,7 @@ describe("checkHttpExpect", () => {
expect(r.failure!.phase).toBe("headers");
});
test("完整流水线 status/duration/headers 通过但 body 失败", () => {
test("完整流水线 status/headers 通过但 body 失败", () => {
const r = checkHttpExpect(200, { "content-type": "text/plain" }, "error occurred", 50, {
body: [{ contains: "success" }],
headers: { "content-type": "text/plain" },
@@ -185,4 +185,16 @@ describe("checkStatus 范围匹配", () => {
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("status");
});
test("1xx 范围匹配 101", () => {
expect(checkStatus(101, ["1xx"]).matched).toBe(true);
});
test("3xx 范围匹配 301", () => {
expect(checkStatus(301, ["3xx"]).matched).toBe(true);
});
test("4xx 范围匹配 404", () => {
expect(checkStatus(404, ["4xx"]).matched).toBe(true);
});
});

View File

@@ -3,6 +3,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { CheckerContext, ResolveContext } from "../../../../../src/server/checker/runner/types";
import type { ResolvedHttpTarget } from "../../../../../src/server/checker/types";
import { checkStatus } from "../../../../../src/server/checker/runner/http/expect";
import { HttpChecker } from "../../../../../src/server/checker/runner/http/runner";
const checker = new HttpChecker();
@@ -71,6 +72,10 @@ describe("HttpChecker", () => {
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" },
@@ -89,6 +94,20 @@ describe("HttpChecker", () => {
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");
}
@@ -355,6 +374,445 @@ describe("HttpChecker", () => {
);
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 范围模式启动校验失败", () => {
expect(() =>
checker.resolve(
{ expect: { status: ["6xx"] }, http: { url: "https://example.com" }, name: "test", type: "http" },
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("5xx");
});
test("status 数字 99 启动校验失败", () => {
expect(() =>
checker.resolve(
{ expect: { status: [99] }, http: { url: "https://example.com" }, name: "test", type: "http" },
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("100-599");
});
test("body rule 忽略未知字段", () => {
const result = checker.resolve(
{
expect: { body: [{ contains: "ok", note: "ignored" }], status: [200] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
);
expect((result as ResolvedHttpTarget).expect?.body).toEqual([
{ contains: "ok", note: "ignored" },
] as unknown as Array<{ contains: string }>);
});
test("body rule 使用 match 字段启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ match: "ok" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("缺少支持的规则类型");
});
test("非法 regex 启动校验失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ regex: "[invalid" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("regex 正则不合法");
});
test("非法 JSONPath 启动校验失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ json: { equals: "ok", path: "status" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
},
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("json.path");
});
test("非法 operator match 启动校验失败", () => {
expect(() =>
checker.resolve(
{
expect: { headers: { "x-test": { match: "[invalid" } } },
http: { url: "https://example.com" },
name: "test",
type: "http",
},
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("match 正则不合法");
});
test("非法 operator gte 类型启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ json: { gte: "abc", path: "$.count" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("gte 必须为有限数字");
});
test("非法 operator exists 类型启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ json: { exists: "yes", path: "$.status" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("exists 必须为布尔值");
});
test("body rule 多个支持字段启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ contains: "ok", regex: "ok" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("只能配置一种规则类型");
});
test("body rule 缺少支持字段启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ foo: "bar" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("缺少支持的规则类型");
});
test("css selector 为空启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ css: { selector: "" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("css.selector 必须为非空字符串");
});
test("xpath path 为空启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ xpath: { path: "" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("xpath.path 必须为非空字符串");
});
test("expect.headers 非对象启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { headers: "invalid" },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("expect.headers 必须为对象");
});
test("expect.body 非数组启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: "not-array" },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("expect.body 必须为数组");
});
test("maxDurationMs 负数启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { maxDurationMs: -100 },
http: { url: "https://example.com" },
name: "test",
type: "http",
},
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("maxDurationMs 必须为非负有限数字");
});
test("http.body 非字符串启动失败", () => {
expect(() =>
checker.resolve(
{
http: { body: 123, url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("http.body 必须为字符串");
});
test("http.headers 非字符串值启动失败", () => {
expect(() =>
checker.resolve(
{
http: { headers: { "X-Test": 123 }, url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("http.headers");
});
test("http.headers 非对象启动失败", () => {
expect(() =>
checker.resolve(
{
http: { headers: "invalid", url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("http.headers 必须为对象");
});
});
describe("HttpChecker.resolve", () => {

View File

@@ -136,4 +136,42 @@ describe("checkBodyExpect (BodyRule[])", () => {
expect(r.matched).toBe(false);
expect(r.failure!.path).toContain("body[1]");
});
test("JSON 响应不是合法 JSON 返回 error kind", () => {
const r = checkBodyExpect("not json", [{ json: { equals: "ok", path: "$.status" } }]);
expect(r.matched).toBe(false);
expect(r.failure!.kind).toBe("error");
expect(r.failure!.phase).toBe("body");
expect(r.failure!.path).toContain("json");
});
test("CSS selector 无匹配元素返回 mismatch kind", () => {
const html = "<div>no match</div>";
const r = checkBodyExpect(html, [{ css: { equals: "test", selector: "span.missing" } }]);
expect(r.matched).toBe(false);
expect(r.failure!.kind).toBe("mismatch");
expect(r.failure!.phase).toBe("body");
expect(r.failure!.path).toContain("css");
});
test("XPath 无匹配节点返回 mismatch kind", () => {
const xml = "<root><status>ok</status></root>";
const r = checkBodyExpect(xml, [{ xpath: { equals: "ok", path: "/root/missing/text()" } }]);
expect(r.matched).toBe(false);
expect(r.failure!.kind).toBe("mismatch");
expect(r.failure!.phase).toBe("body");
expect(r.failure!.path).toContain("xpath");
});
test("regex 规则使用 regex 字段", () => {
const r = checkBodyExpect("status: ok", [{ regex: "^status:" }]);
expect(r.matched).toBe(true);
});
test("regex 规则失败返回 body phase", () => {
const r = checkBodyExpect("status: error", [{ regex: "^ok$" }]);
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("body");
expect(r.failure!.path).toBe("body[0]");
});
});

View File

@@ -30,6 +30,18 @@ describe("parseSize", () => {
expect(parseSize(2048)).toBe(2048);
});
test("数字 0 返回 0", () => {
expect(parseSize(0)).toBe(0);
});
test("数字负数抛出错误", () => {
expect(() => parseSize(-1)).toThrow("非负安全整数");
});
test("数字非整数抛出错误", () => {
expect(() => parseSize(1.5)).toThrow("非负安全整数");
});
test("无效格式抛出错误", () => {
expect(() => parseSize("100")).toThrow("无效的 size 格式");
expect(() => parseSize("100MBB")).toThrow("无效的 size 格式");