feat: 重构为多类型 checker 通用框架,支持 HTTP 与命令检查
- 引入 typed target 判别联合,支持 http 与 command 两种 checker - expect 重构为有序规则数组,按配置顺序快速失败并生成结构化 failure - 新增 command runner,支持 exec + args 本地命令执行 - 引入全局并发限制 maxConcurrentChecks 和 size 解析 (KB/MB/GB) - HTTP/command 各自独立 expect pipeline,应用领域默认成功语义 - SQLite schema、API、Dashboard 全链路调整为 checker 通用契约 - 补充完整测试覆盖(192 tests),更新 README 与示例配置
This commit is contained in:
131
tests/server/checker/command-runner.test.ts
Normal file
131
tests/server/checker/command-runner.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { runCommandCheck } from "../../../src/server/checker/command-runner";
|
||||
import type { ResolvedCommandTarget } from "../../../src/server/checker/types";
|
||||
|
||||
function makeTarget(
|
||||
command: Partial<ResolvedCommandTarget["command"]>,
|
||||
overrides?: Partial<ResolvedCommandTarget>,
|
||||
): ResolvedCommandTarget {
|
||||
return {
|
||||
type: "command",
|
||||
name: "test-cmd",
|
||||
command: {
|
||||
exec: "echo",
|
||||
args: ["hello"],
|
||||
cwd: "/tmp",
|
||||
env: {},
|
||||
maxOutputBytes: 1024 * 1024,
|
||||
...command,
|
||||
},
|
||||
intervalMs: 60000,
|
||||
timeoutMs: 5000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("runCommandCheck", () => {
|
||||
test("exitCode=0 成功", async () => {
|
||||
const result = await runCommandCheck(makeTarget({ exec: "true", args: [] }));
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("exitCode=0");
|
||||
expect(result.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("exitCode=1 不匹配默认 [0]", async () => {
|
||||
const result = await runCommandCheck(makeTarget({ exec: "false", args: [] }));
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.statusDetail).toBe("exitCode=1");
|
||||
expect(result.failure).not.toBeNull();
|
||||
expect(result.failure!.phase).toBe("exitCode");
|
||||
});
|
||||
|
||||
test("exitCode=1 匹配自定义 [1]", async () => {
|
||||
const result = await runCommandCheck(makeTarget({ exec: "false", args: [] }, { expect: { exitCode: [1] } }));
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("exitCode=1");
|
||||
});
|
||||
|
||||
test("命令不存在返回 spawn 错误", async () => {
|
||||
const result = await runCommandCheck(makeTarget({ exec: "/nonexistent/command/xyz" }));
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.failure).not.toBeNull();
|
||||
expect(result.failure!.phase).toBe("exitCode");
|
||||
expect(result.failure!.message).toBeTruthy();
|
||||
});
|
||||
|
||||
test("超时返回错误", async () => {
|
||||
const result = await runCommandCheck(makeTarget({ exec: "sleep", args: ["10"] }, { timeoutMs: 100 }));
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.failure).not.toBeNull();
|
||||
expect(result.failure!.message).toContain("超时");
|
||||
});
|
||||
|
||||
test("stdout 输出捕获", async () => {
|
||||
const result = await runCommandCheck(makeTarget({ exec: "echo", args: ["hello world"] }));
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("stdout 匹配 expect", async () => {
|
||||
const result = await runCommandCheck(
|
||||
makeTarget({ exec: "echo", args: ["hello"] }, { expect: { stdout: [{ contains: "hello" }] } }),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("stdout 不匹配 expect", async () => {
|
||||
const result = await runCommandCheck(
|
||||
makeTarget({ exec: "echo", args: ["hello"] }, { expect: { stdout: [{ contains: "nonexistent" }] } }),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.failure!.phase).toBe("stdout");
|
||||
});
|
||||
|
||||
test("stderr 匹配 expect", async () => {
|
||||
const result = await runCommandCheck(
|
||||
makeTarget({ exec: "bash", args: ["-c", "echo error >&2"] }, { expect: { stderr: [{ contains: "error" }] } }),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("输出超过 maxOutputBytes", async () => {
|
||||
const result = await runCommandCheck(
|
||||
makeTarget({
|
||||
exec: "bash",
|
||||
args: ["-c", "yes | head -1000"],
|
||||
maxOutputBytes: 10,
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.failure).not.toBeNull();
|
||||
expect(result.failure!.message).toContain("超过限制");
|
||||
});
|
||||
|
||||
test("durationMs 非空", async () => {
|
||||
const result = await runCommandCheck(makeTarget({ exec: "true", args: [] }));
|
||||
expect(result.durationMs).not.toBeNull();
|
||||
expect(result.durationMs!).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test("ls 命令执行成功", async () => {
|
||||
const result = await runCommandCheck(makeTarget({ exec: "ls", args: ["/tmp"] }));
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.statusDetail).toBe("exitCode=0");
|
||||
});
|
||||
|
||||
test("不使用 shell,通配符不被展开", async () => {
|
||||
const result = await runCommandCheck(
|
||||
makeTarget({ exec: "echo", args: ["*"] }, { expect: { stdout: [{ contains: "*" }] } }),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("不提供 stdin,等待输入的命令会阻塞超时", async () => {
|
||||
const result = await runCommandCheck(makeTarget({ exec: "bash", args: ["-c", "read line"] }, { timeoutMs: 500 }));
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,66 @@ describe("loadConfig", () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("解析最简 HTTP 配置", async () => {
|
||||
const configPath = join(tempDir, "minimal-http.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "test"
|
||||
type: http
|
||||
http:
|
||||
url: "http://example.com"
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
expect(config.host).toBe("127.0.0.1");
|
||||
expect(config.port).toBe(3000);
|
||||
expect(config.dataDir).toBe("./data");
|
||||
expect(config.maxConcurrentChecks).toBe(20);
|
||||
expect(config.targets).toHaveLength(1);
|
||||
const t = config.targets[0]!;
|
||||
expect(t.type).toBe("http");
|
||||
if (t.type === "http") {
|
||||
expect(t.name).toBe("test");
|
||||
expect(t.http.url).toBe("http://example.com");
|
||||
expect(t.http.method).toBe("GET");
|
||||
expect(t.http.headers).toEqual({});
|
||||
expect(t.http.maxBodyBytes).toBe(104857600);
|
||||
expect(t.intervalMs).toBe(30000);
|
||||
expect(t.timeoutMs).toBe(10000);
|
||||
}
|
||||
});
|
||||
|
||||
test("解析最简 command 配置", async () => {
|
||||
const subdir = join(tempDir, "subdir");
|
||||
await mkdir(subdir, { recursive: true });
|
||||
const configPath = join(subdir, "cmd.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "check-nginx"
|
||||
type: command
|
||||
command:
|
||||
exec: "pgrep"
|
||||
args: ["nginx"]
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
expect(config.targets).toHaveLength(1);
|
||||
const t = config.targets[0]!;
|
||||
expect(t.type).toBe("command");
|
||||
if (t.type === "command") {
|
||||
expect(t.name).toBe("check-nginx");
|
||||
expect(t.command.exec).toBe("pgrep");
|
||||
expect(t.command.args).toEqual(["nginx"]);
|
||||
expect(t.command.cwd).toBe(subdir);
|
||||
expect(t.command.maxOutputBytes).toBe(104857600);
|
||||
expect(t.command.env.PATH).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("解析完整配置", async () => {
|
||||
const configPath = join(tempDir, "full.yaml");
|
||||
await writeFile(
|
||||
@@ -63,13 +123,36 @@ describe("loadConfig", () => {
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
dataDir: "./my-data"
|
||||
runtime:
|
||||
maxConcurrentChecks: 5
|
||||
defaults:
|
||||
interval: "15s"
|
||||
timeout: "5s"
|
||||
method: "POST"
|
||||
http:
|
||||
method: "POST"
|
||||
headers:
|
||||
Authorization: "Bearer token"
|
||||
maxBodyBytes: "50MB"
|
||||
command:
|
||||
cwd: "/tmp"
|
||||
maxOutputBytes: "10MB"
|
||||
targets:
|
||||
- name: "test"
|
||||
url: "http://example.com"
|
||||
- name: "http-target"
|
||||
type: http
|
||||
interval: "1m"
|
||||
http:
|
||||
url: "http://example.com"
|
||||
expect:
|
||||
status: [200]
|
||||
body:
|
||||
- contains: "ok"
|
||||
- name: "cmd-target"
|
||||
type: command
|
||||
command:
|
||||
exec: "ls"
|
||||
args: ["/tmp"]
|
||||
expect:
|
||||
exitCode: [0]
|
||||
`,
|
||||
);
|
||||
|
||||
@@ -77,39 +160,27 @@ targets:
|
||||
expect(config.host).toBe("0.0.0.0");
|
||||
expect(config.port).toBe(8080);
|
||||
expect(config.dataDir).toBe("./my-data");
|
||||
expect(config.targets).toHaveLength(1);
|
||||
expect(config.targets[0]).toEqual({
|
||||
name: "test",
|
||||
url: "http://example.com",
|
||||
method: "POST",
|
||||
headers: {},
|
||||
body: undefined,
|
||||
intervalMs: 15000,
|
||||
timeoutMs: 5000,
|
||||
expect: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("解析最简配置(只有 targets)", async () => {
|
||||
const configPath = join(tempDir, "minimal.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "t1"
|
||||
url: "http://a.com"
|
||||
- name: "t2"
|
||||
url: "http://b.com"
|
||||
interval: "1m"
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
expect(config.host).toBe("127.0.0.1");
|
||||
expect(config.port).toBe(3000);
|
||||
expect(config.dataDir).toBe("./data");
|
||||
expect(config.maxConcurrentChecks).toBe(5);
|
||||
expect(config.targets).toHaveLength(2);
|
||||
expect(config.targets[0]!.intervalMs).toBe(30000);
|
||||
expect(config.targets[1]!.intervalMs).toBe(60000);
|
||||
|
||||
const http = config.targets[0]!;
|
||||
expect(http.type).toBe("http");
|
||||
if (http.type === "http") {
|
||||
expect(http.http.url).toBe("http://example.com");
|
||||
expect(http.http.method).toBe("POST");
|
||||
expect(http.http.headers).toEqual({ Authorization: "Bearer token" });
|
||||
expect(http.http.maxBodyBytes).toBe(52428800);
|
||||
expect(http.intervalMs).toBe(60000);
|
||||
expect(http.timeoutMs).toBe(5000);
|
||||
}
|
||||
|
||||
const cmd = config.targets[1]!;
|
||||
expect(cmd.type).toBe("command");
|
||||
if (cmd.type === "command") {
|
||||
expect(cmd.command.exec).toBe("ls");
|
||||
expect(cmd.command.args).toEqual(["/tmp"]);
|
||||
expect(cmd.command.maxOutputBytes).toBe(10485760);
|
||||
}
|
||||
});
|
||||
|
||||
test("per-target 覆盖 defaults", async () => {
|
||||
@@ -119,26 +190,29 @@ targets:
|
||||
`defaults:
|
||||
interval: "30s"
|
||||
timeout: "10s"
|
||||
method: "GET"
|
||||
headers:
|
||||
Authorization: "Bearer token"
|
||||
http:
|
||||
method: "GET"
|
||||
maxBodyBytes: "10MB"
|
||||
targets:
|
||||
- name: "override-all"
|
||||
url: "http://example.com"
|
||||
method: "POST"
|
||||
type: http
|
||||
interval: "5m"
|
||||
timeout: "30s"
|
||||
headers:
|
||||
X-Custom: "value"
|
||||
http:
|
||||
url: "http://example.com"
|
||||
method: "POST"
|
||||
maxBodyBytes: "1MB"
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
const target = config.targets[0]!;
|
||||
expect(target.method).toBe("POST");
|
||||
expect(target.intervalMs).toBe(300000);
|
||||
expect(target.timeoutMs).toBe(30000);
|
||||
expect(target.headers).toEqual({ Authorization: "Bearer token", "X-Custom": "value" });
|
||||
const t = config.targets[0]!;
|
||||
if (t.type === "http") {
|
||||
expect(t.http.method).toBe("POST");
|
||||
expect(t.intervalMs).toBe(300000);
|
||||
expect(t.timeoutMs).toBe(30000);
|
||||
expect(t.http.maxBodyBytes).toBe(1048576);
|
||||
}
|
||||
});
|
||||
|
||||
test("配置文件不存在抛出错误", async () => {
|
||||
@@ -150,23 +224,63 @@ targets:
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- url: "http://example.com"
|
||||
- type: http
|
||||
http:
|
||||
url: "http://example.com"
|
||||
`,
|
||||
);
|
||||
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("缺少 name 字段");
|
||||
});
|
||||
|
||||
test("target 缺少 url 抛出错误", async () => {
|
||||
test("target 缺少 type 抛出错误", async () => {
|
||||
const configPath = join(tempDir, "no-type.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "test"
|
||||
http:
|
||||
url: "http://example.com"
|
||||
`,
|
||||
);
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("缺少 type 字段");
|
||||
});
|
||||
|
||||
test("HTTP target 缺少 url 抛出错误", async () => {
|
||||
const configPath = join(tempDir, "no-url.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "test"
|
||||
type: http
|
||||
http: {}
|
||||
`,
|
||||
);
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("缺少 http.url 字段");
|
||||
});
|
||||
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("缺少 url 字段");
|
||||
test("command target 缺少 exec 抛出错误", async () => {
|
||||
const configPath = join(tempDir, "no-exec.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "test"
|
||||
type: command
|
||||
command: {}
|
||||
`,
|
||||
);
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("缺少 command.exec 字段");
|
||||
});
|
||||
|
||||
test("非法 target type 抛出错误", async () => {
|
||||
const configPath = join(tempDir, "bad-type.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "test"
|
||||
type: dns
|
||||
`,
|
||||
);
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("不支持的 type");
|
||||
});
|
||||
|
||||
test("target name 重复抛出错误", async () => {
|
||||
@@ -175,19 +289,21 @@ targets:
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "dup"
|
||||
url: "http://a.com"
|
||||
type: http
|
||||
http:
|
||||
url: "http://a.com"
|
||||
- name: "dup"
|
||||
url: "http://b.com"
|
||||
type: http
|
||||
http:
|
||||
url: "http://b.com"
|
||||
`,
|
||||
);
|
||||
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("target name 重复");
|
||||
});
|
||||
|
||||
test("targets 为空数组抛出错误", async () => {
|
||||
const configPath = join(tempDir, "empty-targets.yaml");
|
||||
await writeFile(configPath, `targets: []`);
|
||||
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("至少一个 target");
|
||||
});
|
||||
|
||||
@@ -199,33 +315,168 @@ targets:
|
||||
port: 99999
|
||||
targets:
|
||||
- name: "t"
|
||||
url: "http://a.com"
|
||||
type: http
|
||||
http:
|
||||
url: "http://a.com"
|
||||
`,
|
||||
);
|
||||
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("无效端口号");
|
||||
});
|
||||
|
||||
test("非法 maxConcurrentChecks 抛出错误", async () => {
|
||||
const configPath = join(tempDir, "bad-concurrency.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`runtime:
|
||||
maxConcurrentChecks: -1
|
||||
targets:
|
||||
- name: "t"
|
||||
type: http
|
||||
http:
|
||||
url: "http://a.com"
|
||||
`,
|
||||
);
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("maxConcurrentChecks 必须为正整数");
|
||||
});
|
||||
|
||||
test("非法 size 格式抛出错误", async () => {
|
||||
const configPath = join(tempDir, "bad-size.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`defaults:
|
||||
http:
|
||||
maxBodyBytes: "100TB"
|
||||
targets:
|
||||
- name: "t"
|
||||
type: http
|
||||
http:
|
||||
url: "http://a.com"
|
||||
`,
|
||||
);
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("无效的 size 格式");
|
||||
});
|
||||
|
||||
test("非法 interval 格式抛出错误", async () => {
|
||||
const configPath = join(tempDir, "bad-interval.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "t"
|
||||
type: http
|
||||
interval: "30x"
|
||||
http:
|
||||
url: "http://a.com"
|
||||
`,
|
||||
);
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("无效的时长格式");
|
||||
});
|
||||
|
||||
test("解析 expect 配置", async () => {
|
||||
const configPath = join(tempDir, "expect.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "with-expect"
|
||||
url: "http://example.com"
|
||||
type: http
|
||||
http:
|
||||
url: "http://example.com"
|
||||
expect:
|
||||
status: [200, 201]
|
||||
body:
|
||||
contains: "ok"
|
||||
maxLatencyMs: 3000
|
||||
- contains: "ok"
|
||||
- json:
|
||||
path: "$.status"
|
||||
equals: "ok"
|
||||
maxDurationMs: 3000
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
expect(config.targets[0]!.expect).toEqual({
|
||||
status: [200, 201],
|
||||
body: { contains: "ok" },
|
||||
maxLatencyMs: 3000,
|
||||
});
|
||||
const t = config.targets[0]!;
|
||||
if (t.type === "http") {
|
||||
expect(t.expect).toEqual({
|
||||
status: [200, 201],
|
||||
body: [{ contains: "ok" }, { json: { path: "$.status", equals: "ok" } }],
|
||||
maxDurationMs: 3000,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("解析 command expect 配置", async () => {
|
||||
const configPath = join(tempDir, "cmd-expect.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "cmd-with-expect"
|
||||
type: command
|
||||
command:
|
||||
exec: "mycheck"
|
||||
expect:
|
||||
exitCode: [0, 2]
|
||||
stdout:
|
||||
- contains: "ok"
|
||||
- match: "done"
|
||||
stderr:
|
||||
- empty: true
|
||||
maxDurationMs: 5000
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
const t = config.targets[0]!;
|
||||
if (t.type === "command") {
|
||||
expect(t.expect).toEqual({
|
||||
exitCode: [0, 2],
|
||||
stdout: [{ contains: "ok" }, { match: "done" }],
|
||||
stderr: [{ empty: true }],
|
||||
maxDurationMs: 5000,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("command cwd 相对配置文件目录", async () => {
|
||||
const subdir = join(tempDir, "cwd-test");
|
||||
await mkdir(subdir, { recursive: true });
|
||||
const configPath = join(subdir, "cwd.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "cwd-test"
|
||||
type: command
|
||||
command:
|
||||
exec: "ls"
|
||||
cwd: "scripts"
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
const t = config.targets[0]!;
|
||||
if (t.type === "command") {
|
||||
expect(t.command.cwd).toBe(join(subdir, "scripts"));
|
||||
}
|
||||
});
|
||||
|
||||
test("command env 覆盖", async () => {
|
||||
const configPath = join(tempDir, "env.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "env-test"
|
||||
type: command
|
||||
command:
|
||||
exec: "echo"
|
||||
env:
|
||||
LANG: "C"
|
||||
CUSTOM_VAR: "test"
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
const t = config.targets[0]!;
|
||||
if (t.type === "command") {
|
||||
expect(t.command.env.LANG).toBe("C");
|
||||
expect(t.command.env.CUSTOM_VAR).toBe("test");
|
||||
expect(t.command.env.PATH).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,96 +1,204 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { ProbeStore } from "../../../src/server/checker/store";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { ProbeEngine } from "../../../src/server/checker/engine";
|
||||
import type { ResolvedTarget } from "../../../src/server/checker/types";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { ProbeStore } from "../../../src/server/checker/store";
|
||||
import type { ResolvedCommandTarget, ResolvedHttpTarget, ResolvedTarget } from "../../../src/server/checker/types";
|
||||
|
||||
function createMockStore(targetNames: string[]) {
|
||||
let nextId = 1;
|
||||
const targets = targetNames.map((name) => ({ id: nextId++, name }));
|
||||
const results: Array<Record<string, unknown>> = [];
|
||||
|
||||
return {
|
||||
getTargets() {
|
||||
return targets.map(({ id, name }) => ({
|
||||
id,
|
||||
name,
|
||||
type: "command" as const,
|
||||
target: "",
|
||||
config: "",
|
||||
interval_ms: 60000,
|
||||
timeout_ms: 5000,
|
||||
expect: null,
|
||||
}));
|
||||
},
|
||||
insertCheckResult(result: Record<string, unknown>) {
|
||||
results.push(result);
|
||||
},
|
||||
_results: results,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCommandTarget(name: string, overrides?: Partial<ResolvedCommandTarget>): ResolvedCommandTarget {
|
||||
return {
|
||||
type: "command",
|
||||
name,
|
||||
command: {
|
||||
exec: "echo",
|
||||
args: ["hello"],
|
||||
cwd: "/tmp",
|
||||
env: {},
|
||||
maxOutputBytes: 1024 * 1024,
|
||||
},
|
||||
intervalMs: 60000,
|
||||
timeoutMs: 5000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ProbeEngine", () => {
|
||||
let tempDir: string;
|
||||
let store: ProbeStore;
|
||||
|
||||
const target: ResolvedTarget = {
|
||||
name: "httpbin",
|
||||
url: "https://httpbin.org/get",
|
||||
method: "GET",
|
||||
headers: {},
|
||||
intervalMs: 60000,
|
||||
timeoutMs: 10000,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDir = join(tmpdir(), `gc-engine-test-${Date.now()}`);
|
||||
await mkdir(tempDir, { recursive: true });
|
||||
store = new ProbeStore(join(tempDir, "test.db"));
|
||||
store.syncTargets([target]);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
store.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("groupByInterval 分组逻辑", () => {
|
||||
const targets: ResolvedTarget[] = [
|
||||
{ name: "a", url: "http://a.com", method: "GET", headers: {}, intervalMs: 30000, timeoutMs: 10000 },
|
||||
{ name: "b", url: "http://b.com", method: "GET", headers: {}, intervalMs: 30000, timeoutMs: 10000 },
|
||||
{ name: "c", url: "http://c.com", method: "GET", headers: {}, intervalMs: 60000, timeoutMs: 10000 },
|
||||
];
|
||||
|
||||
const engine = new ProbeEngine(store, targets);
|
||||
engine.start();
|
||||
engine.stop();
|
||||
|
||||
// 只要能启动和停止不出错就行
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
test("engine start/stop 不抛错", () => {
|
||||
const engine = new ProbeEngine(store, [target]);
|
||||
test("start/stop 不抛错", () => {
|
||||
const mockStore = createMockStore(["test"]) as unknown as ProbeStore;
|
||||
const targets: ResolvedTarget[] = [makeCommandTarget("test")];
|
||||
const engine = new ProbeEngine(mockStore, targets);
|
||||
engine.start();
|
||||
engine.stop();
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
test("单次拨测写入数据库", async () => {
|
||||
const engine = new ProbeEngine(store, [target]);
|
||||
// 手动调用 probeGroup 不启动 timer
|
||||
test("单次 probeGroup 执行 command 检查", async () => {
|
||||
const target = makeCommandTarget("cmd-echo");
|
||||
const mockStore = createMockStore(["cmd-echo"]) as unknown as ProbeStore;
|
||||
const engine = new ProbeEngine(mockStore, [target]);
|
||||
|
||||
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(
|
||||
engine,
|
||||
);
|
||||
await probeGroup([target]);
|
||||
|
||||
const dbTargets = store.getTargets();
|
||||
const latest = store.getLatestCheck(dbTargets[0]!.id);
|
||||
expect(latest).not.toBeNull();
|
||||
expect(latest!.success === 1 || latest!.success === 0).toBe(true);
|
||||
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]!.success).toBe(true);
|
||||
expect(results[0]!.matched).toBe(true);
|
||||
expect(results[0]!.statusDetail).toBe("exitCode=0");
|
||||
});
|
||||
|
||||
test("单目标失败隔离", async () => {
|
||||
const badTarget: ResolvedTarget = {
|
||||
name: "bad-target",
|
||||
url: "http://127.0.0.1:1/impossible",
|
||||
method: "GET",
|
||||
headers: {},
|
||||
intervalMs: 60000,
|
||||
timeoutMs: 2000,
|
||||
};
|
||||
test("多个目标并发执行", async () => {
|
||||
const targetA = makeCommandTarget("echo-a", {
|
||||
command: { exec: "echo", args: ["a"], cwd: "/tmp", env: {}, maxOutputBytes: 1024 * 1024 },
|
||||
});
|
||||
const targetB = makeCommandTarget("echo-b", {
|
||||
command: { exec: "echo", args: ["b"], cwd: "/tmp", env: {}, maxOutputBytes: 1024 * 1024 },
|
||||
});
|
||||
|
||||
store.syncTargets([target, badTarget]);
|
||||
const mockStore = createMockStore(["echo-a", "echo-b"]) as unknown as ProbeStore;
|
||||
const engine = new ProbeEngine(mockStore, [targetA, targetB]);
|
||||
|
||||
const engine = new ProbeEngine(store, [target, badTarget]);
|
||||
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(
|
||||
engine,
|
||||
);
|
||||
await probeGroup([target, badTarget]);
|
||||
await probeGroup([targetA, targetB]);
|
||||
|
||||
const dbTargets = store.getTargets();
|
||||
const goodResult = store.getLatestCheck(dbTargets.find((t) => t.name === "httpbin")!.id);
|
||||
const badResult = store.getLatestCheck(dbTargets.find((t) => t.name === "bad-target")!.id);
|
||||
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
||||
expect(results.length).toBe(2);
|
||||
});
|
||||
|
||||
expect(goodResult).not.toBeNull();
|
||||
expect(badResult).not.toBeNull();
|
||||
expect(badResult!.success).toBe(0);
|
||||
test("失败目标不阻塞其他目标", async () => {
|
||||
const badTarget = makeCommandTarget("bad-cmd", {
|
||||
command: { exec: "false", args: [], cwd: "/tmp", env: {}, maxOutputBytes: 1024 * 1024 },
|
||||
});
|
||||
const goodTarget = makeCommandTarget("good-cmd");
|
||||
|
||||
const mockStore = createMockStore(["bad-cmd", "good-cmd"]) as unknown as ProbeStore;
|
||||
const engine = new ProbeEngine(mockStore, [badTarget, goodTarget]);
|
||||
|
||||
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(
|
||||
engine,
|
||||
);
|
||||
await probeGroup([badTarget, goodTarget]);
|
||||
|
||||
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
||||
expect(results.length).toBe(2);
|
||||
|
||||
const badResult = results.find((r) => r.success === false);
|
||||
const goodResult = results.find((r) => r.success === true);
|
||||
expect(badResult).toBeDefined();
|
||||
expect(goodResult).toBeDefined();
|
||||
});
|
||||
|
||||
test("并发限制 maxConcurrentChecks", async () => {
|
||||
const targets = Array.from({ length: 5 }, (_, i) =>
|
||||
makeCommandTarget(`cmd-${i}`, {
|
||||
command: { exec: "echo", args: [String(i)], cwd: "/tmp", env: {}, maxOutputBytes: 1024 * 1024 },
|
||||
}),
|
||||
);
|
||||
|
||||
const mockStore = createMockStore(targets.map((t) => t.name)) as unknown as ProbeStore;
|
||||
const engine = new ProbeEngine(mockStore, targets, 2);
|
||||
|
||||
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(
|
||||
engine,
|
||||
);
|
||||
await probeGroup(targets);
|
||||
|
||||
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
||||
expect(results.length).toBe(5);
|
||||
for (const r of results) {
|
||||
expect(r.success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("groupByInterval 按间隔分组", () => {
|
||||
const targetA = makeCommandTarget("a", { intervalMs: 30000 });
|
||||
const targetB = makeCommandTarget("b", { intervalMs: 30000 });
|
||||
const targetC = makeCommandTarget("c", { intervalMs: 60000 });
|
||||
|
||||
const mockStore = createMockStore(["a", "b", "c"]) as unknown as ProbeStore;
|
||||
const engine = new ProbeEngine(mockStore, [targetA, targetB, targetC]);
|
||||
engine.start();
|
||||
engine.stop();
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
test("未注册的 targetName 不写入结果", async () => {
|
||||
const target = makeCommandTarget("unknown-target");
|
||||
const mockStore = createMockStore(["other-name"]) as unknown as ProbeStore;
|
||||
const engine = new ProbeEngine(mockStore, [target]);
|
||||
|
||||
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(
|
||||
engine,
|
||||
);
|
||||
await probeGroup([target]);
|
||||
|
||||
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
|
||||
test("HTTP 目标运行", async () => {
|
||||
const httpServer = Bun.serve({
|
||||
port: 0,
|
||||
fetch() {
|
||||
return new Response("ok");
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const httpTarget: ResolvedHttpTarget = {
|
||||
type: "http",
|
||||
name: "http-test",
|
||||
http: {
|
||||
url: `http://localhost:${httpServer.port}/`,
|
||||
method: "GET",
|
||||
headers: {},
|
||||
maxBodyBytes: 1024 * 1024,
|
||||
},
|
||||
intervalMs: 60000,
|
||||
timeoutMs: 5000,
|
||||
};
|
||||
|
||||
const mockStore = createMockStore(["http-test"]) as unknown as ProbeStore;
|
||||
const engine = new ProbeEngine(mockStore, [httpTarget]);
|
||||
|
||||
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(
|
||||
engine,
|
||||
);
|
||||
await probeGroup([httpTarget]);
|
||||
|
||||
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]!.success).toBe(true);
|
||||
expect(results[0]!.statusDetail).toBe("HTTP 200");
|
||||
} finally {
|
||||
httpServer.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { applyOperator, checkBodyExpect, evaluateJsonPath } from "../../../src/server/checker/body-expect";
|
||||
import {
|
||||
applyOperator,
|
||||
checkBodyExpect,
|
||||
checkExpectValue,
|
||||
evaluateJsonPath,
|
||||
} from "../../../../src/server/checker/expect/body";
|
||||
|
||||
describe("evaluateJsonPath", () => {
|
||||
const obj = {
|
||||
@@ -125,106 +130,158 @@ describe("applyOperator", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkBodyExpect", () => {
|
||||
test("无 body config 返回 true", () => {
|
||||
expect(checkBodyExpect("anything", undefined)).toBe(true);
|
||||
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);
|
||||
});
|
||||
|
||||
test("contains 匹配", () => {
|
||||
expect(checkBodyExpect("hello world", { contains: "hello" })).toBe(true);
|
||||
expect(checkBodyExpect("hello world", { contains: "missing" })).toBe(false);
|
||||
test("对象作为操作符", () => {
|
||||
expect(checkExpectValue(42, { gte: 10 })).toBe(true);
|
||||
expect(checkExpectValue(42, { gte: 100 })).toBe(false);
|
||||
expect(checkExpectValue("hello", { contains: "ell" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkBodyExpect (BodyRule[])", () => {
|
||||
test("无规则返回匹配成功", () => {
|
||||
const r = checkBodyExpect("anything");
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("regex 匹配", () => {
|
||||
expect(checkBodyExpect("status: ok", { regex: "ok" })).toBe(true);
|
||||
expect(checkBodyExpect("status: error", { regex: "ok" })).toBe(false);
|
||||
test("空规则数组返回匹配成功", () => {
|
||||
const r = checkBodyExpect("anything", []);
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("json 简单等值匹配", () => {
|
||||
test("contains 规则匹配成功", () => {
|
||||
const r = checkBodyExpect("hello world", [{ contains: "hello" }]);
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("contains 规则匹配失败", () => {
|
||||
const r = checkBodyExpect("hello world", [{ contains: "missing" }]);
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure).not.toBeNull();
|
||||
expect(r.failure!.kind).toBe("mismatch");
|
||||
expect(r.failure!.phase).toBe("body");
|
||||
expect(r.failure!.path).toBe("body[0]");
|
||||
});
|
||||
|
||||
test("regex 规则匹配成功", () => {
|
||||
const r = checkBodyExpect("status: ok", [{ regex: "ok" }]);
|
||||
expect(r.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("regex 规则匹配失败", () => {
|
||||
const r = checkBodyExpect("status: error", [{ regex: "^ok$" }]);
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.path).toBe("body[0]");
|
||||
});
|
||||
|
||||
test("json 等值匹配成功", () => {
|
||||
const body = JSON.stringify({ status: "ok", code: 0 });
|
||||
expect(checkBodyExpect(body, { json: { "$.status": "ok" } })).toBe(true);
|
||||
expect(checkBodyExpect(body, { json: { "$.code": 0 } })).toBe(true);
|
||||
expect(checkBodyExpect(body, { json: { "$.status": "error" } })).toBe(false);
|
||||
const r = checkBodyExpect(body, [{ json: { path: "$.status", equals: "ok" } }]);
|
||||
expect(r.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("json 等值匹配失败", () => {
|
||||
const body = JSON.stringify({ status: "ok" });
|
||||
const r = checkBodyExpect(body, [{ json: { path: "$.status", equals: "error" } }]);
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.kind).toBe("mismatch");
|
||||
});
|
||||
|
||||
test("json 操作符匹配", () => {
|
||||
const body = JSON.stringify({ count: 42, version: "v2.1.0", message: "success" });
|
||||
expect(checkBodyExpect(body, { json: { "$.count": { gte: 10 } } })).toBe(true);
|
||||
expect(checkBodyExpect(body, { json: { "$.version": { match: "\\d+\\.\\d+\\.\\d+" } } })).toBe(true);
|
||||
expect(checkBodyExpect(body, { json: { "$.message": { contains: "success" } } })).toBe(true);
|
||||
expect(checkBodyExpect(body, { json: { "$.count": { gte: 100 } } })).toBe(false);
|
||||
const body = JSON.stringify({ count: 42, version: "v2.1.0" });
|
||||
expect(checkBodyExpect(body, [{ json: { path: "$.count", gte: 10 } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(body, [{ json: { path: "$.version", match: "\\d+\\.\\d+\\.\\d+" } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(body, [{ json: { path: "$.count", gte: 100 } }]).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("json 路径不存在", () => {
|
||||
const body = JSON.stringify({ status: "ok" });
|
||||
expect(checkBodyExpect(body, { json: { "$.notExist": "value" } })).toBe(false);
|
||||
const r = checkBodyExpect(body, [{ json: { path: "$.notExist", equals: "value" } }]);
|
||||
expect(r.matched).toBe(false);
|
||||
});
|
||||
|
||||
test("json 解析失败", () => {
|
||||
expect(checkBodyExpect("not json", { json: { "$.status": "ok" } })).toBe(false);
|
||||
const r = checkBodyExpect("not json", [{ json: { path: "$.status", equals: "ok" } }]);
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.kind).toBe("error");
|
||||
});
|
||||
|
||||
test("css textContent 匹配", () => {
|
||||
test("css 文本内容匹配", () => {
|
||||
const html = "<div id='health'>OK</div><span class='ver'>1.0</span>";
|
||||
expect(checkBodyExpect(html, { css: { "div#health": "OK" } })).toBe(true);
|
||||
expect(checkBodyExpect(html, { css: { "span.ver": "1.0" } })).toBe(true);
|
||||
expect(checkBodyExpect(html, { css: { "div#health": "ERROR" } })).toBe(false);
|
||||
expect(checkBodyExpect(html, [{ css: { selector: "div#health", equals: "OK" } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(html, [{ css: { selector: "span.ver", equals: "1.0" } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(html, [{ css: { selector: "div#health", equals: "ERROR" } }]).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("css 选择器无匹配元素", () => {
|
||||
const html = "<div>OK</div>";
|
||||
expect(checkBodyExpect(html, { css: { "span.missing": "OK" } })).toBe(false);
|
||||
const r = checkBodyExpect(html, [{ css: { selector: "span.missing", equals: "OK" } }]);
|
||||
expect(r.matched).toBe(false);
|
||||
});
|
||||
|
||||
test("css attr 提取", () => {
|
||||
const html = '<meta name="version" content="2.0.1"><link rel="icon" href="/favicon.ico">';
|
||||
expect(checkBodyExpect(html, { css: { 'meta[name="version"]': { attr: "content", equals: "2.0.1" } } })).toBe(true);
|
||||
const html = '<meta name="version" content="2.0.1">';
|
||||
expect(
|
||||
checkBodyExpect(html, { css: { 'meta[name="version"]': { attr: "content", match: "\\d+\\.\\d+\\.\\d+" } } }),
|
||||
checkBodyExpect(html, [{ css: { selector: 'meta[name="version"]', attr: "content", equals: "2.0.1" } }]).matched,
|
||||
).toBe(true);
|
||||
expect(
|
||||
checkBodyExpect(html, [
|
||||
{ css: { selector: 'meta[name="version"]', attr: "content", match: "\\d+\\.\\d+\\.\\d+" } },
|
||||
]).matched,
|
||||
).toBe(true);
|
||||
expect(checkBodyExpect(html, { css: { 'link[rel="icon"]': { attr: "href", contains: "favicon" } } })).toBe(true);
|
||||
});
|
||||
|
||||
test("css exists 检查", () => {
|
||||
const html = "<div id='test'>OK</div>";
|
||||
expect(checkBodyExpect(html, { css: { "div#test": { exists: true } } })).toBe(true);
|
||||
expect(checkBodyExpect(html, { css: { "span#missing": { exists: false } } })).toBe(true);
|
||||
expect(checkBodyExpect(html, { css: { "div#test": { exists: false } } })).toBe(false);
|
||||
expect(checkBodyExpect(html, [{ css: { selector: "div#test", exists: true } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(html, [{ css: { selector: "span#missing", exists: false } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(html, [{ css: { selector: "div#test", exists: false } }]).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("xpath 节点文本匹配", () => {
|
||||
const xml = "<root><status>ok</status><code>200</code></root>";
|
||||
expect(checkBodyExpect(xml, { xpath: { "/root/status/text()": "ok" } })).toBe(true);
|
||||
expect(checkBodyExpect(xml, { xpath: { "/root/status/text()": "error" } })).toBe(false);
|
||||
expect(checkBodyExpect(xml, [{ xpath: { path: "/root/status/text()", equals: "ok" } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(xml, [{ xpath: { path: "/root/status/text()", equals: "error" } }]).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("xpath 无匹配节点", () => {
|
||||
const xml = "<root><status>ok</status></root>";
|
||||
expect(checkBodyExpect(xml, { xpath: { "/root/missing/text()": "ok" } })).toBe(false);
|
||||
const r = checkBodyExpect(xml, [{ xpath: { path: "/root/missing/text()", equals: "ok" } }]);
|
||||
expect(r.matched).toBe(false);
|
||||
});
|
||||
|
||||
test("xpath 包含匹配", () => {
|
||||
const html = "<html><body><div id='msg'>success</div></body></html>";
|
||||
expect(checkBodyExpect(html, { xpath: { "//div[@id='msg']/text()": "success" } })).toBe(true);
|
||||
});
|
||||
|
||||
test("多种 body 方法 AND 组合", () => {
|
||||
const body = JSON.stringify({ status: "healthy", count: 5 });
|
||||
expect(
|
||||
checkBodyExpect(body, {
|
||||
contains: "healthy",
|
||||
json: { "$.status": "healthy", "$.count": { gte: 1 } },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("多种 body 方法部分失败", () => {
|
||||
test("规则数组按顺序检查,第一条失败立即返回", () => {
|
||||
const body = JSON.stringify({ status: "error" });
|
||||
expect(
|
||||
checkBodyExpect(body, {
|
||||
contains: "healthy",
|
||||
json: { "$.status": "error" },
|
||||
}),
|
||||
).toBe(false);
|
||||
const r = checkBodyExpect(body, [{ contains: "healthy" }, { json: { path: "$.status", equals: "error" } }]);
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.path).toBe("body[0]");
|
||||
});
|
||||
|
||||
test("多条规则全部通过", () => {
|
||||
const body = JSON.stringify({ status: "healthy", count: 5 });
|
||||
const r = checkBodyExpect(body, [
|
||||
{ contains: "healthy" },
|
||||
{ json: { path: "$.status", equals: "healthy" } },
|
||||
{ json: { path: "$.count", gte: 1 } },
|
||||
]);
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("第二条规则失败返回正确索引", () => {
|
||||
const body = JSON.stringify({ status: "ok" });
|
||||
const r = checkBodyExpect(body, [{ contains: "ok" }, { json: { path: "$.status", equals: "error" } }]);
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.path).toContain("body[1]");
|
||||
});
|
||||
});
|
||||
168
tests/server/checker/expect/command.test.ts
Normal file
168
tests/server/checker/expect/command.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { checkCommandExpect } from "../../../../src/server/checker/expect/command";
|
||||
import type { CommandObservation } from "../../../../src/server/checker/expect/command";
|
||||
import type { CommandExpectConfig } from "../../../../src/server/checker/types";
|
||||
|
||||
function obs(overrides: Partial<CommandObservation> = {}): CommandObservation {
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
durationMs: 100,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("checkCommandExpect", () => {
|
||||
test("无 expect 配置时默认检查 exitCode [0] 匹配成功", () => {
|
||||
const r = checkCommandExpect(obs());
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("无 expect 配置时 exitCode 非 0 匹配失败", () => {
|
||||
const r = checkCommandExpect(obs({ exitCode: 1 }));
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure).not.toBeNull();
|
||||
expect(r.failure!.phase).toBe("exitCode");
|
||||
expect(r.failure!.kind).toBe("mismatch");
|
||||
});
|
||||
|
||||
test("exitCode 匹配指定退出码", () => {
|
||||
const cfg: CommandExpectConfig = { exitCode: [0, 1] };
|
||||
expect(checkCommandExpect(obs({ exitCode: 0 }), cfg).matched).toBe(true);
|
||||
expect(checkCommandExpect(obs({ exitCode: 1 }), cfg).matched).toBe(true);
|
||||
expect(checkCommandExpect(obs({ exitCode: 2 }), cfg).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("exitCode 不匹配返回 phase=exitCode 的失败", () => {
|
||||
const r = checkCommandExpect(obs({ exitCode: 2 }), { exitCode: [0] });
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("exitCode");
|
||||
expect(r.failure!.expected).toEqual([0]);
|
||||
expect(r.failure!.actual).toBe(2);
|
||||
});
|
||||
|
||||
test("duration 在限制内匹配成功", () => {
|
||||
const r = checkCommandExpect(obs({ durationMs: 50 }), { maxDurationMs: 100 });
|
||||
expect(r.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("duration 超过限制匹配失败", () => {
|
||||
const r = checkCommandExpect(obs({ durationMs: 200 }), { maxDurationMs: 100 });
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("duration");
|
||||
});
|
||||
|
||||
test("stdout TextRule 数组匹配", () => {
|
||||
const o = obs({ stdout: "build completed successfully" });
|
||||
expect(checkCommandExpect(o, { stdout: [{ contains: "completed" }] }).matched).toBe(true);
|
||||
expect(checkCommandExpect(o, { stdout: [{ contains: "failed" }] }).matched).toBe(false);
|
||||
expect(checkCommandExpect(o, { stdout: [{ match: "completed.*successfully$" }] }).matched).toBe(true);
|
||||
});
|
||||
|
||||
test("stdout 多条规则全部通过", () => {
|
||||
const o = obs({ stdout: "version: 3.2.1, build: ok" });
|
||||
const r = checkCommandExpect(o, {
|
||||
stdout: [{ contains: "version" }, { match: "\\d+\\.\\d+\\.\\d+" }],
|
||||
});
|
||||
expect(r.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("stdout 第一条规则失败立即返回", () => {
|
||||
const o = obs({ stdout: "error occurred" });
|
||||
const r = checkCommandExpect(o, {
|
||||
stdout: [{ contains: "success" }, { contains: "error" }],
|
||||
});
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("stdout");
|
||||
expect(r.failure!.path).toBe("stdout[0]");
|
||||
});
|
||||
|
||||
test("stderr TextRule 数组匹配", () => {
|
||||
const o = obs({ stderr: "warning: deprecated" });
|
||||
expect(checkCommandExpect(o, { stderr: [{ contains: "warning" }] }).matched).toBe(true);
|
||||
expect(checkCommandExpect(o, { stderr: [{ contains: "error" }] }).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("stdout 失败阻止 stderr 检查", () => {
|
||||
const o = obs({ stdout: "bad output", stderr: "warning message" });
|
||||
const r = checkCommandExpect(o, {
|
||||
exitCode: [0],
|
||||
stdout: [{ contains: "success" }],
|
||||
stderr: [{ contains: "warning" }],
|
||||
});
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("stdout");
|
||||
});
|
||||
|
||||
test("stdout 通过但 stderr 失败", () => {
|
||||
const o = obs({ stdout: "ok", stderr: "fatal error" });
|
||||
const r = checkCommandExpect(o, {
|
||||
stdout: [{ contains: "ok" }],
|
||||
stderr: [{ equals: "clean" }],
|
||||
});
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("stderr");
|
||||
});
|
||||
|
||||
test("完整流水线 exitCode->duration->stdout->stderr 全部通过", () => {
|
||||
const o = obs({
|
||||
exitCode: 0,
|
||||
durationMs: 50,
|
||||
stdout: "build success",
|
||||
stderr: "",
|
||||
});
|
||||
const r = checkCommandExpect(o, {
|
||||
exitCode: [0],
|
||||
maxDurationMs: 100,
|
||||
stdout: [{ contains: "success" }],
|
||||
stderr: [{ empty: true }],
|
||||
});
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("完整流水线 exitCode 通过但 duration 失败", () => {
|
||||
const o = obs({ exitCode: 0, durationMs: 500 });
|
||||
const r = checkCommandExpect(o, {
|
||||
exitCode: [0],
|
||||
maxDurationMs: 100,
|
||||
stdout: [{ contains: "ok" }],
|
||||
});
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("duration");
|
||||
});
|
||||
|
||||
test("完整流水线 exitCode/duration 通过但 stdout 失败", () => {
|
||||
const o = obs({ exitCode: 0, durationMs: 50, stdout: "error" });
|
||||
const r = checkCommandExpect(o, {
|
||||
exitCode: [0],
|
||||
maxDurationMs: 100,
|
||||
stdout: [{ contains: "success" }],
|
||||
});
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("stdout");
|
||||
});
|
||||
|
||||
test("完整流水线 exitCode/duration/stdout 通过但 stderr 失败", () => {
|
||||
const o = obs({ exitCode: 0, durationMs: 50, stdout: "ok", stderr: "warning" });
|
||||
const r = checkCommandExpect(o, {
|
||||
exitCode: [0],
|
||||
maxDurationMs: 100,
|
||||
stdout: [{ contains: "ok" }],
|
||||
stderr: [{ empty: true }],
|
||||
});
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("stderr");
|
||||
});
|
||||
|
||||
test("stdout 操作符组合", () => {
|
||||
const o = obs({ stdout: "count: 42" });
|
||||
expect(
|
||||
checkCommandExpect(o, {
|
||||
stdout: [{ contains: "count" }, { match: "\\d+" }],
|
||||
}).matched,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
79
tests/server/checker/expect/failure.test.ts
Normal file
79
tests/server/checker/expect/failure.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { truncateActual, mismatchFailure, errorFailure } from "../../../../src/server/checker/expect/failure";
|
||||
|
||||
describe("truncateActual", () => {
|
||||
test("短字符串不截断", () => {
|
||||
expect(truncateActual("hello")).toBe("hello");
|
||||
});
|
||||
|
||||
test("恰好等于限制长度不截断", () => {
|
||||
const str = "a".repeat(200);
|
||||
expect(truncateActual(str)).toBe(str);
|
||||
});
|
||||
|
||||
test("超过限制长度截断并加省略号", () => {
|
||||
const str = "a".repeat(300);
|
||||
const result = truncateActual(str) as string;
|
||||
expect(result.length).toBe(203);
|
||||
expect(result.endsWith("...")).toBe(true);
|
||||
expect(result.startsWith("a".repeat(200))).toBe(true);
|
||||
});
|
||||
|
||||
test("自定义最大长度", () => {
|
||||
const str = "abcdefghij";
|
||||
const result = truncateActual(str, 5) as string;
|
||||
expect(result).toBe("abcde...");
|
||||
});
|
||||
|
||||
test("null 不截断", () => {
|
||||
expect(truncateActual(null)).toBe(null);
|
||||
});
|
||||
|
||||
test("undefined 不截断", () => {
|
||||
expect(truncateActual(undefined)).toBe(undefined);
|
||||
});
|
||||
|
||||
test("数字转换为字符串后判断", () => {
|
||||
expect(truncateActual(42)).toBe(42);
|
||||
expect(truncateActual(123456789, 3) as string).toBe("123...");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mismatchFailure", () => {
|
||||
test("返回正确的 mismatch 结构", () => {
|
||||
const f = mismatchFailure("status", "status", [200], 500, "status mismatch");
|
||||
expect(f).toEqual({
|
||||
kind: "mismatch",
|
||||
phase: "status",
|
||||
path: "status",
|
||||
expected: [200],
|
||||
actual: 500,
|
||||
message: "status mismatch",
|
||||
});
|
||||
});
|
||||
|
||||
test("自动截断过长的 actual", () => {
|
||||
const longStr = "x".repeat(300);
|
||||
const f = mismatchFailure("body", "body[0]", "short", longStr, "too long");
|
||||
expect((f.actual as string).endsWith("...")).toBe(true);
|
||||
expect((f.actual as string).length).toBe(203);
|
||||
});
|
||||
});
|
||||
|
||||
describe("errorFailure", () => {
|
||||
test("返回正确的 error 结构", () => {
|
||||
const f = errorFailure("body", "body[0].json($.x)", "body is not valid JSON");
|
||||
expect(f).toEqual({
|
||||
kind: "error",
|
||||
phase: "body",
|
||||
path: "body[0].json($.x)",
|
||||
message: "body is not valid JSON",
|
||||
});
|
||||
});
|
||||
|
||||
test("不含 expected 和 actual 字段", () => {
|
||||
const f = errorFailure("headers", "headers.x", "header missing");
|
||||
expect(f).not.toHaveProperty("expected");
|
||||
expect(f).not.toHaveProperty("actual");
|
||||
});
|
||||
});
|
||||
165
tests/server/checker/expect/http.test.ts
Normal file
165
tests/server/checker/expect/http.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { checkHttpExpect } from "../../../../src/server/checker/expect/http";
|
||||
import type { HttpObservation } from "../../../../src/server/checker/expect/http";
|
||||
import type { HttpExpectConfig } from "../../../../src/server/checker/types";
|
||||
|
||||
function obs(overrides: Partial<HttpObservation> = {}): HttpObservation {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
body: "",
|
||||
durationMs: 100,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("checkHttpExpect", () => {
|
||||
test("无 expect 配置时默认检查 status [200] 匹配成功", () => {
|
||||
const r = checkHttpExpect(obs());
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("无 expect 配置时 status 非 200 匹配失败", () => {
|
||||
const r = checkHttpExpect(obs({ statusCode: 500 }));
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure).not.toBeNull();
|
||||
expect(r.failure!.phase).toBe("status");
|
||||
expect(r.failure!.kind).toBe("mismatch");
|
||||
});
|
||||
|
||||
test("status 匹配指定状态码", () => {
|
||||
const cfg: HttpExpectConfig = { status: [200, 301] };
|
||||
expect(checkHttpExpect(obs({ statusCode: 200 }), cfg).matched).toBe(true);
|
||||
expect(checkHttpExpect(obs({ statusCode: 301 }), cfg).matched).toBe(true);
|
||||
expect(checkHttpExpect(obs({ statusCode: 404 }), cfg).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("status 不匹配返回 phase=status 的失败", () => {
|
||||
const r = checkHttpExpect(obs({ statusCode: 503 }), { status: [200] });
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("status");
|
||||
expect(r.failure!.expected).toEqual([200]);
|
||||
expect(r.failure!.actual).toBe(503);
|
||||
});
|
||||
|
||||
test("duration 在限制内匹配成功", () => {
|
||||
const r = checkHttpExpect(obs({ durationMs: 50 }), { maxDurationMs: 100 });
|
||||
expect(r.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("duration 超过限制匹配失败", () => {
|
||||
const r = checkHttpExpect(obs({ durationMs: 200 }), { maxDurationMs: 100 });
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("duration");
|
||||
});
|
||||
|
||||
test("duration 恰好等于限制匹配成功", () => {
|
||||
const r = checkHttpExpect(obs({ durationMs: 100 }), { maxDurationMs: 100 });
|
||||
expect(r.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("headers 字符串格式检查(等于)", () => {
|
||||
const o = obs({ headers: { "content-type": "application/json", "x-api": "v1" } });
|
||||
expect(checkHttpExpect(o, { headers: { "content-type": "application/json" } }).matched).toBe(true);
|
||||
expect(checkHttpExpect(o, { headers: { "content-type": "text/html" } }).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("headers 操作符格式检查", () => {
|
||||
const o = obs({ headers: { "content-type": "application/json" } });
|
||||
expect(checkHttpExpect(o, { headers: { "content-type": { contains: "json" } } }).matched).toBe(true);
|
||||
expect(checkHttpExpect(o, { headers: { "content-type": { match: "^application/" } } }).matched).toBe(true);
|
||||
expect(checkHttpExpect(o, { headers: { "content-type": { contains: "xml" } } }).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("headers 大小写不敏感匹配", () => {
|
||||
const o = obs({ headers: { "content-type": "application/json" } });
|
||||
expect(checkHttpExpect(o, { headers: { "Content-Type": "application/json" } }).matched).toBe(true);
|
||||
});
|
||||
|
||||
test("headers 不存在时返回失败", () => {
|
||||
const o = obs({ headers: {} });
|
||||
const r = checkHttpExpect(o, { headers: { "x-missing": "value" } });
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("headers");
|
||||
});
|
||||
|
||||
test("body 规则数组按顺序检查", () => {
|
||||
const o = obs({ body: JSON.stringify({ status: "ok", count: 5 }) });
|
||||
const r = checkHttpExpect(o, {
|
||||
body: [{ contains: "ok" }, { json: { path: "$.count", gte: 1 } }],
|
||||
});
|
||||
expect(r.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("body 第一条规则失败立即返回", () => {
|
||||
const o = obs({ body: "hello world" });
|
||||
const r = checkHttpExpect(o, {
|
||||
body: [{ contains: "missing" }, { contains: "hello" }],
|
||||
});
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.path).toBe("body[0]");
|
||||
});
|
||||
|
||||
test("body 为 null 但有 body 规则时报错", () => {
|
||||
const o = obs({ body: null });
|
||||
const r = checkHttpExpect(o, { body: [{ contains: "test" }] });
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.kind).toBe("error");
|
||||
});
|
||||
|
||||
test("完整流水线 status->duration->headers->body 全部通过", () => {
|
||||
const o = obs({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ status: "healthy" }),
|
||||
durationMs: 50,
|
||||
});
|
||||
const r = checkHttpExpect(o, {
|
||||
status: [200],
|
||||
maxDurationMs: 100,
|
||||
headers: { "content-type": { contains: "json" } },
|
||||
body: [{ json: { path: "$.status", equals: "healthy" } }],
|
||||
});
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("完整流水线 status 通过但 duration 失败", () => {
|
||||
const o = obs({ statusCode: 200, durationMs: 500 });
|
||||
const r = checkHttpExpect(o, {
|
||||
status: [200],
|
||||
maxDurationMs: 100,
|
||||
});
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("duration");
|
||||
});
|
||||
|
||||
test("完整流水线 status 和 duration 通过但 headers 失败", () => {
|
||||
const o = obs({ statusCode: 200, durationMs: 50, headers: { "x-api": "v1" } });
|
||||
const r = checkHttpExpect(o, {
|
||||
status: [200],
|
||||
maxDurationMs: 100,
|
||||
headers: { "x-api": "v2" },
|
||||
});
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("headers");
|
||||
});
|
||||
|
||||
test("完整流水线 status/duration/headers 通过但 body 失败", () => {
|
||||
const o = obs({
|
||||
statusCode: 200,
|
||||
durationMs: 50,
|
||||
headers: { "content-type": "text/plain" },
|
||||
body: "error occurred",
|
||||
});
|
||||
const r = checkHttpExpect(o, {
|
||||
status: [200],
|
||||
maxDurationMs: 100,
|
||||
headers: { "content-type": "text/plain" },
|
||||
body: [{ contains: "success" }],
|
||||
});
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("body");
|
||||
});
|
||||
});
|
||||
@@ -1,118 +1,258 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { checkExpect } from "../../../src/server/checker/fetcher";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { runHttpCheck } from "../../../src/server/checker/fetcher";
|
||||
|
||||
const emptyHeaders: Record<string, string> = {};
|
||||
|
||||
describe("checkExpect", () => {
|
||||
test("无 expect 配置时 matched 为 true", () => {
|
||||
expect(checkExpect(200, "ok", 100, emptyHeaders, undefined)).toBe(true);
|
||||
});
|
||||
|
||||
test("status 匹配", () => {
|
||||
expect(checkExpect(200, "", 100, emptyHeaders, { status: [200, 201] })).toBe(true);
|
||||
expect(checkExpect(201, "", 100, emptyHeaders, { status: [200, 201] })).toBe(true);
|
||||
expect(checkExpect(404, "", 100, emptyHeaders, { status: [200, 201] })).toBe(false);
|
||||
});
|
||||
|
||||
test("headers 匹配", () => {
|
||||
const headers = { "content-type": "application/json", "x-custom": "test" };
|
||||
expect(checkExpect(200, "", 100, headers, { headers: { "Content-Type": "application/json" } })).toBe(true);
|
||||
expect(checkExpect(200, "", 100, headers, { headers: { "Content-Type": "text/html" } })).toBe(false);
|
||||
expect(checkExpect(200, "", 100, headers, { headers: { "X-Missing": "test" } })).toBe(false);
|
||||
});
|
||||
|
||||
test("body.contains 匹配", () => {
|
||||
expect(checkExpect(200, "hello world", 100, emptyHeaders, { body: { contains: "hello" } })).toBe(true);
|
||||
expect(checkExpect(200, "hello world", 100, emptyHeaders, { body: { contains: "missing" } })).toBe(false);
|
||||
});
|
||||
|
||||
test("body.regex 匹配", () => {
|
||||
expect(checkExpect(200, "status: ok", 100, emptyHeaders, { body: { regex: "status.*ok" } })).toBe(true);
|
||||
expect(checkExpect(200, "status: error", 100, emptyHeaders, { body: { regex: "status.*ok" } })).toBe(false);
|
||||
});
|
||||
|
||||
test("body.json 匹配", () => {
|
||||
expect(
|
||||
checkExpect(200, JSON.stringify({ status: "ok" }), 100, emptyHeaders, { body: { json: { "$.status": "ok" } } }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
checkExpect(200, JSON.stringify({ status: "error" }), 100, emptyHeaders, {
|
||||
body: { json: { "$.status": "ok" } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("body.json 解析失败", () => {
|
||||
expect(checkExpect(200, "not json", 100, emptyHeaders, { body: { json: { "$.status": "ok" } } })).toBe(false);
|
||||
});
|
||||
|
||||
test("body 多种方法 AND 组合", () => {
|
||||
expect(
|
||||
checkExpect(200, "healthy", 100, emptyHeaders, {
|
||||
body: {
|
||||
contains: "healthy",
|
||||
regex: "healthy",
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
checkExpect(200, "healthy", 100, emptyHeaders, {
|
||||
body: {
|
||||
contains: "healthy",
|
||||
regex: "unhealthy",
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("maxLatencyMs 匹配", () => {
|
||||
expect(checkExpect(200, "", 100, emptyHeaders, { maxLatencyMs: 200 })).toBe(true);
|
||||
expect(checkExpect(200, "", 300, emptyHeaders, { maxLatencyMs: 200 })).toBe(false);
|
||||
expect(checkExpect(200, "", 200, emptyHeaders, { maxLatencyMs: 200 })).toBe(true);
|
||||
});
|
||||
|
||||
test("多条 expect 全部通过", () => {
|
||||
expect(
|
||||
checkExpect(200, "healthy", 100, emptyHeaders, {
|
||||
status: [200],
|
||||
body: { contains: "healthy" },
|
||||
maxLatencyMs: 200,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("多条 expect 部分失败", () => {
|
||||
expect(
|
||||
checkExpect(200, "healthy", 500, emptyHeaders, {
|
||||
status: [200],
|
||||
body: { contains: "healthy" },
|
||||
maxLatencyMs: 200,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("status + headers + body + maxLatencyMs 全组合", () => {
|
||||
const headers = { "content-type": "application/json" };
|
||||
expect(
|
||||
checkExpect(200, JSON.stringify({ status: "ok" }), 100, headers, {
|
||||
status: [200],
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: { contains: "ok", json: { "$.status": "ok" } },
|
||||
maxLatencyMs: 200,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("全组合中 headers 失败", () => {
|
||||
const headers = { "content-type": "text/html" };
|
||||
expect(
|
||||
checkExpect(200, JSON.stringify({ status: "ok" }), 100, headers, {
|
||||
status: [200],
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: { contains: "ok", json: { "$.status": "ok" } },
|
||||
maxLatencyMs: 200,
|
||||
}),
|
||||
).toBe(false);
|
||||
describe("runHttpCheck", () => {
|
||||
test("checkExpect 已移除", async () => {
|
||||
const mod = await import("../../../src/server/checker/fetcher");
|
||||
expect((mod as Record<string, unknown>).checkExpect).toBeUndefined();
|
||||
expect((mod as Record<string, unknown>).fetchTarget).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runHttpCheck 集成", () => {
|
||||
let server: ReturnType<typeof Bun.serve>;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(() => {
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
switch (url.pathname) {
|
||||
case "/ok":
|
||||
return new Response("hello world", {
|
||||
headers: { "content-type": "text/plain", "x-custom": "test-value" },
|
||||
});
|
||||
case "/json":
|
||||
return new Response(JSON.stringify({ status: "ok" }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
case "/echo":
|
||||
return new Response(JSON.stringify({ method: req.method, body: req.body ? "present" : "empty" }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
case "/large":
|
||||
return new Response("x".repeat(2000));
|
||||
case "/notfound":
|
||||
return new Response("not found", { status: 404 });
|
||||
case "/slow":
|
||||
return new Response("slow", { status: 200 });
|
||||
default:
|
||||
return new Response("ok");
|
||||
}
|
||||
},
|
||||
});
|
||||
baseUrl = `http://localhost:${server.port}`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.stop();
|
||||
});
|
||||
|
||||
function makeTarget(overrides: {
|
||||
url?: string;
|
||||
method?: string;
|
||||
body?: string;
|
||||
headers?: Record<string, string>;
|
||||
expect?: Record<string, unknown>;
|
||||
maxBodyBytes?: number;
|
||||
timeoutMs?: number;
|
||||
}) {
|
||||
return {
|
||||
type: "http" as const,
|
||||
name: "test-http",
|
||||
http: {
|
||||
url: overrides.url ?? `${baseUrl}/ok`,
|
||||
method: overrides.method ?? "GET",
|
||||
headers: overrides.headers ?? ({} as Record<string, string>),
|
||||
body: overrides.body,
|
||||
maxBodyBytes: overrides.maxBodyBytes ?? 1024 * 1024,
|
||||
},
|
||||
intervalMs: 60000,
|
||||
timeoutMs: overrides.timeoutMs ?? 5000,
|
||||
expect: overrides.expect as import("../../../src/server/checker/types").HttpExpectConfig | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
test("成功请求 200", async () => {
|
||||
const result = await runHttpCheck(makeTarget({ url: `${baseUrl}/ok` }));
|
||||
expect(result.success).toBe(true);
|
||||
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 runHttpCheck(makeTarget({ url: `${baseUrl}/notfound` }));
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.statusDetail).toBe("HTTP 404");
|
||||
expect(result.failure).not.toBeNull();
|
||||
expect(result.failure!.phase).toBe("status");
|
||||
});
|
||||
|
||||
test("404 匹配自定义 status [404]", async () => {
|
||||
const result = await runHttpCheck(
|
||||
makeTarget({
|
||||
url: `${baseUrl}/notfound`,
|
||||
expect: { status: [404] },
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("headers 检查通过", async () => {
|
||||
const result = await runHttpCheck(
|
||||
makeTarget({
|
||||
url: `${baseUrl}/ok`,
|
||||
expect: { headers: { "x-custom": "test-value" } },
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("headers 检查失败", async () => {
|
||||
const result = await runHttpCheck(
|
||||
makeTarget({
|
||||
url: `${baseUrl}/ok`,
|
||||
expect: { headers: { "x-custom": "wrong-value" } },
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.failure!.phase).toBe("headers");
|
||||
});
|
||||
|
||||
test("body contains 检查", async () => {
|
||||
const result = await runHttpCheck(
|
||||
makeTarget({
|
||||
url: `${baseUrl}/ok`,
|
||||
expect: { body: [{ contains: "hello" }] },
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("body contains 失败", async () => {
|
||||
const result = await runHttpCheck(
|
||||
makeTarget({
|
||||
url: `${baseUrl}/ok`,
|
||||
expect: { body: [{ contains: "nonexistent" }] },
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.failure!.phase).toBe("body");
|
||||
});
|
||||
|
||||
test("body json 检查", async () => {
|
||||
const result = await runHttpCheck(
|
||||
makeTarget({
|
||||
url: `${baseUrl}/json`,
|
||||
expect: { body: [{ json: { path: "$.status", equals: "ok" } }] },
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("响应体超过 maxBodyBytes", async () => {
|
||||
const result = await runHttpCheck(
|
||||
makeTarget({
|
||||
url: `${baseUrl}/large`,
|
||||
maxBodyBytes: 100,
|
||||
expect: { body: [{ contains: "x" }] },
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.failure).not.toBeNull();
|
||||
expect(result.failure!.phase).toBe("body");
|
||||
expect(result.failure!.message).toContain("超过限制");
|
||||
});
|
||||
|
||||
test("请求超时", async () => {
|
||||
const timeoutServer = Bun.serve({
|
||||
port: 0,
|
||||
async fetch() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10000));
|
||||
return new Response("late");
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await runHttpCheck(
|
||||
makeTarget({
|
||||
url: `http://localhost:${timeoutServer.port}/`,
|
||||
timeoutMs: 100,
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.failure).not.toBeNull();
|
||||
expect(result.failure!.message).toContain("超时");
|
||||
} finally {
|
||||
timeoutServer.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("快速失败:status 失败时不读取 body", async () => {
|
||||
const result = await runHttpCheck(
|
||||
makeTarget({
|
||||
url: `${baseUrl}/notfound`,
|
||||
expect: { status: [200], body: [{ contains: "something" }] },
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.failure!.phase).toBe("status");
|
||||
});
|
||||
|
||||
test("快速失败:headers 失败时不读取 body", async () => {
|
||||
const result = await runHttpCheck(
|
||||
makeTarget({
|
||||
url: `${baseUrl}/ok`,
|
||||
expect: { headers: { "x-missing": "value" }, body: [{ contains: "hello" }] },
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.failure!.phase).toBe("headers");
|
||||
});
|
||||
|
||||
test("status 通过但 body 失败", async () => {
|
||||
const result = await runHttpCheck(
|
||||
makeTarget({
|
||||
url: `${baseUrl}/ok`,
|
||||
expect: { status: [200], body: [{ contains: "not-in-body" }] },
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.failure!.phase).toBe("body");
|
||||
});
|
||||
|
||||
test("无 expect 时默认检查 status 200", async () => {
|
||||
const result = await runHttpCheck(makeTarget({ url: `${baseUrl}/ok`, expect: undefined }));
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("POST 请求携带 body", async () => {
|
||||
const result = await runHttpCheck(
|
||||
makeTarget({
|
||||
url: `${baseUrl}/echo`,
|
||||
method: "POST",
|
||||
body: "test-body",
|
||||
headers: { "content-type": "text/plain" },
|
||||
expect: { status: [200], body: [{ json: { path: "$.body", equals: "present" } }] },
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("仅 contains 规则时不解析 JSON", async () => {
|
||||
const result = await runHttpCheck(
|
||||
makeTarget({
|
||||
url: `${baseUrl}/ok`,
|
||||
expect: { body: [{ contains: "hello world" }] },
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
43
tests/server/checker/size.test.ts
Normal file
43
tests/server/checker/size.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseSize, DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_OUTPUT_BYTES } from "../../../src/server/checker/size";
|
||||
|
||||
describe("parseSize", () => {
|
||||
test("解析 B", () => {
|
||||
expect(parseSize("1024B")).toBe(1024);
|
||||
expect(parseSize("0B")).toBe(0);
|
||||
});
|
||||
|
||||
test("解析 KB", () => {
|
||||
expect(parseSize("1KB")).toBe(1024);
|
||||
expect(parseSize("512KB")).toBe(524288);
|
||||
});
|
||||
|
||||
test("解析 MB", () => {
|
||||
expect(parseSize("1MB")).toBe(1048576);
|
||||
expect(parseSize("100MB")).toBe(104857600);
|
||||
});
|
||||
|
||||
test("解析 GB", () => {
|
||||
expect(parseSize("1GB")).toBe(1073741824);
|
||||
});
|
||||
|
||||
test("解析小数", () => {
|
||||
expect(parseSize("1.5MB")).toBe(1572864);
|
||||
});
|
||||
|
||||
test("数字直接返回", () => {
|
||||
expect(parseSize(2048)).toBe(2048);
|
||||
});
|
||||
|
||||
test("无效格式抛出错误", () => {
|
||||
expect(() => parseSize("100")).toThrow("无效的 size 格式");
|
||||
expect(() => parseSize("100MBB")).toThrow("无效的 size 格式");
|
||||
expect(() => parseSize("abc")).toThrow("无效的 size 格式");
|
||||
expect(() => parseSize("")).toThrow("无效的 size 格式");
|
||||
});
|
||||
|
||||
test("默认值", () => {
|
||||
expect(DEFAULT_MAX_BODY_BYTES).toBe(104857600);
|
||||
expect(DEFAULT_MAX_OUTPUT_BYTES).toBe(104857600);
|
||||
});
|
||||
});
|
||||
@@ -1,34 +1,42 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { ProbeStore } from "../../../src/server/checker/store";
|
||||
import type { ResolvedTarget } from "../../../src/server/checker/types";
|
||||
import type { CheckFailure, ResolvedTarget } from "../../../src/server/checker/types";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
const httpTarget: ResolvedTarget = {
|
||||
type: "http",
|
||||
name: "test-http",
|
||||
http: {
|
||||
url: "https://example.com/health",
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
maxBodyBytes: 104857600,
|
||||
},
|
||||
intervalMs: 30000,
|
||||
timeoutMs: 10000,
|
||||
expect: { status: [200], maxDurationMs: 3000 },
|
||||
};
|
||||
|
||||
const commandTarget: ResolvedTarget = {
|
||||
type: "command",
|
||||
name: "test-cmd",
|
||||
command: {
|
||||
exec: "ping",
|
||||
args: ["-c", "1", "localhost"],
|
||||
cwd: "/tmp",
|
||||
env: {},
|
||||
maxOutputBytes: 104857600,
|
||||
},
|
||||
intervalMs: 60000,
|
||||
timeoutMs: 5000,
|
||||
};
|
||||
|
||||
describe("ProbeStore", () => {
|
||||
let tempDir: string;
|
||||
let store: ProbeStore;
|
||||
|
||||
const target1: ResolvedTarget = {
|
||||
name: "test-a",
|
||||
url: "http://a.com",
|
||||
method: "GET",
|
||||
headers: {},
|
||||
intervalMs: 30000,
|
||||
timeoutMs: 10000,
|
||||
};
|
||||
|
||||
const target2: ResolvedTarget = {
|
||||
name: "test-b",
|
||||
url: "http://b.com",
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: '{"ping": true}',
|
||||
intervalMs: 60000,
|
||||
timeoutMs: 5000,
|
||||
expect: { status: [200], maxLatencyMs: 3000 },
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDir = join(tmpdir(), `gc-store-test-${Date.now()}`);
|
||||
await mkdir(tempDir, { recursive: true });
|
||||
@@ -44,42 +52,62 @@ describe("ProbeStore", () => {
|
||||
expect(store.getTargets()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("同步新增 targets", () => {
|
||||
store.syncTargets([target1, target2]);
|
||||
test("同步 http 和 command targets", () => {
|
||||
store.syncTargets([httpTarget, commandTarget]);
|
||||
const targets = store.getTargets();
|
||||
expect(targets).toHaveLength(2);
|
||||
expect(targets[0]!.name).toBe("test-a");
|
||||
expect(targets[1]!.name).toBe("test-b");
|
||||
expect(targets[0]!.name).toBe("test-http");
|
||||
expect(targets[1]!.name).toBe("test-cmd");
|
||||
});
|
||||
|
||||
test("同步后 target 字段正确", () => {
|
||||
const targets = store.getTargets();
|
||||
const t2 = targets.find((t) => t.name === "test-b")!;
|
||||
expect(t2.url).toBe("http://b.com");
|
||||
expect(t2.method).toBe("POST");
|
||||
expect(JSON.parse(t2.headers)).toEqual({ "Content-Type": "application/json" });
|
||||
expect(t2.body).toBe('{"ping": true}');
|
||||
expect(t2.interval_ms).toBe(60000);
|
||||
expect(t2.expect).toBe(JSON.stringify({ status: [200], maxLatencyMs: 3000 }));
|
||||
test("http target 字段正确", () => {
|
||||
const t = store.getTargets().find((t) => t.name === "test-http")!;
|
||||
expect(t.type).toBe("http");
|
||||
expect(t.target).toBe("https://example.com/health");
|
||||
const config = JSON.parse(t.config);
|
||||
expect(config.url).toBe("https://example.com/health");
|
||||
expect(config.method).toBe("GET");
|
||||
expect(config.headers).toEqual({ Accept: "application/json" });
|
||||
expect(config.maxBodyBytes).toBe(104857600);
|
||||
expect(t.interval_ms).toBe(30000);
|
||||
expect(t.timeout_ms).toBe(10000);
|
||||
expect(JSON.parse(t.expect!)).toEqual({ status: [200], maxDurationMs: 3000 });
|
||||
});
|
||||
|
||||
test("command target 字段正确", () => {
|
||||
const t = store.getTargets().find((t) => t.name === "test-cmd")!;
|
||||
expect(t.type).toBe("command");
|
||||
expect(t.target).toBe("exec ping -c 1 localhost");
|
||||
const config = JSON.parse(t.config);
|
||||
expect(config.exec).toBe("ping");
|
||||
expect(config.args).toEqual(["-c", "1", "localhost"]);
|
||||
expect(config.cwd).toBe("/tmp");
|
||||
expect(config.maxOutputBytes).toBe(104857600);
|
||||
expect(t.interval_ms).toBe(60000);
|
||||
expect(t.timeout_ms).toBe(5000);
|
||||
expect(t.expect).toBeNull();
|
||||
});
|
||||
|
||||
test("同步更新已有 target", () => {
|
||||
store.syncTargets([{ ...target1, url: "http://a-v2.com" }, target2]);
|
||||
const targets = store.getTargets();
|
||||
const t1 = targets.find((t) => t.name === "test-a")!;
|
||||
expect(t1.url).toBe("http://a-v2.com");
|
||||
expect(targets).toHaveLength(2);
|
||||
const updated: ResolvedTarget = {
|
||||
...httpTarget,
|
||||
http: { ...httpTarget.http, url: "https://example.com/v2" },
|
||||
};
|
||||
store.syncTargets([updated, commandTarget]);
|
||||
const t = store.getTargets().find((t) => t.name === "test-http")!;
|
||||
expect(t.target).toBe("https://example.com/v2");
|
||||
expect(store.getTargets()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("同步删除 target", () => {
|
||||
store.syncTargets([target1]);
|
||||
store.syncTargets([httpTarget]);
|
||||
const targets = store.getTargets();
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0]!.name).toBe("test-a");
|
||||
expect(targets[0]!.name).toBe("test-http");
|
||||
});
|
||||
|
||||
test("重新同步回来", () => {
|
||||
store.syncTargets([target1, target2]);
|
||||
store.syncTargets([httpTarget, commandTarget]);
|
||||
expect(store.getTargets()).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -87,14 +115,15 @@ describe("ProbeStore", () => {
|
||||
const targets = store.getTargets();
|
||||
const found = store.getTargetById(targets[0]!.id);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.name).toBe("test-a");
|
||||
expect(found!.name).toBe("test-http");
|
||||
});
|
||||
|
||||
test("getTargetById 不存在", () => {
|
||||
expect(store.getTargetById(99999)).toBeNull();
|
||||
});
|
||||
|
||||
test("写入和查询 check result", () => {
|
||||
test("写入 check result 并查询", () => {
|
||||
store.syncTargets([httpTarget, commandTarget]);
|
||||
const targets = store.getTargets();
|
||||
const t1Id = targets[0]!.id;
|
||||
|
||||
@@ -102,30 +131,39 @@ describe("ProbeStore", () => {
|
||||
targetId: t1Id,
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
latencyMs: 150,
|
||||
error: null,
|
||||
matched: true,
|
||||
durationMs: 150.5,
|
||||
statusDetail: "200 OK",
|
||||
failure: null,
|
||||
});
|
||||
|
||||
store.insertCheckResult({
|
||||
targetId: t1Id,
|
||||
timestamp: "2025-01-01T00:00:30.000Z",
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
latencyMs: 300,
|
||||
error: null,
|
||||
matched: true,
|
||||
durationMs: 300,
|
||||
statusDetail: "200 OK",
|
||||
failure: null,
|
||||
});
|
||||
|
||||
const failure: CheckFailure = {
|
||||
kind: "error",
|
||||
phase: "duration",
|
||||
path: "$.maxDurationMs",
|
||||
expected: 3000,
|
||||
actual: 5000,
|
||||
message: "请求耗时 5000ms 超过限制 3000ms",
|
||||
};
|
||||
|
||||
store.insertCheckResult({
|
||||
targetId: t1Id,
|
||||
timestamp: "2025-01-01T00:01:00.000Z",
|
||||
success: false,
|
||||
statusCode: null,
|
||||
latencyMs: null,
|
||||
error: "timeout",
|
||||
matched: false,
|
||||
durationMs: null,
|
||||
statusDetail: null,
|
||||
failure,
|
||||
});
|
||||
|
||||
const history = store.getHistory(t1Id, 10);
|
||||
@@ -134,7 +172,11 @@ describe("ProbeStore", () => {
|
||||
|
||||
const latest = store.getLatestCheck(t1Id)!;
|
||||
expect(latest.success).toBe(0);
|
||||
expect(latest.error).toBe("timeout");
|
||||
expect(latest.failure).not.toBeNull();
|
||||
const parsedFailure = JSON.parse(latest.failure!) as CheckFailure;
|
||||
expect(parsedFailure.kind).toBe("error");
|
||||
expect(parsedFailure.phase).toBe("duration");
|
||||
expect(parsedFailure.message).toBe("请求耗时 5000ms 超过限制 3000ms");
|
||||
});
|
||||
|
||||
test("getHistory 默认 limit=20", () => {
|
||||
@@ -144,12 +186,12 @@ describe("ProbeStore", () => {
|
||||
for (let i = 0; i < 25; i++) {
|
||||
store.insertCheckResult({
|
||||
targetId: t1Id,
|
||||
timestamp: `2025-01-01T00:${String(i).padStart(2, "0")}:00.000Z`,
|
||||
timestamp: `2025-01-01T01:${String(i).padStart(2, "0")}:00.000Z`,
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
latencyMs: 100 + i,
|
||||
error: null,
|
||||
matched: true,
|
||||
durationMs: 100 + i,
|
||||
statusDetail: "200 OK",
|
||||
failure: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -157,7 +199,7 @@ describe("ProbeStore", () => {
|
||||
expect(history).toHaveLength(20);
|
||||
});
|
||||
|
||||
test("getTargetStats 计算可用率和延迟", () => {
|
||||
test("getTargetStats 计算可用率和 duration", () => {
|
||||
const targets = store.getTargets();
|
||||
const t1Id = targets[0]!.id;
|
||||
|
||||
@@ -165,17 +207,19 @@ describe("ProbeStore", () => {
|
||||
expect(stats.totalChecks).toBeGreaterThan(0);
|
||||
expect(stats.availability).toBeGreaterThanOrEqual(0);
|
||||
expect(stats.availability).toBeLessThanOrEqual(100);
|
||||
expect(stats.avgLatencyMs).not.toBeNull();
|
||||
expect(stats.avgDurationMs).not.toBeNull();
|
||||
expect(typeof stats.avgDurationMs).toBe("number");
|
||||
});
|
||||
|
||||
test("无记录目标的 stats", () => {
|
||||
const targets = store.getTargets();
|
||||
const t2Id = targets.find((t) => t.name === "test-b")!.id;
|
||||
const t2Id = targets.find((t) => t.name === "test-cmd")!.id;
|
||||
|
||||
const stats = store.getTargetStats(t2Id);
|
||||
expect(stats.totalChecks).toBe(0);
|
||||
expect(stats.availability).toBe(0);
|
||||
expect(stats.avgLatencyMs).toBeNull();
|
||||
expect(stats.avgDurationMs).toBeNull();
|
||||
expect(stats.p99DurationMs).toBeNull();
|
||||
});
|
||||
|
||||
test("getSummary 返回总览统计", () => {
|
||||
@@ -183,6 +227,7 @@ describe("ProbeStore", () => {
|
||||
expect(summary.total).toBe(2);
|
||||
expect(summary.up + summary.down).toBe(2);
|
||||
expect(summary.lastCheckTime).not.toBeNull();
|
||||
expect(summary.avgDurationMs).not.toBeNull();
|
||||
});
|
||||
|
||||
test("getTrend 返回趋势数据", () => {
|
||||
@@ -191,5 +236,30 @@ describe("ProbeStore", () => {
|
||||
|
||||
const trend = store.getTrend(t1Id, 24);
|
||||
expect(Array.isArray(trend)).toBe(true);
|
||||
if (trend.length > 0) {
|
||||
expect(trend[0]!.hour).toBeDefined();
|
||||
expect(trend[0]!.avgDurationMs).toBeDefined();
|
||||
expect(trend[0]!.availability).toBeGreaterThanOrEqual(0);
|
||||
expect(trend[0]!.totalChecks).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("getSparkline 返回 duration 数组", () => {
|
||||
const targets = store.getTargets();
|
||||
const t1Id = targets[0]!.id;
|
||||
|
||||
const sparkline = store.getSparkline(t1Id);
|
||||
expect(Array.isArray(sparkline)).toBe(true);
|
||||
expect(sparkline.length).toBeGreaterThan(0);
|
||||
for (const val of sparkline) {
|
||||
expect(typeof val).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
test("关闭后操作不报错", () => {
|
||||
const closedStore = new ProbeStore(join(tempDir, "closed.db"));
|
||||
closedStore.close();
|
||||
expect(closedStore.getTargets()).toHaveLength(0);
|
||||
expect(closedStore.getTargetById(1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user