1
0

feat: WS checker,支持可达性检测和单次请求-响应交互验证

This commit is contained in:
2026-05-25 14:13:43 +08:00
parent 714b635aef
commit c1db793073
20 changed files with 2339 additions and 4 deletions

View File

@@ -72,8 +72,8 @@ describe("CheckerRegistry", () => {
const second = createDefaultCheckerRegistry();
first.register(createChecker("custom"));
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "dns", "custom"]);
expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "dns"]);
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "dns", "ws", "custom"]);
expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "dns", "ws"]);
expect(
first.definitions.every((checker) => checker.schemas.authoring.config && checker.schemas.normalized.expect),
).toBe(true);

View File

@@ -0,0 +1,155 @@
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 { ResolvedWsTarget } from "../../../../../src/server/checker/runner/ws/types";
import { loadConfig } from "../../../../../src/server/checker/config-loader";
describe("loadConfig with ws checker", () => {
let tempDir: string;
beforeAll(async () => {
tempDir = join(tmpdir(), `ws-cfg-test-${Date.now()}`);
await mkdir(tempDir, { recursive: true });
});
afterAll(async () => {
await rm(tempDir, { force: true, recursive: true });
});
test("解析最简 ws 配置", async () => {
const configPath = join(tempDir, "minimal-ws.yaml");
await writeFile(
configPath,
`targets:
- id: "ws-test"
type: ws
ws:
url: "ws://example.com/ws"
`,
);
const config = await loadConfig(configPath);
expect(config.targets).toHaveLength(1);
const t = config.targets[0]! as ResolvedWsTarget;
expect(t.type).toBe("ws");
expect(t.id).toBe("ws-test");
expect(t.ws.url).toBe("ws://example.com/ws");
expect(t.ws.headers).toEqual({});
expect(t.ws.ignoreSSL).toBe(false);
expect(t.ws.maxMessageBytes).toBe(4096);
expect(t.ws.receiveTimeout).toBe(5000);
expect(t.ws.send).toBeUndefined();
expect(t.ws.subprotocols).toEqual([]);
expect(t.expect).toEqual({ connected: true });
});
test("解析带 send 的 ws 配置", async () => {
const configPath = join(tempDir, "ws-send.yaml");
await writeFile(
configPath,
`targets:
- id: "ws-echo"
name: "WS Echo 检查"
type: ws
ws:
url: "wss://api.example.com/ws"
headers:
Authorization: "Bearer token"
subprotocols:
- "json"
ignoreSSL: true
send: "ping"
receiveTimeout: 3000
maxMessageBytes: "8KB"
expect:
message:
- contains: "pong"
durationMs:
lte: 5000
`,
);
const config = await loadConfig(configPath);
expect(config.targets).toHaveLength(1);
const t = config.targets[0]! as ResolvedWsTarget;
expect(t.type).toBe("ws");
expect(t.ws.url).toBe("wss://api.example.com/ws");
expect(t.ws.headers).toEqual({ Authorization: "Bearer token" });
expect(t.ws.ignoreSSL).toBe(true);
expect(t.ws.maxMessageBytes).toBe(8192);
expect(t.ws.receiveTimeout).toBe(3000);
expect(t.ws.send).toBe("ping");
expect(t.ws.subprotocols).toEqual(["json"]);
expect(t.expect?.connected).toBe(true);
expect(t.expect?.message).toBeDefined();
expect(t.expect?.durationMs).toEqual({ lte: 5000 });
});
test("ws 缺少 url 抛出错误", async () => {
const configPath = join(tempDir, "ws-no-url.yaml");
await writeFile(
configPath,
`targets:
- id: "t"
type: ws
ws: {}
`,
);
let error: unknown;
try {
await loadConfig(configPath);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain("ws.url");
});
test("ws url 非 ws/wss 协议抛出错误", async () => {
const configPath = join(tempDir, "ws-bad-url.yaml");
await writeFile(
configPath,
`targets:
- id: "t"
type: ws
ws:
url: "http://example.com"
`,
);
let error: unknown;
try {
await loadConfig(configPath);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain("ws:// 或 wss://");
});
test("ws expect.message 未配置 send 抛出错误", async () => {
const configPath = join(tempDir, "ws-no-send.yaml");
await writeFile(
configPath,
`targets:
- id: "t"
type: ws
ws:
url: "ws://example.com"
expect:
message:
- contains: "pong"
`,
);
let error: unknown;
try {
await loadConfig(configPath);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain("send");
});
});

View File

@@ -0,0 +1,201 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { CheckerContext } from "../../../../../src/server/checker/runner/types";
import type { ResolvedWsTarget } from "../../../../../src/server/checker/runner/ws/types";
import { WsChecker } from "../../../../../src/server/checker/runner/ws/execute";
function createEchoServer() {
return Bun.serve({
fetch(req, server) {
const success = server.upgrade(req);
if (!success) return new Response("Upgrade failed", { status: 500 });
return undefined;
},
port: 0,
websocket: {
close() {
/* ws close */
},
message(ws, message) {
ws.send(message);
},
open() {
/* ws open */
},
},
});
}
function createNoReplyServer() {
return Bun.serve({
fetch(req, server) {
const success = server.upgrade(req);
if (!success) return new Response("Upgrade failed", { status: 500 });
return undefined;
},
port: 0,
websocket: {
close() {
/* ws close */
},
message() {
/* no reply */
},
open() {
/* ws open */
},
},
});
}
function createRejectServer() {
return Bun.serve({
fetch() {
return new Response("Forbidden", { status: 403 });
},
port: 0,
});
}
function makeContext(overrides?: Partial<CheckerContext>): CheckerContext {
return {
signal: AbortSignal.timeout(15000),
...overrides,
};
}
function makeWsTarget(overrides?: Partial<ResolvedWsTarget>): ResolvedWsTarget {
return {
description: null,
expect: { connected: true },
group: "default",
id: "test-ws",
intervalMs: 30000,
name: null,
timeoutMs: 10000,
type: "ws",
ws: {
headers: {},
ignoreSSL: false,
maxMessageBytes: 4096,
receiveTimeout: 5000,
subprotocols: [],
url: "ws://127.0.0.1:19999/ws",
},
...overrides,
};
}
let echoServer: ReturnType<typeof createEchoServer>;
let noReplyServer: ReturnType<typeof createNoReplyServer>;
let rejectServer: ReturnType<typeof createRejectServer>;
beforeAll(() => {
echoServer = createEchoServer();
noReplyServer = createNoReplyServer();
rejectServer = createRejectServer();
});
afterAll(async () => {
await echoServer.stop();
await noReplyServer.stop();
await rejectServer.stop();
});
describe("WsChecker execute", () => {
const checker = new WsChecker();
test("可达性检查 - 连接成功", async () => {
const target = makeWsTarget({ ws: { ...makeWsTarget().ws, url: `ws://127.0.0.1:${echoServer.port}` } });
const result = await checker.execute(target, makeContext());
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
expect(result.observation!["connected"]).toBe(true);
});
test("可达性检查 - 连接失败", async () => {
const target = makeWsTarget({ ws: { ...makeWsTarget().ws, url: "ws://127.0.0.1:1" } });
const result = await checker.execute(target, makeContext());
expect(result.matched).toBe(false);
expect(result.failure).not.toBeNull();
expect(result.observation!["connected"]).toBe(false);
});
test("可达性检查 - 连接失败但 expect.connected=false", async () => {
const target = makeWsTarget({
expect: { connected: false },
ws: { ...makeWsTarget().ws, url: "ws://127.0.0.1:1" },
});
const result = await checker.execute(target, makeContext());
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
test("交互模式 - 发送消息并收到响应", async () => {
const target = makeWsTarget({
expect: {
connected: true,
message: [{ kind: "value" as const, matcher: { equals: "ping" } }],
},
ws: {
...makeWsTarget().ws,
send: "ping",
url: `ws://127.0.0.1:${echoServer.port}`,
},
});
const result = await checker.execute(target, makeContext());
expect(result.matched).toBe(true);
expect(result.observation!["message"]).toBe("ping");
expect(result.observation!["messageSize"]).toBe(4);
});
test("交互模式 - 消息不匹配", async () => {
const target = makeWsTarget({
expect: {
connected: true,
message: [{ kind: "value" as const, matcher: { equals: "pong" } }],
},
ws: {
...makeWsTarget().ws,
send: "ping",
url: `ws://127.0.0.1:${echoServer.port}`,
},
});
const result = await checker.execute(target, makeContext());
expect(result.matched).toBe(false);
expect(result.failure).not.toBeNull();
});
test("交互模式 - receiveTimeout 超时", async () => {
const target = makeWsTarget({
ws: {
...makeWsTarget().ws,
receiveTimeout: 500,
send: "ping",
url: `ws://127.0.0.1:${noReplyServer.port}`,
},
});
const result = await checker.execute(target, makeContext());
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("message");
});
test("HTTP 403 握手失败", async () => {
const target = makeWsTarget({ ws: { ...makeWsTarget().ws, url: `ws://127.0.0.1:${rejectServer.port}` } });
const result = await checker.execute(target, makeContext());
expect(result.matched).toBe(false);
expect(result.observation!["connected"]).toBe(false);
});
test("buildDetail 连接成功", () => {
const detail = checker.buildDetail({ connected: true, connectTimeMs: 50, message: "hello" });
expect(detail).toContain("connected");
expect(detail).toContain("hello");
});
test("buildDetail 连接失败", () => {
const detail = checker.buildDetail({ connected: false, error: "connection refused" });
expect(detail).toContain("connection failed");
});
});

View File

@@ -0,0 +1,95 @@
import { describe, expect, test } from "bun:test";
import type { ResolveContext } from "../../../../../src/server/checker/runner/types";
import type { ResolvedWsTarget } from "../../../../../src/server/checker/runner/ws/types";
import type { RawTargetConfig } from "../../../../../src/server/checker/types";
import { checkerRegistry } from "../../../../../src/server/checker/runner";
function asWs(resolved: ReturnType<ReturnType<typeof checkerRegistry.get>["resolve"]>): ResolvedWsTarget {
return resolved as ResolvedWsTarget;
}
function makeRawTarget(overrides?: Partial<RawTargetConfig>): RawTargetConfig {
return {
id: "test-ws",
type: "ws",
ws: { url: "ws://example.com/ws" },
...overrides,
};
}
function makeResolveContext(overrides?: Partial<ResolveContext>): ResolveContext {
return {
configDir: "/tmp",
defaultIntervalMs: 30000,
defaultTimeoutMs: 10000,
...overrides,
};
}
describe("WsChecker resolve", () => {
const checker = checkerRegistry.tryGet("ws")!;
test("最简 target 填充默认值", () => {
const resolved = asWs(checker.resolve(makeRawTarget(), makeResolveContext()));
expect(resolved.type).toBe("ws");
expect(resolved.ws.url).toBe("ws://example.com/ws");
expect(resolved.ws.headers).toEqual({});
expect(resolved.ws.ignoreSSL).toBe(false);
expect(resolved.ws.maxMessageBytes).toBe(4096);
expect(resolved.ws.receiveTimeout).toBe(5000);
expect(resolved.ws.send).toBeUndefined();
expect(resolved.ws.subprotocols).toEqual([]);
expect(resolved.expect).toEqual({ connected: true });
expect(resolved.group).toBe("default");
expect(resolved.intervalMs).toBe(30000);
expect(resolved.timeoutMs).toBe(10000);
});
test("完整配置正确 resolve", () => {
const raw = makeRawTarget({
expect: { connected: true, durationMs: { lte: 5000 } },
ws: {
headers: { Authorization: "Bearer token" },
ignoreSSL: true,
maxMessageBytes: "8KB",
receiveTimeout: 3000,
send: "ping",
subprotocols: ["json"],
url: "wss://api.example.com/ws",
},
});
const resolved = asWs(checker.resolve(raw, makeResolveContext()));
expect(resolved.ws.url).toBe("wss://api.example.com/ws");
expect(resolved.ws.headers).toEqual({ Authorization: "Bearer token" });
expect(resolved.ws.ignoreSSL).toBe(true);
expect(resolved.ws.maxMessageBytes).toBe(8192);
expect(resolved.ws.receiveTimeout).toBe(3000);
expect(resolved.ws.send).toBe("ping");
expect(resolved.ws.subprotocols).toEqual(["json"]);
expect(resolved.expect?.connected).toBe(true);
});
test("expect 默认 connected=true", () => {
const raw = makeRawTarget({ expect: { durationMs: { lte: 1000 } } });
const resolved = asWs(checker.resolve(raw, makeResolveContext()));
expect(resolved.expect?.connected).toBe(true);
});
test("expect.connected=false 保留", () => {
const raw = makeRawTarget({ expect: { connected: false } });
const resolved = asWs(checker.resolve(raw, makeResolveContext()));
expect(resolved.expect?.connected).toBe(false);
});
test("serialize 返回正确格式", () => {
const resolved = asWs(checker.resolve(makeRawTarget(), makeResolveContext()));
const serialized = checker.serialize(resolved);
expect(serialized.target).toBe("ws://example.com/ws");
const config = JSON.parse(serialized.config) as Record<string, unknown>;
expect(config["url"]).toBe("ws://example.com/ws");
expect(config["ignoreSSL"]).toBe(false);
expect(config["receiveTimeout"]).toBe(5000);
});
});

View File

@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test";
import { checkerRegistry } from "../../../../../src/server/checker/runner";
describe("WsChecker schema", () => {
const checker = checkerRegistry.tryGet("ws");
test("ws checker 注册到 registry", () => {
expect(checker).toBeDefined();
expect(checker?.type).toBe("ws");
expect(checker?.configKey).toBe("ws");
});
test("schemas 包含 authoring 和 normalized config/expect", () => {
expect(checker).toBeDefined();
expect(Object.keys(checker!.schemas).sort()).toEqual(["authoring", "normalized"].sort());
expect(checker!.schemas.authoring.config).toBeDefined();
expect(checker!.schemas.authoring.expect).toBeDefined();
expect(checker!.schemas.normalized.config).toBeDefined();
expect(checker!.schemas.normalized.expect).toBeDefined();
});
});

View File

@@ -0,0 +1,215 @@
import { describe, expect, test } from "bun:test";
import type { CheckerValidationInput } from "../../../../../src/server/checker/runner/types";
import { validateWsConfig } from "../../../../../src/server/checker/runner/ws/validate";
function makeInput(targets: unknown[]): CheckerValidationInput {
return {
targets: targets as CheckerValidationInput["targets"],
};
}
describe("validateWsConfig", () => {
test("合法 ws target 无错误", () => {
const issues = validateWsConfig(makeInput([{ id: "t1", type: "ws", ws: { url: "ws://example.com" } }]));
expect(issues).toHaveLength(0);
});
test("合法 wss target 无错误", () => {
const issues = validateWsConfig(makeInput([{ id: "t1", type: "ws", ws: { url: "wss://example.com/ws" } }]));
expect(issues).toHaveLength(0);
});
test("缺少 ws 分组", () => {
const issues = validateWsConfig(makeInput([{ id: "t1", type: "ws" }]));
expect(issues.length).toBeGreaterThan(0);
expect(issues.some((i) => i.message.includes("ws"))).toBe(true);
});
test("缺少 url", () => {
const issues = validateWsConfig(makeInput([{ id: "t1", type: "ws", ws: {} }]));
expect(issues.some((i) => i.path.includes("url"))).toBe(true);
});
test("url 非 ws/wss 协议报错", () => {
const issues = validateWsConfig(makeInput([{ id: "t1", type: "ws", ws: { url: "http://example.com" } }]));
expect(issues.some((i) => i.code === "invalid-url")).toBe(true);
});
test("url 格式非法报错", () => {
const issues = validateWsConfig(makeInput([{ id: "t1", type: "ws", ws: { url: "not-a-url" } }]));
expect(issues.some((i) => i.code === "invalid-url")).toBe(true);
});
test("subprotocols 非数组报错", () => {
const issues = validateWsConfig(
makeInput([{ id: "t1", type: "ws", ws: { subprotocols: "json", url: "ws://example.com" } }]),
);
expect(issues.some((i) => i.path.includes("subprotocols"))).toBe(true);
});
test("subprotocols 元素为空字符串报错", () => {
const issues = validateWsConfig(
makeInput([{ id: "t1", type: "ws", ws: { subprotocols: [""], url: "ws://example.com" } }]),
);
expect(issues.some((i) => i.path.includes("subprotocols"))).toBe(true);
});
test("subprotocols 合法无错误", () => {
const issues = validateWsConfig(
makeInput([{ id: "t1", type: "ws", ws: { subprotocols: ["json", "binary"], url: "ws://example.com" } }]),
);
expect(issues).toHaveLength(0);
});
test("ignoreSSL 非布尔值报错", () => {
const issues = validateWsConfig(
makeInput([{ id: "t1", type: "ws", ws: { ignoreSSL: "yes", url: "ws://example.com" } }]),
);
expect(issues.some((i) => i.path.includes("ignoreSSL"))).toBe(true);
});
test("receiveTimeout 非数字报错", () => {
const issues = validateWsConfig(
makeInput([{ id: "t1", type: "ws", ws: { receiveTimeout: "slow", url: "ws://example.com" } }]),
);
expect(issues.some((i) => i.path.includes("receiveTimeout"))).toBe(true);
});
test("receiveTimeout 为负数报错", () => {
const issues = validateWsConfig(
makeInput([{ id: "t1", type: "ws", ws: { receiveTimeout: -1, url: "ws://example.com" } }]),
);
expect(issues.some((i) => i.path.includes("receiveTimeout"))).toBe(true);
});
test("maxMessageBytes 非法值报错", () => {
const issues = validateWsConfig(
makeInput([{ id: "t1", type: "ws", ws: { maxMessageBytes: -1, url: "ws://example.com" } }]),
);
expect(issues.some((i) => i.path.includes("maxMessageBytes"))).toBe(true);
});
test("ws 分组未知字段", () => {
const issues = validateWsConfig(makeInput([{ id: "t1", type: "ws", ws: { tls: true, url: "ws://example.com" } }]));
expect(issues.some((i) => i.message.includes("未知字段"))).toBe(true);
});
test("expect.message 未配置 ws.send 报错", () => {
const issues = validateWsConfig(
makeInput([
{
expect: { message: [{ contains: "pong" }] },
id: "t1",
type: "ws",
ws: { url: "ws://example.com" },
},
]),
);
expect(issues.some((i) => i.message.includes("send"))).toBe(true);
});
test("expect.message 配置 ws.send 无错误", () => {
const issues = validateWsConfig(
makeInput([
{
expect: { message: [{ contains: "pong" }] },
id: "t1",
type: "ws",
ws: { send: "ping", url: "ws://example.com" },
},
]),
);
expect(issues).toHaveLength(0);
});
test("expect.connected 非布尔值报错", () => {
const issues = validateWsConfig(
makeInput([
{
expect: { connected: "yes" },
id: "t1",
type: "ws",
ws: { url: "ws://example.com" },
},
]),
);
expect(issues.some((i) => i.path.includes("connected"))).toBe(true);
});
test("expect.connected=false 时 expect.message 报错", () => {
const issues = validateWsConfig(
makeInput([
{
expect: { connected: false, message: [{ contains: "pong" }] },
id: "t1",
type: "ws",
ws: { send: "ping", url: "ws://example.com" },
},
]),
);
expect(issues.some((i) => i.message.includes("connected"))).toBe(true);
});
test("expect.connected=false 时 expect.handshakeHeaders 报错", () => {
const issues = validateWsConfig(
makeInput([
{
expect: { connected: false, handshakeHeaders: { "Sec-WebSocket-Protocol": { equals: "json" } } },
id: "t1",
type: "ws",
ws: { url: "ws://example.com" },
},
]),
);
expect(issues.some((i) => i.message.includes("connected"))).toBe(true);
});
test("expect.connected=false 时 expect.connectTimeMs 报错", () => {
const issues = validateWsConfig(
makeInput([
{
expect: { connected: false, connectTimeMs: { lte: 1000 } },
id: "t1",
type: "ws",
ws: { url: "ws://example.com" },
},
]),
);
expect(issues.some((i) => i.message.includes("connected"))).toBe(true);
});
test("expect.connected=false 单独配置合法", () => {
const issues = validateWsConfig(
makeInput([
{
expect: { connected: false },
id: "t1",
type: "ws",
ws: { url: "ws://example.com" },
},
]),
);
expect(issues).toHaveLength(0);
});
test("expect 未知字段报错", () => {
const issues = validateWsConfig(
makeInput([
{
expect: { status: [200] },
id: "t1",
type: "ws",
ws: { url: "ws://example.com" },
},
]),
);
expect(issues.some((i) => i.message.includes("未知字段"))).toBe(true);
});
test("非 ws 类型 target 跳过", () => {
const issues = validateWsConfig(makeInput([{ http: { url: "http://example.com" }, id: "t1", type: "http" }]));
expect(issues).toHaveLength(0);
});
});