1
0

feat: 新增本机 CPU checker

- 新增 type: cpu checker,基于 os.cpus() 两次快照计算 CPU 使用率
- 配置项:sampleDuration(默认 1s)、includePerCore(默认 false)
- expect 字段:usagePercent、idlePercent、maxCoreUsagePercent、minCoreUsagePercent、durationMs
- idlePercent 与 usagePercent 互补恒等于 100,百分比范围 0-100
- logicalCoreCount 仅输出到 observation,不作为 expect 字段
- 不暴露 userPercent / systemPercent
- 语义校验禁止 sampleDuration >= timeout
- 支持 AbortSignal 超时取消
- 完整测试覆盖:schema、validate、normalize、resolve、calculate、execute、expect、config-loader
- 新增用户文档 docs/user/checkers/cpu.md
- 更新 checker 索引、配置类型列表、示例配置和 schema
This commit is contained in:
2026-05-26 22:34:57 +08:00
parent f38286d74d
commit c2dcfab80c
22 changed files with 1839 additions and 3 deletions

View File

@@ -0,0 +1,255 @@
import { describe, expect, test } from "bun:test";
import type { SnapshotReader } from "../../../../../src/server/checker/runner/cpu/execute";
import type { CpuCoreSnapshot } from "../../../../../src/server/checker/runner/cpu/types";
import type { RawTargetConfig } from "../../../../../src/server/checker/types";
import { CpuChecker } from "../../../../../src/server/checker/runner/cpu/execute";
function makeCore(user: number, nice: number, sys: number, idle: number, irq: number): CpuCoreSnapshot {
return { times: { idle, irq, nice, sys, user } };
}
function makeResolveContext(
overrides: Partial<{ configDir: string; defaultIntervalMs: number; defaultTimeoutMs: number }> = {},
) {
return {
configDir: "/test",
defaultIntervalMs: 30000,
defaultTimeoutMs: 10000,
...overrides,
};
}
describe("CpuChecker resolve", () => {
const checker = new CpuChecker();
test("默认值sampleDurationMs=1000, includePerCore=false", () => {
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext());
expect(resolved.cpu.sampleDurationMs).toBe(1000);
expect(resolved.cpu.includePerCore).toBe(false);
});
test("显式配置覆盖默认值", () => {
const target: RawTargetConfig = {
cpu: { includePerCore: true, sampleDuration: "2s" },
id: "cpu-test",
type: "cpu",
};
const resolved = checker.resolve(target, makeResolveContext());
expect(resolved.cpu.sampleDurationMs).toBe(2000);
expect(resolved.cpu.includePerCore).toBe(true);
});
test("无 expect 时 expect 为 undefined", () => {
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext());
expect(resolved.expect).toBeUndefined();
});
test("保留 expect 字段", () => {
const target: RawTargetConfig = {
cpu: {},
expect: { usagePercent: { lte: 85 } },
id: "cpu-test",
type: "cpu",
};
const resolved = checker.resolve(target, makeResolveContext());
expect(resolved.expect).toEqual({ usagePercent: { lte: 85 } });
});
test("type 为 cpu", () => {
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext());
expect(resolved.type).toBe("cpu");
});
});
describe("CpuChecker execute", () => {
function makeSnapshotReader(_first: CpuCoreSnapshot[], _second: CpuCoreSnapshot[]): SnapshotReader {
let callCount = 0;
const snapshots = [_first, _second];
return () => {
const result = snapshots[Math.min(callCount, snapshots.length - 1)]!;
callCount++;
return result;
};
}
test("成功匹配", async () => {
// 50% usage, 50% idle
const before = [makeCore(1000, 0, 0, 9000, 0)];
const after = [makeCore(1500, 0, 0, 9500, 0)];
const reader = makeSnapshotReader(before, after);
const checker = new CpuChecker(reader);
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
resolved.expect = { usagePercent: { lte: 85 } };
const ctx = { signal: new AbortController().signal };
const result = await checker.execute(resolved, ctx);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
expect(result.observation).toMatchObject({
idlePercent: 50,
logicalCoreCount: 1,
usagePercent: 50,
});
// 默认不包含 perCoreUsagePercent
expect(result.observation!["perCoreUsagePercent"]).toBeUndefined();
});
test("usagePercent mismatch", async () => {
// 90% usage: before idle=0, after idle=1000, total=10000
const before = [makeCore(0, 0, 0, 0, 0)];
const after = [makeCore(9000, 0, 0, 1000, 0)];
const reader = makeSnapshotReader(before, after);
const checker = new CpuChecker(reader);
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
resolved.expect = { usagePercent: { lte: 50 } };
const ctx = { signal: new AbortController().signal };
const result = await checker.execute(resolved, ctx);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("usage");
});
test("idlePercent mismatch", async () => {
// idle = 10%: before idle=0, after idle=1000, total=10000
const before = [makeCore(0, 0, 0, 0, 0)];
const after = [makeCore(9000, 0, 0, 1000, 0)];
const reader = makeSnapshotReader(before, after);
const checker = new CpuChecker(reader);
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
resolved.expect = { idlePercent: { gte: 80 } };
const ctx = { signal: new AbortController().signal };
const result = await checker.execute(resolved, ctx);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("idle");
});
test("maxCoreUsagePercent mismatch", async () => {
// core 0: 95% usage, core 1: 10% usage
const bf = [makeCore(0, 0, 0, 0, 0), makeCore(0, 0, 0, 0, 0)];
const af = [makeCore(9500, 0, 0, 500, 0), makeCore(1000, 0, 0, 9000, 0)];
const reader = makeSnapshotReader(bf, af);
const checker = new CpuChecker(reader);
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
resolved.expect = { maxCoreUsagePercent: { lte: 80 } };
const ctx = { signal: new AbortController().signal };
const result = await checker.execute(resolved, ctx);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("maxCoreUsage");
});
test("minCoreUsagePercent mismatch", async () => {
// core 0: 95% usage, core 1: 10% usage
const bf = [makeCore(0, 0, 0, 0, 0), makeCore(0, 0, 0, 0, 0)];
const af = [makeCore(9500, 0, 0, 500, 0), makeCore(1000, 0, 0, 9000, 0)];
const reader = makeSnapshotReader(bf, af);
const checker = new CpuChecker(reader);
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
resolved.expect = { minCoreUsagePercent: { gte: 50 } };
const ctx = { signal: new AbortController().signal };
const result = await checker.execute(resolved, ctx);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("minCoreUsage");
});
test("durationMs mismatch", async () => {
const before = [makeCore(0, 0, 0, 10000, 0)];
const after = [makeCore(1000, 0, 0, 9000, 0)];
const reader = makeSnapshotReader(before, after);
const checker = new CpuChecker(reader);
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
resolved.expect = { durationMs: { lte: 0 } };
const ctx = { signal: new AbortController().signal };
const result = await checker.execute(resolved, ctx);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("duration");
});
test("超时取消", async () => {
const before = [makeCore(0, 0, 0, 10000, 0)];
const after = [makeCore(1000, 0, 0, 9000, 0)];
const reader = makeSnapshotReader(before, after);
const checker = new CpuChecker(reader);
const target: RawTargetConfig = { cpu: { sampleDuration: "10s" }, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 100 }));
const controller = new AbortController();
controller.abort();
const result = await checker.execute(resolved, { signal: controller.signal });
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("cpu");
expect(result.failure?.path).toBe("timeout");
});
test("includePerCore=true 时输出 perCoreUsagePercent", async () => {
const before = [makeCore(0, 0, 0, 0, 0), makeCore(0, 0, 0, 0, 0)];
const after = [makeCore(8000, 0, 0, 2000, 0), makeCore(2000, 0, 0, 8000, 0)];
const reader = makeSnapshotReader(before, after);
const checker = new CpuChecker(reader);
const target: RawTargetConfig = { cpu: { includePerCore: true }, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
const ctx = { signal: new AbortController().signal };
const result = await checker.execute(resolved, ctx);
expect(result.observation).toMatchObject({
perCoreUsagePercent: [80, 20],
});
});
});
describe("CpuChecker buildDetail", () => {
test("正常输出格式", () => {
const checker = new CpuChecker();
const detail = checker.buildDetail({
idlePercent: 40,
logicalCoreCount: 8,
maxCoreUsagePercent: 91.5,
minCoreUsagePercent: 8.2,
usagePercent: 60,
});
expect(detail).toBe("usage 60%, max core 91.5%, 8 cores");
});
});
describe("CpuChecker serialize", () => {
test("序列化输出", () => {
const checker = new CpuChecker();
const target: RawTargetConfig = { cpu: { sampleDuration: "1s" }, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext());
const result = checker.serialize(resolved);
expect(result.target).toBe("cpu sample 1000ms");
const config = JSON.parse(result.config) as { sampleDurationMs: number };
expect(config.sampleDurationMs).toBe(1000);
});
});