- 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 变更
348 lines
11 KiB
TypeScript
348 lines
11 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
|
|
import type { ResolvedCommandTarget } from "../../../src/server/checker/runner/cmd/types";
|
|
import type { ResolvedHttpTarget } from "../../../src/server/checker/runner/http/types";
|
|
import type { ProbeStore } from "../../../src/server/checker/store";
|
|
import type { ResolvedTargetBase } from "../../../src/server/checker/types";
|
|
|
|
import { ProbeEngine } from "../../../src/server/checker/engine";
|
|
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";
|
|
|
|
const processEnv = Object.fromEntries(
|
|
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
|
|
);
|
|
|
|
function createMockStore(targetNames: string[]) {
|
|
const targets = targetNames.map((name) => ({ id: name, name }));
|
|
const results: Array<Record<string, unknown>> = [];
|
|
|
|
return {
|
|
_results: results,
|
|
getTargets() {
|
|
return targets.map(({ id, name }) => ({
|
|
config: "",
|
|
expect: null,
|
|
grp: "default",
|
|
id,
|
|
interval_ms: 60000,
|
|
name,
|
|
target: "",
|
|
timeout_ms: 5000,
|
|
type: "cmd" as const,
|
|
}));
|
|
},
|
|
insertCheckResult(result: Record<string, unknown>) {
|
|
results.push(result);
|
|
},
|
|
};
|
|
}
|
|
|
|
function ensureRegistered() {
|
|
if (!checkerRegistry.supportedTypes.includes("http")) {
|
|
checkerRegistry.register(new HttpChecker());
|
|
checkerRegistry.register(new CommandChecker());
|
|
}
|
|
}
|
|
|
|
function makeCommandTarget(name: string, overrides?: Partial<ResolvedCommandTarget>): ResolvedCommandTarget {
|
|
return {
|
|
cmd: {
|
|
args: ["-e", "console.log('hello')"],
|
|
cwd: process.cwd(),
|
|
env: processEnv,
|
|
exec: "bun",
|
|
maxOutputBytes: 1024 * 1024,
|
|
},
|
|
description: null,
|
|
group: "default",
|
|
id: name,
|
|
intervalMs: 60000,
|
|
name,
|
|
timeoutMs: 5000,
|
|
type: "cmd",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("ProbeEngine", () => {
|
|
test("start/stop 不抛错", () => {
|
|
ensureRegistered();
|
|
const mockStore = createMockStore(["test"]) as unknown as ProbeStore;
|
|
const targets: ResolvedTargetBase[] = [makeCommandTarget("test")];
|
|
const engine = new ProbeEngine(mockStore, targets);
|
|
engine.start();
|
|
engine.stop();
|
|
expect(true).toBe(true);
|
|
});
|
|
|
|
test("单次 probeGroup 执行 cmd 检查", 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: ResolvedTargetBase[]) => Promise<void> }
|
|
).probeGroup.bind(engine);
|
|
await probeGroup([target]);
|
|
|
|
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
|
expect(results.length).toBe(1);
|
|
expect(results[0]!["matched"]).toBe(true);
|
|
expect((results[0]!["observation"] as Record<string, unknown>)["exitCode"]).toBe(0);
|
|
});
|
|
|
|
test("多个目标并发执行", async () => {
|
|
const targetA = makeCommandTarget("echo-a", {
|
|
cmd: {
|
|
args: ["-e", "console.log('a')"],
|
|
cwd: process.cwd(),
|
|
env: processEnv,
|
|
exec: "bun",
|
|
maxOutputBytes: 1024 * 1024,
|
|
},
|
|
});
|
|
const targetB = makeCommandTarget("echo-b", {
|
|
cmd: {
|
|
args: ["-e", "console.log('b')"],
|
|
cwd: process.cwd(),
|
|
env: processEnv,
|
|
exec: "bun",
|
|
maxOutputBytes: 1024 * 1024,
|
|
},
|
|
});
|
|
|
|
const mockStore = createMockStore(["echo-a", "echo-b"]) as unknown as ProbeStore;
|
|
const engine = new ProbeEngine(mockStore, [targetA, targetB]);
|
|
|
|
const probeGroup = (
|
|
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
|
|
).probeGroup.bind(engine);
|
|
await probeGroup([targetA, targetB]);
|
|
|
|
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
|
expect(results.length).toBe(2);
|
|
});
|
|
|
|
test("失败目标不阻塞其他目标", async () => {
|
|
const badTarget = makeCommandTarget("bad-cmd", {
|
|
cmd: {
|
|
args: ["-e", "process.exit(1)"],
|
|
cwd: process.cwd(),
|
|
env: processEnv,
|
|
exec: "bun",
|
|
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: ResolvedTargetBase[]) => 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["matched"] === false);
|
|
const goodResult = results.find((r) => r["matched"] === true);
|
|
expect(badResult).toBeDefined();
|
|
expect(goodResult).toBeDefined();
|
|
});
|
|
|
|
test("checker rejected 时写入 internal error 结果", async () => {
|
|
ensureRegistered();
|
|
const checker = checkerRegistry.get("cmd");
|
|
const originalExecute = checker.execute.bind(checker);
|
|
checker.execute = async (target, ctx) => {
|
|
if (target.name === "reject-cmd") {
|
|
throw new Error("boom");
|
|
}
|
|
return originalExecute(target, ctx);
|
|
};
|
|
|
|
try {
|
|
const rejectTarget = makeCommandTarget("reject-cmd");
|
|
const goodTarget = makeCommandTarget("good-cmd");
|
|
const mockStore = createMockStore(["reject-cmd", "good-cmd"]) as unknown as ProbeStore;
|
|
const engine = new ProbeEngine(mockStore, [rejectTarget, goodTarget]);
|
|
|
|
const probeGroup = (
|
|
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
|
|
).probeGroup.bind(engine);
|
|
await probeGroup([rejectTarget, goodTarget]);
|
|
|
|
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
|
expect(results.length).toBe(2);
|
|
expect(results[0]!["targetId"]).toBe("reject-cmd");
|
|
expect(results[0]!["matched"]).toBe(false);
|
|
expect(results[0]!["durationMs"]).toBeNull();
|
|
expect(results[0]!["observation"]).toBeNull();
|
|
expect(results[0]!["failure"]).toEqual({
|
|
kind: "error",
|
|
message: "boom",
|
|
path: "engine",
|
|
phase: "internal",
|
|
});
|
|
expect(typeof results[0]!["timestamp"]).toBe("string");
|
|
expect(results[1]!["targetId"]).toBe("good-cmd");
|
|
expect(results[1]!["matched"]).toBe(true);
|
|
} finally {
|
|
checker.execute = originalExecute;
|
|
}
|
|
});
|
|
|
|
test("并发限制 maxConcurrentChecks", async () => {
|
|
const targets = Array.from({ length: 5 }, (_, i) =>
|
|
makeCommandTarget(`cmd-${i}`, {
|
|
cmd: {
|
|
args: ["-e", `console.log('${i}')`],
|
|
cwd: process.cwd(),
|
|
env: processEnv,
|
|
exec: "bun",
|
|
maxOutputBytes: 1024 * 1024,
|
|
},
|
|
}),
|
|
);
|
|
|
|
const mockStore = createMockStore(targets.map((t) => t.name ?? t.id)) as unknown as ProbeStore;
|
|
const engine = new ProbeEngine(mockStore, targets, 2);
|
|
|
|
const probeGroup = (
|
|
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => 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["matched"]).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("未注册的 target id 不写入结果", 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: ResolvedTargetBase[]) => 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({
|
|
fetch() {
|
|
return new Response("ok");
|
|
},
|
|
port: 0,
|
|
});
|
|
|
|
try {
|
|
const httpTarget: ResolvedHttpTarget = {
|
|
description: null,
|
|
group: "default",
|
|
http: {
|
|
headers: {},
|
|
ignoreSSL: false,
|
|
maxBodyBytes: 1024 * 1024,
|
|
maxRedirects: 0,
|
|
method: "GET",
|
|
url: `http://localhost:${httpServer.port}/`,
|
|
},
|
|
id: "http-test",
|
|
intervalMs: 60000,
|
|
name: "http-test",
|
|
timeoutMs: 5000,
|
|
type: "http",
|
|
};
|
|
|
|
const mockStore = createMockStore(["http-test"]) as unknown as ProbeStore;
|
|
const engine = new ProbeEngine(mockStore, [httpTarget]);
|
|
|
|
const probeGroup = (
|
|
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => 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]!["matched"]).toBe(true);
|
|
expect((results[0]!["observation"] as Record<string, unknown>)["statusCode"]).toBe(200);
|
|
} finally {
|
|
void httpServer.stop();
|
|
}
|
|
});
|
|
|
|
test("retentionMs > 0 时 start 调用 prune", () => {
|
|
let pruneCalled = false;
|
|
const mockStore = {
|
|
...createMockStore(["test"]),
|
|
prune() {
|
|
pruneCalled = true;
|
|
return 0;
|
|
},
|
|
} as unknown as ProbeStore;
|
|
|
|
const targets: ResolvedTargetBase[] = [makeCommandTarget("test")];
|
|
const engine = new ProbeEngine(mockStore, targets, 20, 86400000);
|
|
engine.start();
|
|
expect(pruneCalled).toBe(true);
|
|
engine.stop();
|
|
});
|
|
|
|
test("retentionMs = 0 时不调用 prune", () => {
|
|
let pruneCalled = false;
|
|
const mockStore = {
|
|
...createMockStore(["test"]),
|
|
prune() {
|
|
pruneCalled = true;
|
|
return 0;
|
|
},
|
|
} as unknown as ProbeStore;
|
|
|
|
const targets: ResolvedTargetBase[] = [makeCommandTarget("test")];
|
|
const engine = new ProbeEngine(mockStore, targets, 20, 0);
|
|
engine.start();
|
|
expect(pruneCalled).toBe(false);
|
|
engine.stop();
|
|
});
|
|
|
|
test("retentionMs 未传时不调用 prune", () => {
|
|
let pruneCalled = false;
|
|
const mockStore = {
|
|
...createMockStore(["test"]),
|
|
prune() {
|
|
pruneCalled = true;
|
|
return 0;
|
|
},
|
|
} as unknown as ProbeStore;
|
|
|
|
const targets: ResolvedTargetBase[] = [makeCommandTarget("test")];
|
|
const engine = new ProbeEngine(mockStore, targets);
|
|
engine.start();
|
|
expect(pruneCalled).toBe(false);
|
|
engine.stop();
|
|
});
|
|
});
|