- CheckResult: statusDetail -> observation (持久化) + detail (API 动态派生) - 存储: status_detail 列 -> observation TEXT (JSON) - CheckerDefinition: 新增 buildDetail(observation) 方法 - 各 checker 返回结构化 observation,API 层通过 registry 调用 buildDetail - HTTP: bodyPreview 在 status/header 失败时也提前采集 - UDP: observation 包含 durationMs,未响应归为 error failure - CMD: 超时/输出超限时保留已收集 observation - TCP: connectTimeMs 仅含连接建立耗时,不含 banner 等待 - 新增 buildDetail 单测和 mapCheckResult 覆盖测试 - 同步 openspec 主规范,归档 checker-observation 变更
369 lines
12 KiB
TypeScript
369 lines
12 KiB
TypeScript
import { describe, expect, it } from "bun:test";
|
||
|
||
import type { ResolvedUdpTarget, UdpExpectConfig } from "../../../../../src/server/checker/runner/udp/types";
|
||
|
||
import { UdpChecker } from "../../../../../src/server/checker/runner/udp/execute";
|
||
|
||
async function createEchoServer(): Promise<{ close: () => void; port: number }> {
|
||
const socket = await Bun.udpSocket({
|
||
socket: {
|
||
data(sock, buf, port, addr) {
|
||
sock.send(buf, port, addr);
|
||
},
|
||
drain() {
|
||
// Bun UDP socket handler 必填项
|
||
},
|
||
error() {
|
||
// Bun UDP socket handler 必填项
|
||
},
|
||
},
|
||
});
|
||
return { close: () => socket.close(), port: socket.port };
|
||
}
|
||
|
||
function makeSignal(timeoutMs: number): { cleanup: () => void; signal: AbortSignal } {
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||
return { cleanup: () => clearTimeout(timer), signal: controller.signal };
|
||
}
|
||
|
||
function makeTarget(overrides: Partial<ResolvedUdpTarget["udp"]> = {}, expect?: UdpExpectConfig): ResolvedUdpTarget {
|
||
return {
|
||
description: null,
|
||
expect,
|
||
group: "default",
|
||
id: "test-udp",
|
||
intervalMs: 30000,
|
||
name: null,
|
||
timeoutMs: 10000,
|
||
type: "udp",
|
||
udp: {
|
||
encoding: "text",
|
||
host: "127.0.0.1",
|
||
maxResponseBytes: 4096,
|
||
payload: "PING",
|
||
port: 0,
|
||
responseEncoding: "text",
|
||
...overrides,
|
||
},
|
||
};
|
||
}
|
||
|
||
describe("UdpChecker execute", () => {
|
||
it("should resolve and respond successfully", async () => {
|
||
const server = await createEchoServer();
|
||
try {
|
||
const checker = new UdpChecker();
|
||
const target = makeTarget({ port: server.port });
|
||
const { cleanup, signal } = makeSignal(5000);
|
||
const result = await checker.execute(target, { signal });
|
||
cleanup();
|
||
expect(result.matched).toBe(true);
|
||
expect(result.failure).toBeNull();
|
||
expect(result.observation).toMatchObject({ responded: true });
|
||
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
||
} finally {
|
||
server.close();
|
||
}
|
||
});
|
||
|
||
it("should work with localhost hostname", async () => {
|
||
const server = await createEchoServer();
|
||
try {
|
||
const checker = new UdpChecker();
|
||
const target = makeTarget({ host: "localhost", port: server.port });
|
||
const { cleanup, signal } = makeSignal(5000);
|
||
const result = await checker.execute(target, { signal });
|
||
cleanup();
|
||
expect(result.matched).toBe(true);
|
||
} finally {
|
||
server.close();
|
||
}
|
||
});
|
||
|
||
it("should fail when no response and default expect.responded=true", async () => {
|
||
const server = await Bun.udpSocket({
|
||
socket: {
|
||
data() {
|
||
// sink - 不回包以模拟无响应
|
||
},
|
||
drain() {
|
||
// Bun UDP socket handler 必填项
|
||
},
|
||
error() {
|
||
// Bun UDP socket handler 必填项
|
||
},
|
||
},
|
||
});
|
||
try {
|
||
const checker = new UdpChecker();
|
||
const target = makeTarget({ port: server.port });
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), 200);
|
||
const result = await checker.execute(target, { signal: controller.signal });
|
||
clearTimeout(timer);
|
||
expect(result.matched).toBe(false);
|
||
expect(result.failure).toMatchObject({ kind: "error", phase: "response" });
|
||
expect(result.observation?.["durationMs"]).toBeGreaterThanOrEqual(0);
|
||
expect(result.observation?.["error"]).toBeTruthy();
|
||
expect(result.observation).toMatchObject({ responded: false });
|
||
} finally {
|
||
server.close();
|
||
}
|
||
});
|
||
|
||
it("should match when expect.responded=false and no response", async () => {
|
||
const server = await Bun.udpSocket({
|
||
socket: {
|
||
data() {
|
||
// sink - 不回包以模拟无响应
|
||
},
|
||
drain() {
|
||
// Bun UDP socket handler 必填项
|
||
},
|
||
error() {
|
||
// Bun UDP socket handler 必填项
|
||
},
|
||
},
|
||
});
|
||
try {
|
||
const checker = new UdpChecker();
|
||
const target = makeTarget({ port: server.port }, { responded: false });
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), 200);
|
||
const result = await checker.execute(target, { signal: controller.signal });
|
||
clearTimeout(timer);
|
||
expect(result.matched).toBe(true);
|
||
expect(result.observation?.["durationMs"]).toBeGreaterThanOrEqual(0);
|
||
expect(result.observation).toMatchObject({ error: null, responded: false });
|
||
} finally {
|
||
server.close();
|
||
}
|
||
});
|
||
|
||
it("should fail when expect.responded=false but response received", async () => {
|
||
const server = await createEchoServer();
|
||
try {
|
||
const checker = new UdpChecker();
|
||
const target = makeTarget({ port: server.port }, { responded: false });
|
||
const { cleanup, signal } = makeSignal(5000);
|
||
const result = await checker.execute(target, { signal });
|
||
cleanup();
|
||
expect(result.matched).toBe(false);
|
||
expect(result.failure?.phase).toBe("responded");
|
||
} finally {
|
||
server.close();
|
||
}
|
||
});
|
||
|
||
it("should validate response content with expect.response", async () => {
|
||
const server = await createEchoServer();
|
||
try {
|
||
const checker = new UdpChecker();
|
||
const target = makeTarget({ port: server.port }, { response: [{ contains: "PING" }] });
|
||
const { cleanup, signal } = makeSignal(5000);
|
||
const result = await checker.execute(target, { signal });
|
||
cleanup();
|
||
expect(result.matched).toBe(true);
|
||
} finally {
|
||
server.close();
|
||
}
|
||
});
|
||
|
||
it("should fail response content mismatch", async () => {
|
||
const server = await createEchoServer();
|
||
try {
|
||
const checker = new UdpChecker();
|
||
const target = makeTarget({ port: server.port }, { response: [{ contains: "PONG" }] });
|
||
const { cleanup, signal } = makeSignal(5000);
|
||
const result = await checker.execute(target, { signal });
|
||
cleanup();
|
||
expect(result.matched).toBe(false);
|
||
expect(result.failure?.phase).toBe("response");
|
||
} finally {
|
||
server.close();
|
||
}
|
||
});
|
||
|
||
it("should validate responseSize", async () => {
|
||
const server = await createEchoServer();
|
||
try {
|
||
const checker = new UdpChecker();
|
||
const target = makeTarget({ port: server.port }, { responseSize: { gte: 1 } });
|
||
const { cleanup, signal } = makeSignal(5000);
|
||
const result = await checker.execute(target, { signal });
|
||
cleanup();
|
||
expect(result.matched).toBe(true);
|
||
} finally {
|
||
server.close();
|
||
}
|
||
});
|
||
|
||
it("should only use the first datagram when server sends multiple", async () => {
|
||
const server = await Bun.udpSocket({
|
||
socket: {
|
||
data(sock, buf, port, addr) {
|
||
sock.send(Buffer.from("FIRST"), port, addr);
|
||
setTimeout(() => {
|
||
try {
|
||
sock.send(Buffer.from("SECOND"), port, addr);
|
||
} catch {
|
||
// checker 已关闭 connected socket,第二次发送可能失败
|
||
}
|
||
}, 30);
|
||
},
|
||
drain() {
|
||
// Bun UDP socket handler 必填项
|
||
},
|
||
error() {
|
||
// Bun UDP socket handler 必填项
|
||
},
|
||
},
|
||
});
|
||
try {
|
||
const checker = new UdpChecker();
|
||
const target = makeTarget({ payload: "PING", port: server.port }, { response: [{ contains: "FIRST" }] });
|
||
const { cleanup, signal } = makeSignal(5000);
|
||
const result = await checker.execute(target, { signal });
|
||
cleanup();
|
||
expect(result.matched).toBe(true);
|
||
expect(result.observation).toMatchObject({ responded: true, responseSize: 5 });
|
||
} finally {
|
||
server.close();
|
||
}
|
||
});
|
||
|
||
it("should fail when response exceeds maxResponseBytes", async () => {
|
||
const server = await createEchoServer();
|
||
try {
|
||
const checker = new UdpChecker();
|
||
const longPayload = "X".repeat(100);
|
||
const target = makeTarget({
|
||
maxResponseBytes: 10,
|
||
payload: longPayload,
|
||
port: server.port,
|
||
});
|
||
const { cleanup, signal } = makeSignal(5000);
|
||
const result = await checker.execute(target, { signal });
|
||
cleanup();
|
||
expect(result.matched).toBe(false);
|
||
expect(result.failure?.phase).toBe("response");
|
||
expect(result.failure?.kind).toBe("error");
|
||
} finally {
|
||
server.close();
|
||
}
|
||
});
|
||
|
||
it("should fail when duration exceeds durationMs", async () => {
|
||
const server = await Bun.udpSocket({
|
||
socket: {
|
||
data() {
|
||
// sink - 不回包,通过 abort 触发 no-response 路径
|
||
},
|
||
drain() {
|
||
// Bun UDP socket handler 必填项
|
||
},
|
||
error() {
|
||
// Bun UDP socket handler 必填项
|
||
},
|
||
},
|
||
});
|
||
try {
|
||
const checker = new UdpChecker();
|
||
const target = makeTarget({ port: server.port }, { durationMs: { lte: 1 }, responded: false });
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), 200);
|
||
const result = await checker.execute(target, { signal: controller.signal });
|
||
clearTimeout(timer);
|
||
expect(result.matched).toBe(false);
|
||
expect(result.failure?.phase).toBe("duration");
|
||
} finally {
|
||
server.close();
|
||
}
|
||
});
|
||
|
||
it("should match sourceHost assertion", async () => {
|
||
const server = await createEchoServer();
|
||
try {
|
||
const checker = new UdpChecker();
|
||
const target = makeTarget({ port: server.port }, { sourceHost: { equals: "127.0.0.1" } });
|
||
const { cleanup, signal } = makeSignal(5000);
|
||
const result = await checker.execute(target, { signal });
|
||
cleanup();
|
||
expect(result.matched).toBe(true);
|
||
} finally {
|
||
server.close();
|
||
}
|
||
});
|
||
|
||
it("should match sourcePort assertion", async () => {
|
||
const server = await createEchoServer();
|
||
try {
|
||
const checker = new UdpChecker();
|
||
const target = makeTarget({ port: server.port }, { sourcePort: { equals: server.port } });
|
||
const { cleanup, signal } = makeSignal(5000);
|
||
const result = await checker.execute(target, { signal });
|
||
cleanup();
|
||
expect(result.matched).toBe(true);
|
||
} finally {
|
||
server.close();
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("UdpChecker resolve", () => {
|
||
it("should fill defaults for minimal config", () => {
|
||
const checker = new UdpChecker();
|
||
const target = checker.resolve(
|
||
{ id: "test", type: "udp", udp: { host: "127.0.0.1", port: 9000 } },
|
||
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
|
||
);
|
||
expect(target.udp.payload).toBe("");
|
||
expect(target.udp.encoding).toBe("text");
|
||
expect(target.udp.responseEncoding).toBe("text");
|
||
expect(target.udp.maxResponseBytes).toBe(4096);
|
||
});
|
||
|
||
it("should use defaults.udp for missing fields", () => {
|
||
const checker = new UdpChecker();
|
||
const target = checker.resolve(
|
||
{ id: "test", type: "udp", udp: { host: "127.0.0.1", port: 9000 } },
|
||
{
|
||
configDir: "/tmp",
|
||
defaultIntervalMs: 30000,
|
||
defaults: { udp: { encoding: "hex", maxResponseBytes: "8KB", responseEncoding: "hex" } },
|
||
defaultTimeoutMs: 10000,
|
||
},
|
||
);
|
||
expect(target.udp.encoding).toBe("hex");
|
||
expect(target.udp.responseEncoding).toBe("hex");
|
||
expect(target.udp.maxResponseBytes).toBe(8192);
|
||
});
|
||
|
||
it("should override defaults with target-level config", () => {
|
||
const checker = new UdpChecker();
|
||
const target = checker.resolve(
|
||
{ id: "test", type: "udp", udp: { encoding: "base64", host: "127.0.0.1", port: 9000 } },
|
||
{
|
||
configDir: "/tmp",
|
||
defaultIntervalMs: 30000,
|
||
defaults: { udp: { encoding: "hex" } },
|
||
defaultTimeoutMs: 10000,
|
||
},
|
||
);
|
||
expect(target.udp.encoding).toBe("base64");
|
||
});
|
||
});
|
||
|
||
describe("UdpChecker serialize", () => {
|
||
it("should produce udp host:port target summary", () => {
|
||
const checker = new UdpChecker();
|
||
const target = makeTarget({ host: "10.0.0.1", port: 9000 });
|
||
const { config, target: display } = checker.serialize(target);
|
||
expect(display).toBe("udp 10.0.0.1:9000");
|
||
const parsed = JSON.parse(config) as Record<string, unknown>;
|
||
expect(parsed["host"]).toBe("10.0.0.1");
|
||
expect(parsed["port"]).toBe(9000);
|
||
});
|
||
});
|