实现 type: ping checker,通过 Bun.spawn 调用系统 ping 命令,自行实现跨平台 输出解析器(Linux/macOS/Windows 含中文 locale),支持 alive、丢包率、延迟、 耗时等 expect 断言,复用现有 checker 架构零外部依赖。 包含完整的类型定义、TypeBox schema、语义校验、命令构建、解析、断言、执行、 注册、配置加载测试,以及 probe-config.schema.json 更新和文档更新。 审查修复:提取 buildPingCommand 为独立纯函数并补充跨平台单测,补充 maxDurationMs/maxAvgLatencyMs 类型非法和空字符串 host 边界测试用例。 变更已归档,delta specs 已同步至 main specs。
2074 lines
51 KiB
TypeScript
2074 lines
51 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import type { ResolvedCommandTarget } from "../../../src/server/checker/runner/cmd/types";
|
|
import type { ResolvedHttpTarget } from "../../../src/server/checker/runner/http/types";
|
|
import type { ResolvedPingTarget } from "../../../src/server/checker/runner/icmp/types";
|
|
import type { ResolvedTcpTarget } from "../../../src/server/checker/runner/tcp/types";
|
|
|
|
import { loadConfig, parseDuration } from "../../../src/server/checker/config-loader";
|
|
import { checkerRegistry } from "../../../src/server/checker/runner";
|
|
import { CommandChecker } from "../../../src/server/checker/runner/cmd/execute";
|
|
import { HttpChecker } from "../../../src/server/checker/runner/http/execute";
|
|
import { readRuntimeConfig } from "../../../src/server/config";
|
|
|
|
function ensureRegistered() {
|
|
if (!checkerRegistry.supportedTypes.includes("http")) {
|
|
checkerRegistry.register(new HttpChecker());
|
|
checkerRegistry.register(new CommandChecker());
|
|
}
|
|
}
|
|
|
|
beforeAll(() => {
|
|
ensureRegistered();
|
|
});
|
|
|
|
describe("parseDuration", () => {
|
|
test("解析秒", () => {
|
|
expect(parseDuration("30s")).toBe(30000);
|
|
expect(parseDuration("1s")).toBe(1000);
|
|
});
|
|
|
|
test("解析分钟", () => {
|
|
expect(parseDuration("5m")).toBe(300000);
|
|
expect(parseDuration("1m")).toBe(60000);
|
|
});
|
|
|
|
test("解析毫秒", () => {
|
|
expect(parseDuration("500ms")).toBe(500);
|
|
expect(parseDuration("100ms")).toBe(100);
|
|
});
|
|
|
|
test("解析小数", () => {
|
|
expect(parseDuration("1.5s")).toBe(1500);
|
|
});
|
|
|
|
test("解析小时", () => {
|
|
expect(parseDuration("2h")).toBe(7200000);
|
|
expect(parseDuration("1h")).toBe(3600000);
|
|
});
|
|
|
|
test("解析天", () => {
|
|
expect(parseDuration("7d")).toBe(604800000);
|
|
expect(parseDuration("1d")).toBe(86400000);
|
|
});
|
|
|
|
test("拒绝非正整数毫秒结果", () => {
|
|
expect(() => parseDuration("0ms")).toThrow("正整数毫秒");
|
|
expect(() => parseDuration("1.5ms")).toThrow("正整数毫秒");
|
|
});
|
|
|
|
test("无效格式抛出错误", () => {
|
|
expect(() => parseDuration("30")).toThrow("无效的时长格式");
|
|
expect(() => parseDuration("abc")).toThrow("无效的时长格式");
|
|
expect(() => parseDuration("30x")).toThrow("无效的时长格式");
|
|
expect(() => parseDuration("")).toThrow("无效的时长格式");
|
|
});
|
|
});
|
|
|
|
describe("readRuntimeConfig", () => {
|
|
test("返回配置文件路径", () => {
|
|
expect(readRuntimeConfig(["./probes.yaml"])).toEqual({ configPath: "./probes.yaml" });
|
|
});
|
|
|
|
test("未提供参数抛出错误", () => {
|
|
expect(() => readRuntimeConfig([])).toThrow("需要指定 YAML 配置文件路径");
|
|
});
|
|
});
|
|
|
|
describe("loadConfig", () => {
|
|
let tempDir: string;
|
|
|
|
beforeAll(async () => {
|
|
tempDir = join(tmpdir(), `gc-test-${Date.now()}`);
|
|
await mkdir(tempDir, { recursive: true });
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await rm(tempDir, { force: true, recursive: true });
|
|
});
|
|
|
|
async function expectConfigError(fileName: string, content: string, message: string): Promise<void> {
|
|
const configPath = join(tempDir, fileName);
|
|
await writeFile(configPath, content);
|
|
let error: unknown;
|
|
try {
|
|
await loadConfig(configPath);
|
|
} catch (caught) {
|
|
error = caught;
|
|
}
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect((error as Error).message).toContain(message);
|
|
}
|
|
|
|
test("解析最简 HTTP 配置", async () => {
|
|
const configPath = join(tempDir, "minimal-http.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- name: "test"
|
|
id: "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(join(tempDir, "data"));
|
|
expect(config.maxConcurrentChecks).toBe(20);
|
|
expect(config.targets).toHaveLength(1);
|
|
const t = config.targets[0]! as ResolvedHttpTarget;
|
|
expect(t.type).toBe("http");
|
|
expect(t.id).toBe("test");
|
|
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.ignoreSSL).toBe(false);
|
|
expect(t.http.maxBodyBytes).toBe(104857600);
|
|
expect(t.http.maxRedirects).toBe(0);
|
|
expect(t.intervalMs).toBe(30000);
|
|
expect(t.timeoutMs).toBe(10000);
|
|
});
|
|
|
|
test("解析最简 cmd 配置", async () => {
|
|
const subdir = join(tempDir, "subdir");
|
|
await mkdir(subdir, { recursive: true });
|
|
const configPath = join(subdir, "cmd.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- name: "check-nginx"
|
|
id: "check-nginx"
|
|
type: cmd
|
|
cmd:
|
|
exec: "pgrep"
|
|
args: ["nginx"]
|
|
`,
|
|
);
|
|
|
|
const config = await loadConfig(configPath);
|
|
expect(config.targets).toHaveLength(1);
|
|
const t = config.targets[0]! as ResolvedCommandTarget;
|
|
expect(t.type).toBe("cmd");
|
|
expect(t.id).toBe("check-nginx");
|
|
expect(t.name).toBe("check-nginx");
|
|
expect(t.cmd.exec).toBe("pgrep");
|
|
expect(t.cmd.args).toEqual(["nginx"]);
|
|
expect(t.cmd.cwd).toBe(subdir);
|
|
expect(t.cmd.maxOutputBytes).toBe(104857600);
|
|
expect(Object.keys(t.cmd.env).some((key) => key.toUpperCase() === "PATH")).toBe(true);
|
|
});
|
|
|
|
test("解析完整配置", async () => {
|
|
const configPath = join(tempDir, "full.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`server:
|
|
host: "0.0.0.0"
|
|
port: 8080
|
|
dataDir: "./my-data"
|
|
runtime:
|
|
maxConcurrentChecks: 5
|
|
defaults:
|
|
interval: "15s"
|
|
timeout: "5s"
|
|
http:
|
|
headers:
|
|
Authorization: "Bearer token"
|
|
maxBodyBytes: "50MB"
|
|
cmd:
|
|
cwd: "/tmp"
|
|
maxOutputBytes: "10MB"
|
|
targets:
|
|
- name: "http-target"
|
|
id: "http-target"
|
|
type: http
|
|
interval: "1m"
|
|
http:
|
|
url: "http://example.com"
|
|
method: "POST"
|
|
ignoreSSL: true
|
|
maxRedirects: 5
|
|
expect:
|
|
status: ["2xx", 301]
|
|
body:
|
|
- contains: "ok"
|
|
- name: "cmd-target"
|
|
id: "cmd-target"
|
|
type: cmd
|
|
cmd:
|
|
exec: "ls"
|
|
args: ["/tmp"]
|
|
expect:
|
|
exitCode: [0]
|
|
`,
|
|
);
|
|
|
|
const config = await loadConfig(configPath);
|
|
expect(config.host).toBe("0.0.0.0");
|
|
expect(config.port).toBe(8080);
|
|
expect(config.dataDir).toBe(join(tempDir, "my-data"));
|
|
expect(config.maxConcurrentChecks).toBe(5);
|
|
expect(config.targets).toHaveLength(2);
|
|
|
|
const http = config.targets[0]! as ResolvedHttpTarget;
|
|
expect(http.type).toBe("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.ignoreSSL).toBe(true);
|
|
expect(http.http.maxBodyBytes).toBe(52428800);
|
|
expect(http.http.maxRedirects).toBe(5);
|
|
expect(http.expect?.status).toEqual(["2xx", 301]);
|
|
expect(http.intervalMs).toBe(60000);
|
|
expect(http.timeoutMs).toBe(5000);
|
|
|
|
const cmd = config.targets[1]! as ResolvedCommandTarget;
|
|
expect(cmd.type).toBe("cmd");
|
|
expect(cmd.cmd.exec).toBe("ls");
|
|
expect(cmd.cmd.args).toEqual(["/tmp"]);
|
|
expect(cmd.cmd.maxOutputBytes).toBe(10485760);
|
|
});
|
|
|
|
test("name 缺省时保留为 null", async () => {
|
|
const configPath = join(tempDir, "name-fallback.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- id: "api-health"
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
);
|
|
|
|
const config = await loadConfig(configPath);
|
|
const target = config.targets[0]!;
|
|
expect(target.id).toBe("api-health");
|
|
expect(target.name).toBeNull();
|
|
});
|
|
|
|
test("name 显式 null 保留为 null", async () => {
|
|
const configPath = join(tempDir, "name-explicit-null.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- id: "api-health"
|
|
name: null
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
);
|
|
|
|
const config = await loadConfig(configPath);
|
|
expect(config.targets[0]!.name).toBeNull();
|
|
});
|
|
|
|
test("name YAML 空值保留为 null", async () => {
|
|
const configPath = join(tempDir, "name-yaml-null.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- id: "api-health"
|
|
name:
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
);
|
|
|
|
const config = await loadConfig(configPath);
|
|
expect(config.targets[0]!.name).toBeNull();
|
|
});
|
|
|
|
test("name 为空字符串抛出错误", async () => {
|
|
const configPath = join(tempDir, "empty-name.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- id: "api-health"
|
|
name: ""
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
await expect(loadConfig(configPath)).rejects.toThrow("name 不能为空白");
|
|
});
|
|
|
|
test("name 仅包含空白字符抛出错误", async () => {
|
|
const configPath = join(tempDir, "whitespace-name.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- id: "api-health"
|
|
name: " "
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
await expect(loadConfig(configPath)).rejects.toThrow("name 不能为空白");
|
|
});
|
|
|
|
test("description 显式 null 保留为 null", async () => {
|
|
const configPath = join(tempDir, "description-null.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- id: "api-health"
|
|
description: null
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
);
|
|
const config = await loadConfig(configPath);
|
|
expect(config.targets[0]!.description).toBeNull();
|
|
});
|
|
|
|
test("description YAML 空值保留为 null", async () => {
|
|
const configPath = join(tempDir, "description-yaml-null.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- id: "api-health"
|
|
description:
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
);
|
|
const config = await loadConfig(configPath);
|
|
expect(config.targets[0]!.description).toBeNull();
|
|
});
|
|
|
|
test("name 支持变量替换且不要求唯一", async () => {
|
|
const configPath = join(tempDir, "name-variable.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`variables:
|
|
env: "生产"
|
|
targets:
|
|
- id: "api-a"
|
|
name: "\${env} API"
|
|
type: http
|
|
http:
|
|
url: "http://a.example.com"
|
|
- id: "api-b"
|
|
name: "\${env} API"
|
|
type: http
|
|
http:
|
|
url: "http://b.example.com"
|
|
`,
|
|
);
|
|
|
|
const config = await loadConfig(configPath);
|
|
expect(config.targets.map((target) => [target.id, target.name])).toEqual([
|
|
["api-a", "生产 API"],
|
|
["api-b", "生产 API"],
|
|
]);
|
|
});
|
|
|
|
test("包含 variables 的完整配置在 schema 校验前完成替换", async () => {
|
|
const configPath = join(tempDir, "variables-full.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`variables:
|
|
env: "生产"
|
|
base_url: "https://example.com"
|
|
ignore_ssl: true
|
|
max_redirects: 5
|
|
targets:
|
|
- id: "api-health"
|
|
name: "\${env} API 健康检查"
|
|
type: http
|
|
http:
|
|
url: "\${base_url}/health"
|
|
ignoreSSL: "\${ignore_ssl}"
|
|
maxRedirects: "\${max_redirects}"
|
|
`,
|
|
);
|
|
|
|
const config = await loadConfig(configPath);
|
|
const target = config.targets[0] as ResolvedHttpTarget;
|
|
expect(target.id).toBe("api-health");
|
|
expect(target.name).toBe("生产 API 健康检查");
|
|
expect(target.http.url).toBe("https://example.com/health");
|
|
expect(target.http.ignoreSSL).toBe(true);
|
|
expect(target.http.maxRedirects).toBe(5);
|
|
});
|
|
|
|
test("变量替换后类型不匹配导致 schema 校验失败", async () => {
|
|
const configPath = join(tempDir, "bad-var-type.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`variables:
|
|
max_redirects: "not-a-number"
|
|
targets:
|
|
- id: "bad-var"
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
maxRedirects: "\${max_redirects}"
|
|
`,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
await expect(loadConfig(configPath)).rejects.toThrow("maxRedirects");
|
|
});
|
|
|
|
test("变量替换后通过 schema 校验", async () => {
|
|
const configPath = join(tempDir, "good-var-type.yaml");
|
|
const origEnv = process.env["DIAL_VAR_MAX_REDIRECTS"];
|
|
process.env["DIAL_VAR_MAX_REDIRECTS"] = "3";
|
|
try {
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- id: "good-var"
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
maxRedirects: "\${DIAL_VAR_MAX_REDIRECTS}"
|
|
`,
|
|
);
|
|
const config = await loadConfig(configPath);
|
|
const target = config.targets[0] as ResolvedHttpTarget;
|
|
expect(target.http.maxRedirects).toBe(3);
|
|
} finally {
|
|
if (origEnv === undefined) {
|
|
delete process.env["DIAL_VAR_MAX_REDIRECTS"];
|
|
} else {
|
|
process.env["DIAL_VAR_MAX_REDIRECTS"] = origEnv;
|
|
}
|
|
}
|
|
});
|
|
|
|
test("未定义变量且无默认值阻止启动", async () => {
|
|
const configPath = join(tempDir, "unresolved-var.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- id: "unresolved"
|
|
type: http
|
|
http:
|
|
url: "\${MISSING_BASE_URL}/health"
|
|
`,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
await expect(loadConfig(configPath)).rejects.toThrow("未定义的变量");
|
|
});
|
|
|
|
test("绝对 dataDir 保持不变", async () => {
|
|
const dataDir = join(tempDir, "absolute-data");
|
|
const configPath = join(tempDir, "absolute-data-dir.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`server:
|
|
dataDir: ${JSON.stringify(dataDir)}
|
|
targets:
|
|
- name: "test"
|
|
id: "test"
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
);
|
|
|
|
const config = await loadConfig(configPath);
|
|
expect(config.dataDir).toBe(dataDir);
|
|
});
|
|
|
|
test("per-target 覆盖 defaults", async () => {
|
|
const configPath = join(tempDir, "override.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`defaults:
|
|
interval: "30s"
|
|
timeout: "10s"
|
|
http:
|
|
maxBodyBytes: "10MB"
|
|
targets:
|
|
- name: "override-all"
|
|
id: "override-all"
|
|
type: http
|
|
interval: "5m"
|
|
timeout: "30s"
|
|
http:
|
|
url: "http://example.com"
|
|
method: "POST"
|
|
maxBodyBytes: "1MB"
|
|
`,
|
|
);
|
|
|
|
const config = await loadConfig(configPath);
|
|
const t = config.targets[0]! as ResolvedHttpTarget;
|
|
expect(t.http.method).toBe("POST");
|
|
expect(t.intervalMs).toBe(300000);
|
|
expect(t.timeoutMs).toBe(30000);
|
|
expect(t.http.maxBodyBytes).toBe(1048576);
|
|
});
|
|
|
|
test("配置文件不存在抛出错误", async () => {
|
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
await expect(loadConfig("/nonexistent/file.yaml")).rejects.toThrow("配置文件不存在");
|
|
});
|
|
|
|
test("target 缺少 id 抛出错误", async () => {
|
|
const configPath = join(tempDir, "no-id.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- type: http
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
await expect(loadConfig(configPath)).rejects.toThrow("缺少 id 字段");
|
|
});
|
|
|
|
test("target 缺少 type 抛出错误", async () => {
|
|
const configPath = join(tempDir, "no-type.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- name: "test"
|
|
id: "test"
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
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"
|
|
id: "test"
|
|
type: http
|
|
http: {}
|
|
`,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
await expect(loadConfig(configPath)).rejects.toThrow("缺少 http.url 字段");
|
|
});
|
|
|
|
test("HTTP target 缺少 http 分组抛出清晰错误", async () => {
|
|
const configPath = join(tempDir, "no-http-group.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- name: "test"
|
|
id: "test"
|
|
type: http
|
|
`,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
await expect(loadConfig(configPath)).rejects.toThrow("缺少 http.url 字段");
|
|
});
|
|
|
|
test("HTTP target ignoreSSL 非布尔值抛出错误", async () => {
|
|
const configPath = join(tempDir, "bad-ignore-ssl.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- name: "test"
|
|
id: "test"
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
ignoreSSL: "true"
|
|
`,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
await expect(loadConfig(configPath)).rejects.toThrow("http.ignoreSSL 类型不合法");
|
|
});
|
|
|
|
test("HTTP target maxRedirects 非负整数校验", async () => {
|
|
const configPath = join(tempDir, "bad-max-redirects.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- name: "test"
|
|
id: "test"
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
maxRedirects: 1.5
|
|
`,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
await expect(loadConfig(configPath)).rejects.toThrow("http.maxRedirects 类型不合法");
|
|
});
|
|
|
|
test("HTTP target status 模式非法抛出错误", async () => {
|
|
const configPath = join(tempDir, "bad-status-pattern.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- name: "test"
|
|
id: "test"
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
expect:
|
|
status: ["abc"]
|
|
`,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
await expect(loadConfig(configPath)).rejects.toThrow("status 模式");
|
|
});
|
|
|
|
test("cmd target 缺少 exec 抛出错误", async () => {
|
|
const configPath = join(tempDir, "no-exec.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- name: "test"
|
|
id: "test"
|
|
type: cmd
|
|
cmd: {}
|
|
`,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
await expect(loadConfig(configPath)).rejects.toThrow("缺少 cmd.exec 字段");
|
|
});
|
|
|
|
test("非法 target type 抛出错误", async () => {
|
|
const configPath = join(tempDir, "bad-type.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- name: "test"
|
|
id: "test"
|
|
type: dns
|
|
`,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
await expect(loadConfig(configPath)).rejects.toThrow("不支持的 type");
|
|
});
|
|
|
|
test("target id 重复抛出错误", async () => {
|
|
const configPath = join(tempDir, "dup-id.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- name: "dup"
|
|
id: "dup"
|
|
type: http
|
|
http:
|
|
url: "http://a.com"
|
|
- name: "dup"
|
|
id: "dup"
|
|
type: http
|
|
http:
|
|
url: "http://b.com"
|
|
`,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
await expect(loadConfig(configPath)).rejects.toThrow("target id 重复");
|
|
});
|
|
|
|
test("target id 为空字符串抛出错误", async () => {
|
|
const configPath = join(tempDir, "empty-id.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- id: ""
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
await expect(loadConfig(configPath)).rejects.toThrow("缺少 id 字段");
|
|
});
|
|
|
|
test("target id 命名不合法抛出错误", async () => {
|
|
const configPath = join(tempDir, "bad-id.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- id: "_invalid"
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
await expect(loadConfig(configPath)).rejects.toThrow("id 不符合命名规则");
|
|
});
|
|
|
|
test("target id 包含下划线和连字符通过", async () => {
|
|
const configPath = join(tempDir, "id-underscore-dash.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- id: "db_check-01"
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
);
|
|
const config = await loadConfig(configPath);
|
|
expect(config.targets[0]!.id).toBe("db_check-01");
|
|
});
|
|
|
|
test("targets 为空数组抛出错误", async () => {
|
|
const configPath = join(tempDir, "empty-targets.yaml");
|
|
await writeFile(configPath, `targets: []`);
|
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
await expect(loadConfig(configPath)).rejects.toThrow("至少一个 target");
|
|
});
|
|
|
|
test("无效端口号抛出错误", async () => {
|
|
const configPath = join(tempDir, "bad-port.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`server:
|
|
port: 99999
|
|
targets:
|
|
- name: "t"
|
|
id: "t"
|
|
type: http
|
|
http:
|
|
url: "http://a.com"
|
|
`,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
await expect(loadConfig(configPath)).rejects.toThrow("server.port 数值范围不合法");
|
|
});
|
|
|
|
test("非法 maxConcurrentChecks 抛出错误", async () => {
|
|
const configPath = join(tempDir, "bad-concurrency.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`runtime:
|
|
maxConcurrentChecks: -1
|
|
targets:
|
|
- name: "t"
|
|
id: "t"
|
|
type: http
|
|
http:
|
|
url: "http://a.com"
|
|
`,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
await expect(loadConfig(configPath)).rejects.toThrow("runtime.maxConcurrentChecks 数值范围不合法");
|
|
});
|
|
|
|
test("非法 size 格式抛出错误", async () => {
|
|
const configPath = join(tempDir, "bad-size.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`defaults:
|
|
http:
|
|
maxBodyBytes: "100TB"
|
|
targets:
|
|
- name: "t"
|
|
id: "t"
|
|
type: http
|
|
http:
|
|
url: "http://a.com"
|
|
`,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
await expect(loadConfig(configPath)).rejects.toThrow("无效的 size 格式");
|
|
});
|
|
|
|
test("非法 interval 格式抛出错误", async () => {
|
|
const configPath = join(tempDir, "bad-interval.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- name: "t"
|
|
id: "t"
|
|
type: http
|
|
interval: "30x"
|
|
http:
|
|
url: "http://a.com"
|
|
`,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
await expect(loadConfig(configPath)).rejects.toThrow("无效的时长格式");
|
|
});
|
|
|
|
test("解析 expect 配置", async () => {
|
|
const configPath = join(tempDir, "expect.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- name: "with-expect"
|
|
id: "with-expect"
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
expect:
|
|
status: [200, 201]
|
|
body:
|
|
- contains: "ok"
|
|
- json:
|
|
path: "$.status"
|
|
equals: "ok"
|
|
maxDurationMs: 3000
|
|
`,
|
|
);
|
|
|
|
const config = await loadConfig(configPath);
|
|
const t = config.targets[0]!;
|
|
if (t.type === "http") {
|
|
expect(t.expect).toEqual({
|
|
body: [{ contains: "ok" }, { json: { equals: "ok", path: "$.status" } }],
|
|
maxDurationMs: 3000,
|
|
status: [200, 201],
|
|
});
|
|
}
|
|
});
|
|
|
|
test("解析 cmd expect 配置", async () => {
|
|
const configPath = join(tempDir, "cmd-expect.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- name: "cmd-with-expect"
|
|
id: "cmd-with-expect"
|
|
type: cmd
|
|
cmd:
|
|
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 === "cmd") {
|
|
expect(t.expect).toEqual({
|
|
exitCode: [0, 2],
|
|
maxDurationMs: 5000,
|
|
stderr: [{ empty: true }],
|
|
stdout: [{ contains: "ok" }, { match: "done" }],
|
|
});
|
|
}
|
|
});
|
|
|
|
test("cmd 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"
|
|
id: "cwd-test"
|
|
type: cmd
|
|
cmd:
|
|
exec: "ls"
|
|
cwd: "scripts"
|
|
`,
|
|
);
|
|
|
|
const config = await loadConfig(configPath);
|
|
const t = config.targets[0] as ResolvedCommandTarget;
|
|
expect(t.cmd.cwd).toBe(join(subdir, "scripts"));
|
|
});
|
|
|
|
test("cmd env 覆盖", async () => {
|
|
const configPath = join(tempDir, "env.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- name: "env-test"
|
|
id: "env-test"
|
|
type: cmd
|
|
cmd:
|
|
exec: "echo"
|
|
env:
|
|
LANG: "C"
|
|
CUSTOM_VAR: "test"
|
|
`,
|
|
);
|
|
|
|
const config = await loadConfig(configPath);
|
|
const t = config.targets[0] as ResolvedCommandTarget;
|
|
expect(t.cmd.env["LANG"]).toBe("C");
|
|
expect(t.cmd.env["CUSTOM_VAR"]).toBe("test");
|
|
expect(Object.keys(t.cmd.env).some((key) => key.toUpperCase() === "PATH")).toBe(true);
|
|
});
|
|
|
|
test("解析 group 字段", async () => {
|
|
const configPath = join(tempDir, "group.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- name: "grouped"
|
|
id: "grouped"
|
|
type: http
|
|
group: "搜索引擎"
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
);
|
|
|
|
const config = await loadConfig(configPath);
|
|
expect(config.targets[0]!.group).toBe("搜索引擎");
|
|
});
|
|
|
|
test("group 字段默认为 default", async () => {
|
|
const configPath = join(tempDir, "no-group.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- name: "no-group"
|
|
id: "no-group"
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
);
|
|
|
|
const config = await loadConfig(configPath);
|
|
expect(config.targets[0]!.group).toBe("default");
|
|
});
|
|
|
|
test("非法 group 类型抛出错误", async () => {
|
|
const configPath = join(tempDir, "bad-group.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- name: "test"
|
|
id: "test"
|
|
type: http
|
|
group: 123
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
);
|
|
|
|
// 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"
|
|
id: "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"
|
|
id: "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"
|
|
id: "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"
|
|
id: "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"
|
|
id: "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"
|
|
id: "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"
|
|
id: "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"
|
|
id: "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"
|
|
id: "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"
|
|
id: "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"
|
|
id: "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"
|
|
id: "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"
|
|
id: "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"
|
|
id: "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"
|
|
id: "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"
|
|
id: "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"
|
|
id: "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"
|
|
id: "test"
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
unknownHttpField: "value"
|
|
expect:
|
|
status: [200]
|
|
unknownExpectField: "value"
|
|
body:
|
|
- contains: "ok"
|
|
note: "ignored"
|
|
`,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
await expect(loadConfig(configPath)).rejects.toThrow("unknownHttpField 是未知字段");
|
|
});
|
|
|
|
test("xpath path 非空字符串校验", async () => {
|
|
const configPath = join(tempDir, "bad-xpath-path.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- name: "test"
|
|
id: "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"
|
|
id: "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 类型不合法");
|
|
});
|
|
|
|
test("HTTP method 小写输入失败", async () => {
|
|
await expectConfigError(
|
|
"lowercase-method.yaml",
|
|
`targets:
|
|
- name: "test"
|
|
id: "test"
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
method: get
|
|
`,
|
|
"http.method 不在允许范围内",
|
|
);
|
|
});
|
|
|
|
test("defaults.http.method 触发未知字段错误", async () => {
|
|
await expectConfigError(
|
|
"unknown-default-method.yaml",
|
|
`defaults:
|
|
http:
|
|
method: POST
|
|
targets:
|
|
- name: "test"
|
|
id: "test"
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
"defaults.http.method 是未知字段",
|
|
);
|
|
});
|
|
|
|
test("HTTP method 大写输入通过", async () => {
|
|
const configPath = join(tempDir, "uppercase-method.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- name: "test"
|
|
id: "test"
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
method: POST
|
|
`,
|
|
);
|
|
const config = await loadConfig(configPath);
|
|
const target = config.targets[0] as ResolvedHttpTarget;
|
|
expect(target.type).toBe("http");
|
|
expect(target.http.method).toBe("POST");
|
|
});
|
|
|
|
test("动态 headers 和 env 允许任意键名", async () => {
|
|
const configPath = join(tempDir, "dynamic-maps.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`defaults:
|
|
http:
|
|
headers:
|
|
X-Default-Header: "default"
|
|
targets:
|
|
- name: "http-test"
|
|
id: "http-test"
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
headers:
|
|
X-Custom-Header: "custom"
|
|
expect:
|
|
headers:
|
|
X-Response-Header:
|
|
contains: "ok"
|
|
- name: "cmd-test"
|
|
id: "cmd-test"
|
|
type: cmd
|
|
cmd:
|
|
exec: "true"
|
|
env:
|
|
CUSTOM_ENV_NAME: "custom"
|
|
`,
|
|
);
|
|
const config = await loadConfig(configPath);
|
|
const http = config.targets[0] as ResolvedHttpTarget;
|
|
const cmdTarget = config.targets[1] as ResolvedCommandTarget;
|
|
expect(http.type).toBe("http");
|
|
expect(cmdTarget.type).toBe("cmd");
|
|
expect(http.http.headers["X-Default-Header"]).toBe("default");
|
|
expect(http.http.headers["X-Custom-Header"]).toBe("custom");
|
|
expect(cmdTarget.cmd.env["CUSTOM_ENV_NAME"]).toBe("custom");
|
|
});
|
|
|
|
test("cmd args 类型非法", async () => {
|
|
await expectConfigError(
|
|
"bad-cmd-args.yaml",
|
|
`targets:
|
|
- name: "cmd"
|
|
id: "cmd"
|
|
type: cmd
|
|
cmd:
|
|
exec: "echo"
|
|
args: "hello"
|
|
`,
|
|
"cmd.args 类型不合法",
|
|
);
|
|
});
|
|
|
|
test("cmd cwd 类型非法", async () => {
|
|
await expectConfigError(
|
|
"bad-cmd-cwd.yaml",
|
|
`targets:
|
|
- name: "cmd"
|
|
id: "cmd"
|
|
type: cmd
|
|
cmd:
|
|
exec: "echo"
|
|
cwd: 123
|
|
`,
|
|
"cmd.cwd 类型不合法",
|
|
);
|
|
});
|
|
|
|
test("cmd env 值类型非法", async () => {
|
|
await expectConfigError(
|
|
"bad-cmd-env.yaml",
|
|
`targets:
|
|
- name: "cmd"
|
|
id: "cmd"
|
|
type: cmd
|
|
cmd:
|
|
exec: "echo"
|
|
env:
|
|
COUNT: 123
|
|
`,
|
|
"cmd.env.COUNT 类型不合法",
|
|
);
|
|
});
|
|
|
|
test("cmd maxOutputBytes 非法", async () => {
|
|
await expectConfigError(
|
|
"bad-cmd-max-output.yaml",
|
|
`targets:
|
|
- name: "cmd"
|
|
id: "cmd"
|
|
type: cmd
|
|
cmd:
|
|
exec: "echo"
|
|
maxOutputBytes: "1TB"
|
|
`,
|
|
"maxOutputBytes 无效的 size 格式",
|
|
);
|
|
});
|
|
|
|
test("cmd expect exitCode 类型非法", async () => {
|
|
await expectConfigError(
|
|
"bad-cmd-exit-code.yaml",
|
|
`targets:
|
|
- name: "cmd"
|
|
id: "cmd"
|
|
type: cmd
|
|
cmd:
|
|
exec: "echo"
|
|
expect:
|
|
exitCode: [1.5]
|
|
`,
|
|
"expect.exitCode[0] 类型不合法",
|
|
);
|
|
});
|
|
|
|
test("cmd stdout 空 text rule 非法", async () => {
|
|
await expectConfigError(
|
|
"bad-cmd-stdout-empty.yaml",
|
|
`targets:
|
|
- name: "cmd"
|
|
id: "cmd"
|
|
type: cmd
|
|
cmd:
|
|
exec: "echo"
|
|
expect:
|
|
stdout:
|
|
- {}
|
|
`,
|
|
"stdout[0] 必须包含至少一个合法 operator",
|
|
);
|
|
});
|
|
|
|
test("cmd stderr 未知 operator 非法", async () => {
|
|
await expectConfigError(
|
|
"bad-cmd-stderr-operator.yaml",
|
|
`targets:
|
|
- name: "cmd"
|
|
id: "cmd"
|
|
type: cmd
|
|
cmd:
|
|
exec: "echo"
|
|
expect:
|
|
stderr:
|
|
- foo: "bar"
|
|
`,
|
|
"expect.stderr[0].foo 是未知字段",
|
|
);
|
|
});
|
|
|
|
test("cmd stdout match 正则非法", async () => {
|
|
await expectConfigError(
|
|
"bad-cmd-stdout-regex.yaml",
|
|
`targets:
|
|
- name: "cmd"
|
|
id: "cmd"
|
|
type: cmd
|
|
cmd:
|
|
exec: "echo"
|
|
expect:
|
|
stdout:
|
|
- match: "[invalid"
|
|
`,
|
|
"stdout[0].match 正则不合法",
|
|
);
|
|
});
|
|
|
|
test("cmd expect 未知字段失败", async () => {
|
|
await expectConfigError(
|
|
"bad-cmd-expect-unknown.yaml",
|
|
`targets:
|
|
- name: "cmd"
|
|
id: "cmd"
|
|
type: cmd
|
|
cmd:
|
|
exec: "echo"
|
|
expect:
|
|
status: [200]
|
|
`,
|
|
"expect.status 是未知字段",
|
|
);
|
|
});
|
|
|
|
test("retention 默认值为 7d", async () => {
|
|
const configPath = join(tempDir, "retention-default.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- name: "test"
|
|
id: "test"
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
);
|
|
const config = await loadConfig(configPath);
|
|
expect(config.retentionMs).toBe(604800000);
|
|
});
|
|
|
|
test("retention 自定义值", async () => {
|
|
const configPath = join(tempDir, "retention-custom.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`runtime:
|
|
retention: "24h"
|
|
targets:
|
|
- name: "test"
|
|
id: "test"
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
);
|
|
const config = await loadConfig(configPath);
|
|
expect(config.retentionMs).toBe(86400000);
|
|
});
|
|
|
|
test("retention 非法格式抛出错误", async () => {
|
|
await expectConfigError(
|
|
"bad-retention.yaml",
|
|
`runtime:
|
|
retention: "7x"
|
|
targets:
|
|
- name: "test"
|
|
id: "test"
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
"无效的时长格式",
|
|
);
|
|
});
|
|
|
|
test("解析 description 字段", async () => {
|
|
const configPath = join(tempDir, "description.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- id: "api-health"
|
|
description: "检查生产 API 健康状态"
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
);
|
|
const config = await loadConfig(configPath);
|
|
expect(config.targets[0]!.description).toBe("检查生产 API 健康状态");
|
|
});
|
|
|
|
test("description 使用变量替换", async () => {
|
|
const configPath = join(tempDir, "description-var.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`variables:
|
|
env: "生产"
|
|
targets:
|
|
- id: "api-health"
|
|
description: "\${env} 环境健康检查"
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
);
|
|
const config = await loadConfig(configPath);
|
|
expect(config.targets[0]!.description).toBe("生产 环境健康检查");
|
|
});
|
|
|
|
test("description 缺省为 null", async () => {
|
|
const configPath = join(tempDir, "no-description.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- id: "api-health"
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
);
|
|
const config = await loadConfig(configPath);
|
|
expect(config.targets[0]!.description).toBeNull();
|
|
});
|
|
|
|
test("description 为空字符串通过", async () => {
|
|
const configPath = join(tempDir, "empty-description.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- id: "api-health"
|
|
description: ""
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
);
|
|
const config = await loadConfig(configPath);
|
|
expect(config.targets[0]!.description).toBe("");
|
|
});
|
|
|
|
test("description 非字符串抛出错误", async () => {
|
|
const configPath = join(tempDir, "bad-description-type.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- id: "api-health"
|
|
description: 123
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
await expect(loadConfig(configPath)).rejects.toThrow("description");
|
|
});
|
|
|
|
test("description 超过 500 字符抛出错误", async () => {
|
|
const configPath = join(tempDir, "long-description.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- id: "api-health"
|
|
description: "${"a".repeat(501)}"
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
await expect(loadConfig(configPath)).rejects.toThrow("description");
|
|
});
|
|
|
|
test("变量替换后 description 超长抛出错误", async () => {
|
|
const configPath = join(tempDir, "var-long-description.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`variables:
|
|
prefix: "${"x".repeat(490)}"
|
|
targets:
|
|
- id: "api-health"
|
|
description: "\${prefix}${"a".repeat(15)}"
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
await expect(loadConfig(configPath)).rejects.toThrow("description");
|
|
});
|
|
|
|
test("id 超过 30 字符抛出错误", async () => {
|
|
const configPath = join(tempDir, "long-id.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- id: "${"a".repeat(31)}"
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
await expect(loadConfig(configPath)).rejects.toThrow("id");
|
|
});
|
|
|
|
test("name 超过 30 字符抛出错误", async () => {
|
|
const configPath = join(tempDir, "long-name.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- id: "test"
|
|
name: "${"a".repeat(31)}"
|
|
type: http
|
|
http:
|
|
url: "http://example.com"
|
|
`,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
await expect(loadConfig(configPath)).rejects.toThrow("name");
|
|
});
|
|
|
|
test("解析最简 tcp 配置", async () => {
|
|
const configPath = join(tempDir, "minimal-tcp.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- id: "redis-port"
|
|
type: tcp
|
|
tcp:
|
|
host: "127.0.0.1"
|
|
port: 6379
|
|
`,
|
|
);
|
|
|
|
const config = await loadConfig(configPath);
|
|
expect(config.targets).toHaveLength(1);
|
|
const t = config.targets[0]! as ResolvedTcpTarget;
|
|
expect(t.type).toBe("tcp");
|
|
expect(t.id).toBe("redis-port");
|
|
expect(t.name).toBeNull();
|
|
expect(t.tcp.host).toBe("127.0.0.1");
|
|
expect(t.tcp.port).toBe(6379);
|
|
expect(t.tcp.readBanner).toBe(false);
|
|
expect(t.tcp.bannerReadTimeout).toBe(2000);
|
|
expect(t.tcp.maxBannerBytes).toBe(4096);
|
|
expect(t.group).toBe("default");
|
|
expect(t.intervalMs).toBe(30000);
|
|
expect(t.timeoutMs).toBe(10000);
|
|
});
|
|
|
|
test("tcp 缺少 host 抛出错误", async () => {
|
|
await expectConfigError(
|
|
"tcp-no-host.yaml",
|
|
`targets:
|
|
- id: "t"
|
|
type: tcp
|
|
tcp:
|
|
port: 80
|
|
`,
|
|
"tcp.host",
|
|
);
|
|
});
|
|
|
|
test("tcp 缺少 port 抛出错误", async () => {
|
|
await expectConfigError(
|
|
"tcp-no-port.yaml",
|
|
`targets:
|
|
- id: "t"
|
|
type: tcp
|
|
tcp:
|
|
host: "127.0.0.1"
|
|
`,
|
|
"tcp.port",
|
|
);
|
|
});
|
|
|
|
test("tcp 非法端口范围抛出错误", async () => {
|
|
await expectConfigError(
|
|
"tcp-bad-port.yaml",
|
|
`targets:
|
|
- id: "t"
|
|
type: tcp
|
|
tcp:
|
|
host: "127.0.0.1"
|
|
port: 99999
|
|
`,
|
|
"tcp.port",
|
|
);
|
|
});
|
|
|
|
test("tcp 未知分组字段抛出错误", async () => {
|
|
await expectConfigError(
|
|
"tcp-unknown-field.yaml",
|
|
`targets:
|
|
- id: "t"
|
|
type: tcp
|
|
tcp:
|
|
host: "127.0.0.1"
|
|
port: 80
|
|
tls: true
|
|
`,
|
|
"是未知字段",
|
|
);
|
|
});
|
|
|
|
test("tcp readBanner 开启并配置 expect.banner", async () => {
|
|
const configPath = join(tempDir, "tcp-banner.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- id: "smtp-check"
|
|
type: tcp
|
|
tcp:
|
|
host: "127.0.0.1"
|
|
port: 25
|
|
readBanner: true
|
|
expect:
|
|
banner:
|
|
contains: "ESMTP"
|
|
`,
|
|
);
|
|
|
|
const config = await loadConfig(configPath);
|
|
const t = config.targets[0]! as ResolvedTcpTarget;
|
|
expect(t.tcp.readBanner).toBe(true);
|
|
expect(t.expect?.banner).toEqual({ contains: "ESMTP" });
|
|
});
|
|
|
|
test("tcp expect.banner 未开启 readBanner 抛出错误", async () => {
|
|
await expectConfigError(
|
|
"tcp-banner-no-read.yaml",
|
|
`targets:
|
|
- id: "t"
|
|
type: tcp
|
|
tcp:
|
|
host: "127.0.0.1"
|
|
port: 25
|
|
expect:
|
|
banner:
|
|
contains: "ESMTP"
|
|
`,
|
|
"banner 断言需要启用 tcp.readBanner",
|
|
);
|
|
});
|
|
|
|
test("tcp defaults 覆盖 banner 参数", async () => {
|
|
const configPath = join(tempDir, "tcp-defaults.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`defaults:
|
|
tcp:
|
|
bannerReadTimeout: 1000
|
|
maxBannerBytes: "8KB"
|
|
targets:
|
|
- id: "t1"
|
|
type: tcp
|
|
tcp:
|
|
host: "127.0.0.1"
|
|
port: 80
|
|
- id: "t2"
|
|
type: tcp
|
|
tcp:
|
|
host: "127.0.0.1"
|
|
port: 81
|
|
bannerReadTimeout: 3000
|
|
`,
|
|
);
|
|
|
|
const config = await loadConfig(configPath);
|
|
const t1 = config.targets[0]! as ResolvedTcpTarget;
|
|
expect(t1.tcp.bannerReadTimeout).toBe(1000);
|
|
expect(t1.tcp.maxBannerBytes).toBe(8192);
|
|
|
|
const t2 = config.targets[1]! as ResolvedTcpTarget;
|
|
expect(t2.tcp.bannerReadTimeout).toBe(3000);
|
|
expect(t2.tcp.maxBannerBytes).toBe(8192);
|
|
});
|
|
|
|
test("tcp expect 未知字段抛出错误", async () => {
|
|
await expectConfigError(
|
|
"tcp-unknown-expect.yaml",
|
|
`targets:
|
|
- id: "t"
|
|
type: tcp
|
|
tcp:
|
|
host: "127.0.0.1"
|
|
port: 80
|
|
expect:
|
|
status: [200]
|
|
`,
|
|
"是未知字段",
|
|
);
|
|
});
|
|
|
|
test("tcp expect connected 和 maxDurationMs", async () => {
|
|
const configPath = join(tempDir, "tcp-expect-connected.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- id: "t"
|
|
type: tcp
|
|
tcp:
|
|
host: "127.0.0.1"
|
|
port: 80
|
|
expect:
|
|
connected: false
|
|
maxDurationMs: 5000
|
|
`,
|
|
);
|
|
|
|
const config = await loadConfig(configPath);
|
|
const t = config.targets[0]! as ResolvedTcpTarget;
|
|
expect(t.expect?.connected).toBe(false);
|
|
expect(t.expect?.maxDurationMs).toBe(5000);
|
|
});
|
|
|
|
test("解析最简 ping 配置", async () => {
|
|
const configPath = join(tempDir, "minimal-ping.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- id: "gateway"
|
|
type: ping
|
|
ping:
|
|
host: "10.0.0.1"
|
|
`,
|
|
);
|
|
|
|
const config = await loadConfig(configPath);
|
|
expect(config.targets).toHaveLength(1);
|
|
const t = config.targets[0]! as ResolvedPingTarget;
|
|
expect(t.type).toBe("ping");
|
|
expect(t.ping).toEqual({ count: 3, host: "10.0.0.1", packetSize: 56 });
|
|
expect(t.group).toBe("default");
|
|
expect(t.intervalMs).toBe(30000);
|
|
expect(t.timeoutMs).toBe(10000);
|
|
});
|
|
|
|
test("解析 ping expect 配置", async () => {
|
|
const configPath = join(tempDir, "ping-expect.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
`targets:
|
|
- id: "gateway"
|
|
type: ping
|
|
ping:
|
|
host: "10.0.0.1"
|
|
count: 5
|
|
packetSize: 1472
|
|
expect:
|
|
alive: true
|
|
maxPacketLoss: 10
|
|
maxAvgLatencyMs: 200
|
|
maxMaxLatencyMs: 500
|
|
maxDurationMs: 5000
|
|
`,
|
|
);
|
|
|
|
const config = await loadConfig(configPath);
|
|
const t = config.targets[0]! as ResolvedPingTarget;
|
|
expect(t.ping).toEqual({ count: 5, host: "10.0.0.1", packetSize: 1472 });
|
|
expect(t.expect).toEqual({
|
|
alive: true,
|
|
maxAvgLatencyMs: 200,
|
|
maxDurationMs: 5000,
|
|
maxMaxLatencyMs: 500,
|
|
maxPacketLoss: 10,
|
|
});
|
|
});
|
|
|
|
test("ping 缺少 host 抛出错误", async () => {
|
|
await expectConfigError(
|
|
"ping-no-host.yaml",
|
|
`targets:
|
|
- id: "gateway"
|
|
type: ping
|
|
ping: {}
|
|
`,
|
|
"ping.host",
|
|
);
|
|
});
|
|
|
|
test("ping count 非法抛出错误", async () => {
|
|
await expectConfigError(
|
|
"ping-bad-count.yaml",
|
|
`targets:
|
|
- id: "gateway"
|
|
type: ping
|
|
ping:
|
|
host: "10.0.0.1"
|
|
count: 0
|
|
`,
|
|
"ping.count",
|
|
);
|
|
});
|
|
|
|
test("ping expect 未知字段抛出错误", async () => {
|
|
await expectConfigError(
|
|
"ping-unknown-expect.yaml",
|
|
`targets:
|
|
- id: "gateway"
|
|
type: ping
|
|
ping:
|
|
host: "10.0.0.1"
|
|
expect:
|
|
status: [200]
|
|
`,
|
|
"expect.status 是未知字段",
|
|
);
|
|
});
|
|
});
|