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:
@@ -17,6 +17,7 @@ Checker 是 DiAL 的拨测执行单元。每个 target 通过 `type` 选择一
|
||||
| `dns` | 本机解析或指定 DNS server 协议级检查 | [DNS](dns.md) |
|
||||
| `llm` | 大模型服务应用层健康检查 | [LLM](llm.md) |
|
||||
| `ws` | WebSocket 可达性和消息交互检查 | [WS](ws.md) |
|
||||
| `cpu` | 本机 CPU 使用率健康检查 | [CPU](cpu.md) |
|
||||
|
||||
## 选择建议
|
||||
|
||||
@@ -31,6 +32,7 @@ Checker 是 DiAL 的拨测执行单元。每个 target 通过 `type` 选择一
|
||||
| 域名解析值、DNS RCODE、TTL、flags | `dns` |
|
||||
| LLM API 是否可用、输出是否符合预期 | `llm` |
|
||||
| WebSocket 可达性或消息交互验证 | `ws` |
|
||||
| 本机 CPU 使用率健康检查 | `cpu` |
|
||||
|
||||
## 通用字段
|
||||
|
||||
|
||||
74
docs/user/checkers/cpu.md
Normal file
74
docs/user/checkers/cpu.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# CPU Checker
|
||||
|
||||
`type: cpu` 用于检查本机 CPU 使用率,基于两次系统快照计算总体和每核心的忙碌比例。
|
||||
|
||||
## 配置项
|
||||
|
||||
| 字段 | 说明 | 必填 | 默认值 |
|
||||
| -------------------- | -------------------------------- | ---- | ------- |
|
||||
| `cpu.sampleDuration` | CPU 采样窗口,支持时长格式 | 否 | `1s` |
|
||||
| `cpu.includePerCore` | 是否在结果中输出每核心使用率数组 | 否 | `false` |
|
||||
|
||||
`sampleDuration` 必须小于 target 的 `timeout`。
|
||||
|
||||
## expect 校验项
|
||||
|
||||
| 字段 | 说明 | 必填 | 默认值 |
|
||||
| --------------------- | ----------------------------------------------------------------------------- | ---- | ------ |
|
||||
| `usagePercent` | 总体 CPU 使用率,范围 `0-100`,使用 `ValueMatcher` | 否 | 无 |
|
||||
| `idlePercent` | 总体 CPU 空闲率,与 `usagePercent` 互补(`idlePercent = 100 - usagePercent`) | 否 | 无 |
|
||||
| `maxCoreUsagePercent` | 单核心最高使用率,使用 `ValueMatcher` | 否 | 无 |
|
||||
| `minCoreUsagePercent` | 单核心最低使用率,使用 `ValueMatcher` | 否 | 无 |
|
||||
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
|
||||
|
||||
所有百分比字段范围为 `0-100`,表示所有可见逻辑 CPU 的总体比例,不是"核心数 × 100"。
|
||||
|
||||
## 示例
|
||||
|
||||
```yaml
|
||||
- id: "local-cpu"
|
||||
name: "本机 CPU"
|
||||
type: cpu
|
||||
interval: "30s"
|
||||
timeout: "5s"
|
||||
cpu:
|
||||
sampleDuration: "1s"
|
||||
expect:
|
||||
usagePercent:
|
||||
lte: 85
|
||||
maxCoreUsagePercent:
|
||||
lte: 95
|
||||
```
|
||||
|
||||
输出每核心使用率:
|
||||
|
||||
```yaml
|
||||
- id: "local-cpu-detail"
|
||||
name: "本机 CPU 详细"
|
||||
type: cpu
|
||||
cpu:
|
||||
sampleDuration: "2s"
|
||||
includePerCore: true
|
||||
expect:
|
||||
usagePercent:
|
||||
lte: 80
|
||||
```
|
||||
|
||||
## 语义说明
|
||||
|
||||
CPU checker 采集的是 DiAL 进程运行环境通过系统 API(`os.cpus()`)可见的 CPU 视图。在容器中,它可能不等于严格的 cgroup quota 使用率。
|
||||
|
||||
`usagePercent` 和 `idlePercent` 互补,恒等于 100。`sampleDuration` 决定了两次快照之间的等待时间,窗口越长结果越稳定,但会增加 checker 执行耗时。
|
||||
|
||||
## 不支持的功能
|
||||
|
||||
- CPU 温度、电源状态、频率
|
||||
- `userPercent` / `systemPercent`(用户态/系统态占比)
|
||||
- `loadAverage`(系统负载均值)
|
||||
- 进程级 CPU 使用率
|
||||
- Linux cgroup 精确 CPU 计算
|
||||
- `logicalCoreCount` 作为 expect 字段(仅在 observation 中输出)
|
||||
|
||||
## 更新触发条件
|
||||
|
||||
修改 CPU checker 配置、expect 字段、行为或语义时,必须更新本文档。
|
||||
@@ -121,7 +121,7 @@ targets:
|
||||
| `id` | 目标唯一标识,最长 30 字符,支持字母数字、下划线、连字符,不参与变量替换 | 是 | 无 |
|
||||
| `name` | 展示名称,最长 30 字符,支持变量替换,可省略或显式 null;前端展示时 null 回退到 `id` | 否 | 无 |
|
||||
| `description` | 目标描述,最长 500 字符,支持变量替换,可省略或显式 null,允许空字符串 | 否 | 无 |
|
||||
| `type` | 目标类型:`http`、`cmd`、`db`、`tcp`、`udp`、`dns`、`icmp`、`llm`、`ws` | 是 | 无 |
|
||||
| `type` | 目标类型:`http`、`cmd`、`db`、`tcp`、`udp`、`dns`、`icmp`、`llm`、`ws`、`cpu` | 是 | 无 |
|
||||
| `group` | 分组名称 | 否 | `default` |
|
||||
| `interval` | 拨测间隔,最小 `10s` | 否 | `30s` |
|
||||
| `timeout` | 超时时间,必须小于等于 `interval` | 否 | `10s` |
|
||||
|
||||
@@ -6373,6 +6373,483 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"type",
|
||||
"cpu"
|
||||
],
|
||||
"properties": {
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"maxLength": 500,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"pattern": "^\\$\\{[^}]+\\}$",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"expect": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"durationMs": {
|
||||
"anyOf": [
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"contains": {
|
||||
"type": "string"
|
||||
},
|
||||
"empty": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"equals": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"items": {},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"additionalProperties": {},
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"exists": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"gt": {
|
||||
"type": "number"
|
||||
},
|
||||
"gte": {
|
||||
"type": "number"
|
||||
},
|
||||
"lt": {
|
||||
"type": "number"
|
||||
},
|
||||
"lte": {
|
||||
"type": "number"
|
||||
},
|
||||
"regex": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"idlePercent": {
|
||||
"anyOf": [
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"contains": {
|
||||
"type": "string"
|
||||
},
|
||||
"empty": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"equals": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"items": {},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"additionalProperties": {},
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"exists": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"gt": {
|
||||
"type": "number"
|
||||
},
|
||||
"gte": {
|
||||
"type": "number"
|
||||
},
|
||||
"lt": {
|
||||
"type": "number"
|
||||
},
|
||||
"lte": {
|
||||
"type": "number"
|
||||
},
|
||||
"regex": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"maxCoreUsagePercent": {
|
||||
"anyOf": [
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"contains": {
|
||||
"type": "string"
|
||||
},
|
||||
"empty": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"equals": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"items": {},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"additionalProperties": {},
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"exists": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"gt": {
|
||||
"type": "number"
|
||||
},
|
||||
"gte": {
|
||||
"type": "number"
|
||||
},
|
||||
"lt": {
|
||||
"type": "number"
|
||||
},
|
||||
"lte": {
|
||||
"type": "number"
|
||||
},
|
||||
"regex": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"minCoreUsagePercent": {
|
||||
"anyOf": [
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"contains": {
|
||||
"type": "string"
|
||||
},
|
||||
"empty": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"equals": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"items": {},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"additionalProperties": {},
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"exists": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"gt": {
|
||||
"type": "number"
|
||||
},
|
||||
"gte": {
|
||||
"type": "number"
|
||||
},
|
||||
"lt": {
|
||||
"type": "number"
|
||||
},
|
||||
"lte": {
|
||||
"type": "number"
|
||||
},
|
||||
"regex": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"usagePercent": {
|
||||
"anyOf": [
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"contains": {
|
||||
"type": "string"
|
||||
},
|
||||
"empty": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"equals": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"items": {},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"additionalProperties": {},
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"exists": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"gt": {
|
||||
"type": "number"
|
||||
},
|
||||
"gte": {
|
||||
"type": "number"
|
||||
},
|
||||
"lt": {
|
||||
"type": "number"
|
||||
},
|
||||
"lte": {
|
||||
"type": "number"
|
||||
},
|
||||
"regex": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"group": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"maxLength": 30,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"maxLength": 30,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"pattern": "^\\$\\{[^}]+\\}$",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"timeout": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"const": "cpu",
|
||||
"type": "string"
|
||||
},
|
||||
"cpu": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"includePerCore": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"pattern": "^\\$\\{[^}]+\\}$",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sampleDuration": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"pattern": "^\\$\\{[^}]+\\}$",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -353,3 +353,17 @@ targets:
|
||||
- contains: "hello"
|
||||
durationMs:
|
||||
lte: 5000
|
||||
|
||||
- id: "local-cpu"
|
||||
name: "本机 CPU"
|
||||
type: cpu
|
||||
group: "基础设施"
|
||||
interval: "30s"
|
||||
timeout: "5s"
|
||||
cpu:
|
||||
sampleDuration: "1s"
|
||||
expect:
|
||||
usagePercent:
|
||||
lte: 85
|
||||
maxCoreUsagePercent:
|
||||
lte: 95
|
||||
|
||||
72
src/server/checker/runner/cpu/calculate.ts
Normal file
72
src/server/checker/runner/cpu/calculate.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { cpus } from "node:os";
|
||||
|
||||
import type { CpuCoreSnapshot, CpuStats } from "./types";
|
||||
|
||||
/**
|
||||
* 根据两次 CPU times 快照计算使用率统计。
|
||||
*
|
||||
* - usagePercent = 100 - idlePercent(互补关系,恒等于 100)
|
||||
* - idlePercent = 所有核心 idle delta 之和 ÷ 所有核心 total delta 之和 × 100
|
||||
* - maxCoreUsagePercent / minCoreUsagePercent 为单核心粒度的最高/最低使用率
|
||||
* - 所有百分比范围 0-100,保留 1 位小数
|
||||
*/
|
||||
export function calculateCpuStats(before: CpuCoreSnapshot[], after: CpuCoreSnapshot[]): CpuStats {
|
||||
let totalIdleDelta = 0;
|
||||
let totalDelta = 0;
|
||||
const perCoreUsage: number[] = [];
|
||||
|
||||
for (let i = 0; i < before.length; i++) {
|
||||
const b = before[i]!.times;
|
||||
const a = after[i]!.times;
|
||||
|
||||
const idleDelta = a.idle - b.idle;
|
||||
const userDelta = a.user - b.user;
|
||||
const niceDelta = a.nice - b.nice;
|
||||
const sysDelta = a.sys - b.sys;
|
||||
const irqDelta = a.irq - b.irq;
|
||||
|
||||
const coreTotalDelta = userDelta + niceDelta + sysDelta + idleDelta + irqDelta;
|
||||
const coreIdleDelta = idleDelta;
|
||||
|
||||
totalIdleDelta += coreIdleDelta;
|
||||
totalDelta += coreTotalDelta;
|
||||
|
||||
const coreUsagePercent = coreTotalDelta === 0 ? 0 : round1((1 - coreIdleDelta / coreTotalDelta) * 100);
|
||||
perCoreUsage.push(coreUsagePercent);
|
||||
}
|
||||
|
||||
const idlePercent = totalDelta === 0 ? 0 : round1((totalIdleDelta / totalDelta) * 100);
|
||||
const usagePercent = totalDelta === 0 ? 0 : round1(100 - idlePercent);
|
||||
|
||||
const maxCoreUsagePercent = Math.max(...perCoreUsage);
|
||||
const minCoreUsagePercent = Math.min(...perCoreUsage);
|
||||
|
||||
return {
|
||||
idlePercent,
|
||||
logicalCoreCount: before.length,
|
||||
maxCoreUsagePercent,
|
||||
minCoreUsagePercent,
|
||||
perCoreUsagePercent: perCoreUsage,
|
||||
usagePercent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取当前 CPU 各核心 times 快照。
|
||||
* 委托给 node:os 的 os.cpus(),便于测试时注入 mock。
|
||||
*/
|
||||
export function readCpuSnapshot(): CpuCoreSnapshot[] {
|
||||
return cpus().map((cpu) => ({
|
||||
times: {
|
||||
idle: cpu.times.idle,
|
||||
irq: cpu.times.irq,
|
||||
nice: cpu.times.nice,
|
||||
sys: cpu.times.sys,
|
||||
user: cpu.times.user,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function round1(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
186
src/server/checker/runner/cpu/execute.ts
Normal file
186
src/server/checker/runner/cpu/execute.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type { CpuCoreSnapshot, CpuStats, CpuTargetConfig, ResolvedCpuExpectConfig, ResolvedCpuTarget } from "./types";
|
||||
|
||||
import { errorFailure } from "../../expect/failure";
|
||||
import { checkValueExpectation } from "../../expect/value";
|
||||
import { parseDuration } from "../../utils";
|
||||
import { calculateCpuStats, readCpuSnapshot } from "./calculate";
|
||||
import { checkIdlePercent, checkMaxCoreUsage, checkMinCoreUsage, checkUsagePercent } from "./expect";
|
||||
import { normalizeTargetExpect } from "./normalize";
|
||||
import { cpuCheckerSchemas } from "./schema";
|
||||
import { validateCpuConfig } from "./validate";
|
||||
|
||||
const DEFAULT_SAMPLE_DURATION_MS = 1000;
|
||||
|
||||
/**
|
||||
* 可注入的 CPU 快照读取函数,便于测试。
|
||||
* 生产环境使用 node:os 的 os.cpus()。
|
||||
*/
|
||||
export type SnapshotReader = () => CpuCoreSnapshot[];
|
||||
|
||||
export class CpuChecker implements CheckerDefinition<ResolvedCpuTarget> {
|
||||
readonly configKey = "cpu";
|
||||
|
||||
readonly schemas = cpuCheckerSchemas;
|
||||
|
||||
readonly type = "cpu";
|
||||
|
||||
constructor(private readonly readSnapshot: SnapshotReader = readCpuSnapshot) {}
|
||||
|
||||
buildDetail(observation: Record<string, unknown>): null | string {
|
||||
const usage = observation["usagePercent"];
|
||||
const usageStr = typeof usage === "number" ? formatNumber(usage) : "n/a";
|
||||
const maxCore = observation["maxCoreUsagePercent"];
|
||||
const maxStr = typeof maxCore === "number" ? formatNumber(maxCore) : "n/a";
|
||||
const cores = observation["logicalCoreCount"];
|
||||
const coresStr = typeof cores === "number" ? String(cores) : "?";
|
||||
return `usage ${usageStr}%, max core ${maxStr}%, ${coresStr} cores`;
|
||||
}
|
||||
|
||||
async execute(t: ResolvedCpuTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const start = performance.now();
|
||||
|
||||
let before: CpuCoreSnapshot[];
|
||||
try {
|
||||
before = this.readSnapshot();
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure(
|
||||
"cpu",
|
||||
"snapshot",
|
||||
`CPU 快照读取失败: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
matched: false,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// 采样等待,支持 AbortSignal 取消
|
||||
const aborted = await waitForDuration(t.cpu.sampleDurationMs, ctx.signal);
|
||||
|
||||
const after = aborted ? null : this.readSnapshot();
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
|
||||
if (aborted || after === null) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("cpu", "timeout", `CPU 采样超时 (${t.timeoutMs}ms)`),
|
||||
matched: false,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const stats = calculateCpuStats(before, after);
|
||||
const result = checkStats(stats, t.expect, durationMs);
|
||||
|
||||
const observation: Record<string, unknown> = {
|
||||
error: null,
|
||||
idlePercent: stats.idlePercent,
|
||||
logicalCoreCount: stats.logicalCoreCount,
|
||||
maxCoreUsagePercent: stats.maxCoreUsagePercent,
|
||||
minCoreUsagePercent: stats.minCoreUsagePercent,
|
||||
usagePercent: stats.usagePercent,
|
||||
};
|
||||
|
||||
if (t.cpu.includePerCore) {
|
||||
observation["perCoreUsagePercent"] = stats.perCoreUsagePercent;
|
||||
}
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: result.failure,
|
||||
matched: result.matched,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
normalize(target: RawTargetConfig): RawTargetConfig {
|
||||
return normalizeTargetExpect(target);
|
||||
}
|
||||
|
||||
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedCpuTarget {
|
||||
const t = target as RawTargetConfig & { cpu: CpuTargetConfig; type: "cpu" };
|
||||
|
||||
const rawSampleDuration = t.cpu.sampleDuration;
|
||||
const sampleDurationMs = rawSampleDuration ? parseDuration(rawSampleDuration) : DEFAULT_SAMPLE_DURATION_MS;
|
||||
const includePerCore = t.cpu.includePerCore ?? false;
|
||||
|
||||
return {
|
||||
cpu: { includePerCore, sampleDurationMs },
|
||||
description: null,
|
||||
expect: target.expect as ResolvedCpuExpectConfig | undefined,
|
||||
group: target.group ?? "default",
|
||||
id: t.id,
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
name: t.name ?? null,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
type: "cpu",
|
||||
} satisfies ResolvedCpuTarget;
|
||||
}
|
||||
|
||||
serialize(t: ResolvedCpuTarget): { config: string; target: string } {
|
||||
return {
|
||||
config: JSON.stringify(t.cpu),
|
||||
target: `cpu sample ${t.cpu.sampleDurationMs}ms`,
|
||||
};
|
||||
}
|
||||
|
||||
validate(input: CheckerValidationInput) {
|
||||
return validateCpuConfig(input);
|
||||
}
|
||||
}
|
||||
|
||||
function checkStats(stats: CpuStats, expect: ResolvedCpuExpectConfig | undefined, durationMs: number) {
|
||||
const usageResult = checkUsagePercent(stats.usagePercent, expect?.usagePercent);
|
||||
if (!usageResult.matched) return usageResult;
|
||||
const idleResult = checkIdlePercent(stats.idlePercent, expect?.idlePercent);
|
||||
if (!idleResult.matched) return idleResult;
|
||||
const maxCoreResult = checkMaxCoreUsage(stats.maxCoreUsagePercent, expect?.maxCoreUsagePercent);
|
||||
if (!maxCoreResult.matched) return maxCoreResult;
|
||||
const minCoreResult = checkMinCoreUsage(stats.minCoreUsagePercent, expect?.minCoreUsagePercent);
|
||||
if (!minCoreResult.matched) return minCoreResult;
|
||||
return checkValueExpectation(durationMs, expect?.durationMs, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
phase: "duration",
|
||||
});
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(1)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待指定毫秒,支持 AbortSignal 取消。
|
||||
* 返回 true 表示被中断(aborted),false 表示正常完成。
|
||||
*/
|
||||
async function waitForDuration(ms: number, signal: AbortSignal): Promise<boolean> {
|
||||
if (signal.aborted) return true;
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(false);
|
||||
}, ms);
|
||||
|
||||
function onAbort() {
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
}
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
35
src/server/checker/runner/cpu/expect.ts
Normal file
35
src/server/checker/runner/cpu/expect.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { ExpectationResult, ValueExpectation } from "../../expect/types";
|
||||
|
||||
import { checkValueExpectation } from "../../expect/value";
|
||||
|
||||
export function checkIdlePercent(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
|
||||
return checkValueExpectation(actual, matcher, {
|
||||
message: "CPU 空闲率不满足条件",
|
||||
path: "idlePercent",
|
||||
phase: "idle",
|
||||
});
|
||||
}
|
||||
|
||||
export function checkMaxCoreUsage(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
|
||||
return checkValueExpectation(actual, matcher, {
|
||||
message: "单核心最大使用率不满足条件",
|
||||
path: "maxCoreUsagePercent",
|
||||
phase: "maxCoreUsage",
|
||||
});
|
||||
}
|
||||
|
||||
export function checkMinCoreUsage(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
|
||||
return checkValueExpectation(actual, matcher, {
|
||||
message: "单核心最小使用率不满足条件",
|
||||
path: "minCoreUsagePercent",
|
||||
phase: "minCoreUsage",
|
||||
});
|
||||
}
|
||||
|
||||
export function checkUsagePercent(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
|
||||
return checkValueExpectation(actual, matcher, {
|
||||
message: "CPU 使用率不满足条件",
|
||||
path: "usagePercent",
|
||||
phase: "usage",
|
||||
});
|
||||
}
|
||||
1
src/server/checker/runner/cpu/index.ts
Normal file
1
src/server/checker/runner/cpu/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CpuChecker } from "./execute";
|
||||
20
src/server/checker/runner/cpu/normalize.ts
Normal file
20
src/server/checker/runner/cpu/normalize.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { isPlainObject } from "es-toolkit";
|
||||
|
||||
import type { RawTargetConfig } from "../../types";
|
||||
|
||||
import { compactExpect, normalizeValue } from "../../expect/normalize";
|
||||
|
||||
export function normalizeTargetExpect(target: RawTargetConfig): RawTargetConfig {
|
||||
if (target.expect === undefined || !isPlainObject(target.expect)) return target;
|
||||
const raw = target.expect as Record<string, unknown>;
|
||||
return {
|
||||
...target,
|
||||
expect: compactExpect(raw, {
|
||||
durationMs: normalizeValue(raw["durationMs"]),
|
||||
idlePercent: normalizeValue(raw["idlePercent"]),
|
||||
maxCoreUsagePercent: normalizeValue(raw["maxCoreUsagePercent"]),
|
||||
minCoreUsagePercent: normalizeValue(raw["minCoreUsagePercent"]),
|
||||
usagePercent: normalizeValue(raw["usagePercent"]),
|
||||
}),
|
||||
};
|
||||
}
|
||||
46
src/server/checker/runner/cpu/schema.ts
Normal file
46
src/server/checker/runner/cpu/schema.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import type { CheckerSchemas } from "../types";
|
||||
|
||||
import {
|
||||
createAuthoringFieldSchema,
|
||||
createAuthoringValueExpectationSchema,
|
||||
createNormalizedValueExpectationSchema,
|
||||
durationSchema,
|
||||
} from "../../schema/fragments";
|
||||
|
||||
export const cpuCheckerSchemas: CheckerSchemas = {
|
||||
authoring: {
|
||||
config: createCpuConfigSchema("authoring"),
|
||||
expect: createCpuExpectSchema("authoring"),
|
||||
},
|
||||
normalized: {
|
||||
config: createCpuConfigSchema("normalized"),
|
||||
expect: createCpuExpectSchema("normalized"),
|
||||
},
|
||||
};
|
||||
|
||||
function createCpuConfigSchema(kind: "authoring" | "normalized") {
|
||||
return Type.Object(
|
||||
{
|
||||
includePerCore: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(Type.Boolean()) : Type.Boolean()),
|
||||
sampleDuration: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(durationSchema) : durationSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
}
|
||||
|
||||
function createCpuExpectSchema(kind: "authoring" | "normalized") {
|
||||
const valueSchema =
|
||||
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema();
|
||||
return Type.Object(
|
||||
{
|
||||
durationMs: Type.Optional(valueSchema),
|
||||
idlePercent: Type.Optional(valueSchema),
|
||||
maxCoreUsagePercent: Type.Optional(valueSchema),
|
||||
minCoreUsagePercent: Type.Optional(valueSchema),
|
||||
usagePercent: Type.Optional(valueSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
}
|
||||
59
src/server/checker/runner/cpu/types.ts
Normal file
59
src/server/checker/runner/cpu/types.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { RawValueExpectation, ValueExpectation } from "../../expect/types";
|
||||
import type { ResolvedTargetBase } from "../../types";
|
||||
|
||||
export interface CpuCoreSnapshot {
|
||||
times: CpuTimesSnapshot;
|
||||
}
|
||||
|
||||
export interface CpuStats {
|
||||
idlePercent: number;
|
||||
logicalCoreCount: number;
|
||||
maxCoreUsagePercent: number;
|
||||
minCoreUsagePercent: number;
|
||||
perCoreUsagePercent: number[];
|
||||
usagePercent: number;
|
||||
}
|
||||
|
||||
export interface CpuTargetConfig {
|
||||
includePerCore?: boolean;
|
||||
sampleDuration?: string;
|
||||
}
|
||||
|
||||
export interface CpuTimesSnapshot {
|
||||
idle: number;
|
||||
irq: number;
|
||||
nice: number;
|
||||
sys: number;
|
||||
user: number;
|
||||
}
|
||||
|
||||
export interface RawCpuExpectConfig {
|
||||
durationMs?: RawValueExpectation;
|
||||
idlePercent?: RawValueExpectation;
|
||||
maxCoreUsagePercent?: RawValueExpectation;
|
||||
minCoreUsagePercent?: RawValueExpectation;
|
||||
usagePercent?: RawValueExpectation;
|
||||
}
|
||||
|
||||
export interface ResolvedCpuConfig {
|
||||
includePerCore: boolean;
|
||||
sampleDurationMs: number;
|
||||
}
|
||||
|
||||
export interface ResolvedCpuExpectConfig {
|
||||
durationMs?: ValueExpectation;
|
||||
idlePercent?: ValueExpectation;
|
||||
maxCoreUsagePercent?: ValueExpectation;
|
||||
minCoreUsagePercent?: ValueExpectation;
|
||||
usagePercent?: ValueExpectation;
|
||||
}
|
||||
|
||||
export interface ResolvedCpuTarget extends ResolvedTargetBase {
|
||||
cpu: ResolvedCpuConfig;
|
||||
expect?: ResolvedCpuExpectConfig;
|
||||
group: string;
|
||||
intervalMs: number;
|
||||
name: null | string;
|
||||
timeoutMs: number;
|
||||
type: "cpu";
|
||||
}
|
||||
136
src/server/checker/runner/cpu/validate.ts
Normal file
136
src/server/checker/runner/cpu/validate.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { isString } from "es-toolkit";
|
||||
|
||||
import type { ConfigValidationIssue } from "../../schema/issues";
|
||||
import type { CheckerValidationInput } from "../types";
|
||||
|
||||
import { isPlainRecord, validateRawValueExpectation } from "../../expect/validate";
|
||||
import { issue, joinPath } from "../../schema/issues";
|
||||
import { parseDuration } from "../../utils";
|
||||
|
||||
export function validateCpuConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
|
||||
for (let i = 0; i < input.targets.length; i++) {
|
||||
const target = input.targets[i] as unknown;
|
||||
if (!isPlainRecord(target)) continue;
|
||||
if (target["type"] !== "cpu") continue;
|
||||
issues.push(...validateCpuTarget(target, `targets[${i}]`));
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function getTargetName(target: Record<string, unknown>): string | undefined {
|
||||
if (isString(target["name"])) return target["name"];
|
||||
return isString(target["id"]) ? target["id"] : undefined;
|
||||
}
|
||||
|
||||
function validateCpuExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||
const rawExpect = target["expect"];
|
||||
if (rawExpect === undefined || rawExpect === null || !isPlainRecord(rawExpect)) return [];
|
||||
const expect = rawExpect;
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const targetName = getTargetName(target);
|
||||
const expectPath = joinPath(path, "expect");
|
||||
|
||||
const valueFields = ["durationMs", "idlePercent", "maxCoreUsagePercent", "minCoreUsagePercent", "usagePercent"];
|
||||
for (const key of valueFields) {
|
||||
if (expect[key] !== undefined) {
|
||||
issues.push(...validateRawValueExpectation(expect[key], joinPath(expectPath, key), targetName));
|
||||
}
|
||||
}
|
||||
|
||||
const allowedKeys = new Set(valueFields);
|
||||
for (const key of Object.keys(expect)) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function validateCpuTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const targetName = getTargetName(target);
|
||||
|
||||
// 校验 cpu 配置段
|
||||
const rawCpu = target["cpu"];
|
||||
if (!isPlainRecord(rawCpu)) {
|
||||
issues.push(issue("required", joinPath(path, "cpu"), "缺少 cpu 配置分组", targetName));
|
||||
} else {
|
||||
// 校验 sampleDuration 格式
|
||||
if (rawCpu["sampleDuration"] !== undefined) {
|
||||
const sd = rawCpu["sampleDuration"];
|
||||
if (isString(sd)) {
|
||||
try {
|
||||
parseDuration(sd);
|
||||
} catch {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
joinPath(joinPath(path, "cpu"), "sampleDuration"),
|
||||
"sampleDuration 不是有效的时长格式",
|
||||
targetName,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
// 变量引用时跳过格式校验(authoring 形态允许 "${...}")
|
||||
}
|
||||
|
||||
// 校验 sampleDuration < timeout(仅当两者都可解析为数值时)
|
||||
if (isString(rawCpu["sampleDuration"])) {
|
||||
try {
|
||||
const sampleMs = parseDuration(rawCpu["sampleDuration"]);
|
||||
const timeout = target["timeout"];
|
||||
if (isString(timeout)) {
|
||||
try {
|
||||
const timeoutMs = parseDuration(timeout);
|
||||
if (sampleMs >= timeoutMs) {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
joinPath(joinPath(path, "cpu"), "sampleDuration"),
|
||||
"sampleDuration 必须小于 timeout",
|
||||
targetName,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// timeout 无法解析,由通用校验处理
|
||||
}
|
||||
}
|
||||
// timeout 为 undefined 时使用默认值 10s
|
||||
if (timeout === undefined && sampleMs >= 10000) {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
joinPath(joinPath(path, "cpu"), "sampleDuration"),
|
||||
"sampleDuration 必须小于 timeout(默认 10s)",
|
||||
targetName,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// sampleDuration 无法解析,已由上方格式校验处理
|
||||
}
|
||||
}
|
||||
|
||||
if (rawCpu["includePerCore"] !== undefined && typeof rawCpu["includePerCore"] !== "boolean") {
|
||||
issues.push(issue("invalid-type", joinPath(joinPath(path, "cpu"), "includePerCore"), "必须为布尔值", targetName));
|
||||
}
|
||||
|
||||
const allowedCpuKeys = new Set(["includePerCore", "sampleDuration"]);
|
||||
for (const key of Object.keys(rawCpu)) {
|
||||
if (!allowedCpuKeys.has(key)) {
|
||||
issues.push(issue("unknown-field", joinPath(joinPath(path, "cpu"), key), "是未知字段", targetName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 校验 expect 字段
|
||||
issues.push(...validateCpuExpect(target, path));
|
||||
|
||||
return issues;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CommandChecker } from "./cmd";
|
||||
import { CpuChecker } from "./cpu";
|
||||
import { DbChecker } from "./db";
|
||||
import { DnsChecker } from "./dns";
|
||||
import { HttpChecker } from "./http";
|
||||
@@ -19,6 +20,7 @@ const checkers = [
|
||||
new LlmChecker(),
|
||||
new DnsChecker(),
|
||||
new WsChecker(),
|
||||
new CpuChecker(),
|
||||
];
|
||||
|
||||
export function createDefaultCheckerRegistry(): CheckerRegistry {
|
||||
|
||||
@@ -2270,6 +2270,82 @@ targets:
|
||||
);
|
||||
});
|
||||
|
||||
test("解析最简 cpu 配置", async () => {
|
||||
const configPath = join(tempDir, "minimal-cpu.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- id: "local-cpu"
|
||||
type: cpu
|
||||
cpu: {}
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
expect(config.targets).toHaveLength(1);
|
||||
const t = config.targets[0]! as Record<string, unknown>;
|
||||
expect(t["type"]).toBe("cpu");
|
||||
expect((t["cpu"] as Record<string, unknown>)["sampleDurationMs"]).toBe(1000);
|
||||
expect((t["cpu"] as Record<string, unknown>)["includePerCore"]).toBe(false);
|
||||
expect(t["group"]).toBe("default");
|
||||
expect(t["intervalMs"]).toBe(30000);
|
||||
expect(t["timeoutMs"]).toBe(10000);
|
||||
});
|
||||
|
||||
test("解析 cpu expect 配置", async () => {
|
||||
const configPath = join(tempDir, "cpu-expect.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- id: "local-cpu"
|
||||
type: cpu
|
||||
cpu:
|
||||
sampleDuration: "2s"
|
||||
includePerCore: true
|
||||
expect:
|
||||
usagePercent: { lte: 85 }
|
||||
idlePercent: { gte: 15 }
|
||||
maxCoreUsagePercent: { lte: 95 }
|
||||
durationMs: { lte: 3000 }
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
expect(config.targets).toHaveLength(1);
|
||||
const t = config.targets[0]! as Record<string, unknown>;
|
||||
expect((t["cpu"] as Record<string, unknown>)["sampleDurationMs"]).toBe(2000);
|
||||
expect((t["cpu"] as Record<string, unknown>)["includePerCore"]).toBe(true);
|
||||
expect((t["expect"] as Record<string, unknown>)["usagePercent"]).toEqual({ lte: 85 });
|
||||
});
|
||||
|
||||
test("cpu expect 未知字段抛出错误", async () => {
|
||||
await expectConfigError(
|
||||
"cpu-unknown-expect.yaml",
|
||||
`targets:
|
||||
- id: "local-cpu"
|
||||
type: cpu
|
||||
cpu: {}
|
||||
expect:
|
||||
logicalCoreCount: { gte: 4 }
|
||||
`,
|
||||
"expect.logicalCoreCount 是未知字段",
|
||||
);
|
||||
});
|
||||
|
||||
test("cpu sampleDuration >= timeout 抛出错误", async () => {
|
||||
await expectConfigError(
|
||||
"cpu-sample-too-long.yaml",
|
||||
`targets:
|
||||
- id: "local-cpu"
|
||||
type: cpu
|
||||
timeout: "1s"
|
||||
cpu:
|
||||
sampleDuration: "5s"
|
||||
`,
|
||||
"sampleDuration 必须小于 timeout",
|
||||
);
|
||||
});
|
||||
|
||||
describe("logging 配置", () => {
|
||||
test("logging 全部缺省时使用默认值", async () => {
|
||||
const configPath = join(tempDir, "logging-default.yaml");
|
||||
|
||||
112
tests/server/checker/runner/cpu/calculate.test.ts
Normal file
112
tests/server/checker/runner/cpu/calculate.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import type { CpuCoreSnapshot } from "../../../../../src/server/checker/runner/cpu/types";
|
||||
|
||||
import { calculateCpuStats } from "../../../../../src/server/checker/runner/cpu/calculate";
|
||||
|
||||
function makeCore(user: number, nice: number, sys: number, idle: number, irq: number): CpuCoreSnapshot {
|
||||
return { times: { idle, irq, nice, sys, user } };
|
||||
}
|
||||
|
||||
describe("calculateCpuStats", () => {
|
||||
test("单核心完全空闲", () => {
|
||||
const before = [makeCore(0, 0, 0, 100, 0)];
|
||||
const after = [makeCore(0, 0, 0, 200, 0)];
|
||||
const stats = calculateCpuStats(before, after);
|
||||
expect(stats.usagePercent).toBe(0);
|
||||
expect(stats.idlePercent).toBe(100);
|
||||
expect(stats.maxCoreUsagePercent).toBe(0);
|
||||
expect(stats.minCoreUsagePercent).toBe(0);
|
||||
expect(stats.logicalCoreCount).toBe(1);
|
||||
});
|
||||
|
||||
test("单核心完全忙碌(idle 不变)", () => {
|
||||
const before = [makeCore(100, 0, 0, 100, 0)];
|
||||
const after = [makeCore(200, 0, 0, 100, 0)];
|
||||
// idle delta = 0, total delta = 100
|
||||
// idlePercent = 0, usagePercent = 100
|
||||
const stats = calculateCpuStats(before, after);
|
||||
expect(stats.usagePercent).toBe(100);
|
||||
expect(stats.idlePercent).toBe(0);
|
||||
});
|
||||
|
||||
test("单核心部分使用", () => {
|
||||
const before = [makeCore(100, 0, 0, 900, 0)];
|
||||
const after = [makeCore(150, 0, 0, 950, 0)];
|
||||
// idle delta = 50, total delta = 100
|
||||
// idlePercent = 50, usagePercent = 50
|
||||
const stats = calculateCpuStats(before, after);
|
||||
expect(stats.usagePercent).toBe(50);
|
||||
expect(stats.idlePercent).toBe(50);
|
||||
});
|
||||
|
||||
test("多核心加权平均", () => {
|
||||
// 核心 0: idle delta = 200, total delta = 1000 -> 80% usage
|
||||
// 核心 1: idle delta = 800, total delta = 1000 -> 20% usage
|
||||
const before = [makeCore(0, 0, 0, 1000, 0), makeCore(0, 0, 0, 1000, 0)];
|
||||
const after = [makeCore(800, 0, 0, 1200, 0), makeCore(200, 0, 0, 1800, 0)];
|
||||
const stats = calculateCpuStats(before, after);
|
||||
// 总 idle = 200+800=1000, 总 delta = 1000+1000=2000
|
||||
// idlePercent = 1000/2000*100 = 50
|
||||
// usagePercent = 100 - 50 = 50
|
||||
expect(stats.idlePercent).toBe(50);
|
||||
expect(stats.usagePercent).toBe(50);
|
||||
expect(stats.maxCoreUsagePercent).toBe(80);
|
||||
expect(stats.minCoreUsagePercent).toBe(20);
|
||||
expect(stats.logicalCoreCount).toBe(2);
|
||||
expect(stats.perCoreUsagePercent).toEqual([80, 20]);
|
||||
});
|
||||
|
||||
test("四核心各不相同", () => {
|
||||
const bf = [
|
||||
makeCore(1000, 0, 0, 9000, 0), // core 0 baseline
|
||||
makeCore(1000, 0, 0, 9000, 0), // core 1
|
||||
makeCore(1000, 0, 0, 9000, 0), // core 2
|
||||
makeCore(1000, 0, 0, 9000, 0), // core 3
|
||||
];
|
||||
const af = [
|
||||
makeCore(1900, 0, 0, 9100, 0), // delta: user=900, idle=100, total=1000 -> 90% usage, 10% idle
|
||||
makeCore(1500, 0, 0, 9500, 0), // delta: user=500, idle=500, total=1000 -> 50% usage
|
||||
makeCore(1200, 0, 0, 9800, 0), // delta: user=200, idle=800, total=1000 -> 20% usage
|
||||
makeCore(1010, 0, 0, 9990, 0), // delta: user=10, idle=990, total=1000 -> 1% usage
|
||||
];
|
||||
const stats = calculateCpuStats(bf, af);
|
||||
// 总 idle = 100+500+800+990 = 2390, 总 delta = 4000
|
||||
// idlePercent = 2390/4000*100 = 59.75 -> 59.8
|
||||
expect(stats.idlePercent).toBe(59.8);
|
||||
expect(stats.usagePercent).toBe(40.2);
|
||||
expect(stats.maxCoreUsagePercent).toBe(90);
|
||||
expect(stats.minCoreUsagePercent).toBe(1);
|
||||
expect(stats.perCoreUsagePercent).toEqual([90, 50, 20, 1]);
|
||||
expect(stats.logicalCoreCount).toBe(4);
|
||||
});
|
||||
|
||||
test("delta 为 0 时返回 0", () => {
|
||||
const before = [makeCore(100, 0, 0, 100, 0)];
|
||||
const after = [makeCore(100, 0, 0, 100, 0)];
|
||||
const stats = calculateCpuStats(before, after);
|
||||
expect(stats.usagePercent).toBe(0);
|
||||
expect(stats.idlePercent).toBe(0);
|
||||
});
|
||||
|
||||
test("保留 1 位小数", () => {
|
||||
// 总 idle = 333, 总 delta = 1000 -> idlePercent = 33.3
|
||||
const before = [makeCore(0, 0, 0, 1000, 0)];
|
||||
const after = [makeCore(667, 0, 0, 1333, 0)];
|
||||
const stats = calculateCpuStats(before, after);
|
||||
// idle delta = 333, total delta = 1000
|
||||
expect(stats.idlePercent).toBe(33.3);
|
||||
expect(stats.usagePercent).toBe(66.7);
|
||||
});
|
||||
|
||||
test("nice 和 irq 计入 total 但不影响 idle", () => {
|
||||
const bf = [makeCore(0, 0, 0, 0, 0)];
|
||||
const af = [makeCore(300, 100, 100, 400, 100)];
|
||||
// total delta = 300+100+100+400+100 = 1000
|
||||
// idle delta = 400
|
||||
// idlePercent = 400/1000*100 = 40
|
||||
const stats = calculateCpuStats(bf, af);
|
||||
expect(stats.idlePercent).toBe(40);
|
||||
expect(stats.usagePercent).toBe(60);
|
||||
});
|
||||
});
|
||||
255
tests/server/checker/runner/cpu/execute.test.ts
Normal file
255
tests/server/checker/runner/cpu/execute.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
55
tests/server/checker/runner/cpu/expect.test.ts
Normal file
55
tests/server/checker/runner/cpu/expect.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
checkIdlePercent,
|
||||
checkMaxCoreUsage,
|
||||
checkMinCoreUsage,
|
||||
checkUsagePercent,
|
||||
} from "../../../../../src/server/checker/runner/cpu/expect";
|
||||
|
||||
describe("CPU expect checks", () => {
|
||||
test("checkUsagePercent 匹配", () => {
|
||||
expect(checkUsagePercent(50, { lte: 85 }).matched).toBe(true);
|
||||
});
|
||||
|
||||
test("checkUsagePercent 不匹配", () => {
|
||||
const result = checkUsagePercent(90, { lte: 85 });
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.phase).toBe("usage");
|
||||
});
|
||||
|
||||
test("checkIdlePercent 匹配", () => {
|
||||
expect(checkIdlePercent(50, { gte: 15 }).matched).toBe(true);
|
||||
});
|
||||
|
||||
test("checkIdlePercent 不匹配", () => {
|
||||
const result = checkIdlePercent(10, { gte: 15 });
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.phase).toBe("idle");
|
||||
});
|
||||
|
||||
test("checkMaxCoreUsage 匹配", () => {
|
||||
expect(checkMaxCoreUsage(80, { lte: 95 }).matched).toBe(true);
|
||||
});
|
||||
|
||||
test("checkMaxCoreUsage 不匹配", () => {
|
||||
const result = checkMaxCoreUsage(96, { lte: 95 });
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.phase).toBe("maxCoreUsage");
|
||||
});
|
||||
|
||||
test("checkMinCoreUsage 匹配", () => {
|
||||
expect(checkMinCoreUsage(10, { gte: 5 }).matched).toBe(true);
|
||||
});
|
||||
|
||||
test("checkMinCoreUsage 不匹配", () => {
|
||||
const result = checkMinCoreUsage(3, { gte: 5 });
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.phase).toBe("minCoreUsage");
|
||||
});
|
||||
|
||||
test("undefined matcher 直接通过", () => {
|
||||
expect(checkUsagePercent(99.9, undefined).matched).toBe(true);
|
||||
expect(checkIdlePercent(0, undefined).matched).toBe(true);
|
||||
});
|
||||
});
|
||||
41
tests/server/checker/runner/cpu/normalize.test.ts
Normal file
41
tests/server/checker/runner/cpu/normalize.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { normalizeTargetExpect } from "../../../../../src/server/checker/runner/cpu/normalize";
|
||||
|
||||
describe("normalizeTargetExpect (cpu)", () => {
|
||||
test("无 expect 直接返回", () => {
|
||||
const target = { cpu: {}, id: "test", type: "cpu" };
|
||||
expect(normalizeTargetExpect(target)).toEqual(target);
|
||||
});
|
||||
|
||||
test("expect 为非对象直接返回", () => {
|
||||
const target = { cpu: {}, expect: "not-an-object", id: "test", type: "cpu" };
|
||||
expect(normalizeTargetExpect(target)).toEqual(target);
|
||||
});
|
||||
|
||||
test("ValueMatcher 简写展开", () => {
|
||||
const target = { cpu: {}, expect: { usagePercent: 85 }, id: "test", type: "cpu" };
|
||||
const result = normalizeTargetExpect(target);
|
||||
expect((result.expect as Record<string, unknown>)["usagePercent"]).toEqual({ equals: 85 });
|
||||
});
|
||||
|
||||
test("已经是 matcher 对象的不变", () => {
|
||||
const target = { cpu: {}, expect: { usagePercent: { lte: 85 } }, id: "test", type: "cpu" };
|
||||
const result = normalizeTargetExpect(target);
|
||||
expect((result.expect as Record<string, unknown>)["usagePercent"]).toEqual({ lte: 85 });
|
||||
});
|
||||
|
||||
test("多个字段同时展开", () => {
|
||||
const target = {
|
||||
cpu: {},
|
||||
expect: { idlePercent: 15, maxCoreUsagePercent: { lte: 95 }, usagePercent: 85 },
|
||||
id: "test",
|
||||
type: "cpu",
|
||||
};
|
||||
const result = normalizeTargetExpect(target);
|
||||
const expectObj = result.expect as Record<string, unknown>;
|
||||
expect(expectObj["idlePercent"]).toEqual({ equals: 15 });
|
||||
expect(expectObj["maxCoreUsagePercent"]).toEqual({ lte: 95 });
|
||||
expect(expectObj["usagePercent"]).toEqual({ equals: 85 });
|
||||
});
|
||||
});
|
||||
77
tests/server/checker/runner/cpu/schema.test.ts
Normal file
77
tests/server/checker/runner/cpu/schema.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import Ajv from "ajv";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { cpuCheckerSchemas } from "../../../../../src/server/checker/runner/cpu/schema";
|
||||
|
||||
const ajv = new Ajv({ strict: false });
|
||||
|
||||
describe("CPU checker schema", () => {
|
||||
test("authoring config 允许变量引用", () => {
|
||||
const validate = ajv.compile(cpuCheckerSchemas.authoring.config);
|
||||
expect(validate({ includePerCore: "${per_core|false}", sampleDuration: "${sample_dur|1s}" })).toBe(true);
|
||||
});
|
||||
|
||||
test("normalized config 允许合法值", () => {
|
||||
const validate = ajv.compile(cpuCheckerSchemas.normalized.config);
|
||||
expect(validate({ includePerCore: true, sampleDuration: "1s" })).toBe(true);
|
||||
});
|
||||
|
||||
test("normalized config 空配置通过", () => {
|
||||
const validate = ajv.compile(cpuCheckerSchemas.normalized.config);
|
||||
expect(validate({})).toBe(true);
|
||||
});
|
||||
|
||||
test("config 拒绝额外字段", () => {
|
||||
const validate = ajv.compile(cpuCheckerSchemas.authoring.config);
|
||||
expect(validate({ extraField: true })).toBe(false);
|
||||
});
|
||||
|
||||
test("authoring expect 允许 ValueMatcher 简写", () => {
|
||||
const validate = ajv.compile(cpuCheckerSchemas.authoring.expect);
|
||||
expect(validate({ usagePercent: 85 })).toBe(true);
|
||||
expect(validate({ usagePercent: { lte: 85 } })).toBe(true);
|
||||
});
|
||||
|
||||
test("normalized expect 允许 matcher 对象", () => {
|
||||
const validate = ajv.compile(cpuCheckerSchemas.normalized.expect);
|
||||
expect(validate({ idlePercent: { gte: 15 }, usagePercent: { lte: 85 } })).toBe(true);
|
||||
});
|
||||
|
||||
test("expect 拒绝 logicalCoreCount 字段", () => {
|
||||
const validate = ajv.compile(cpuCheckerSchemas.authoring.expect);
|
||||
expect(validate({ logicalCoreCount: { gte: 4 } })).toBe(false);
|
||||
});
|
||||
|
||||
test("expect 拒绝 userPercent 字段", () => {
|
||||
const validate = ajv.compile(cpuCheckerSchemas.authoring.expect);
|
||||
expect(validate({ userPercent: { lte: 50 } })).toBe(false);
|
||||
});
|
||||
|
||||
test("expect 拒绝 systemPercent 字段", () => {
|
||||
const validate = ajv.compile(cpuCheckerSchemas.authoring.expect);
|
||||
expect(validate({ systemPercent: { lte: 50 } })).toBe(false);
|
||||
});
|
||||
|
||||
test("expect 允许所有合法字段", () => {
|
||||
const validate = ajv.compile(cpuCheckerSchemas.normalized.expect);
|
||||
expect(
|
||||
validate({
|
||||
durationMs: { lte: 2000 },
|
||||
idlePercent: { gte: 15 },
|
||||
maxCoreUsagePercent: { lte: 95 },
|
||||
minCoreUsagePercent: { gte: 0 },
|
||||
usagePercent: { lte: 85 },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("expect 拒绝额外字段", () => {
|
||||
const validate = ajv.compile(cpuCheckerSchemas.normalized.expect);
|
||||
expect(validate({ unknownField: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
test("expect 空对象通过", () => {
|
||||
const validate = ajv.compile(cpuCheckerSchemas.normalized.expect);
|
||||
expect(validate({})).toBe(true);
|
||||
});
|
||||
});
|
||||
84
tests/server/checker/runner/cpu/validate.test.ts
Normal file
84
tests/server/checker/runner/cpu/validate.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import type { RawTargetConfig } from "../../../../../src/server/checker/types";
|
||||
|
||||
import { validateCpuConfig } from "../../../../../src/server/checker/runner/cpu/validate";
|
||||
|
||||
function validate(target: RawTargetConfig) {
|
||||
return validateCpuConfig({ targets: [target] });
|
||||
}
|
||||
|
||||
describe("validateCpuConfig", () => {
|
||||
test("有效配置无错误", () => {
|
||||
expect(validate({ cpu: { sampleDuration: "1s" }, id: "cpu-test", type: "cpu" })).toEqual([]);
|
||||
});
|
||||
|
||||
test("空 cpu 配置无错误", () => {
|
||||
expect(validate({ cpu: {}, id: "cpu-test", type: "cpu" })).toEqual([]);
|
||||
});
|
||||
|
||||
test("缺少 cpu 配置分组", () => {
|
||||
const issues = validate({ id: "cpu-test", type: "cpu" });
|
||||
expect(issues.some((i) => i.path.endsWith("cpu") && i.code === "required")).toBe(true);
|
||||
});
|
||||
|
||||
test("无效 sampleDuration 格式", () => {
|
||||
const issues = validate({ cpu: { sampleDuration: "abc" }, id: "cpu-test", type: "cpu" });
|
||||
expect(issues.some((i) => i.path.endsWith("sampleDuration"))).toBe(true);
|
||||
});
|
||||
|
||||
test("sampleDuration >= timeout 报错", () => {
|
||||
const issues = validate({ cpu: { sampleDuration: "5s" }, id: "cpu-test", timeout: "5s", type: "cpu" });
|
||||
expect(issues.some((i) => i.path.endsWith("sampleDuration") && i.message.includes("必须小于 timeout"))).toBe(true);
|
||||
});
|
||||
|
||||
test("sampleDuration 大于默认 timeout (10s) 报错", () => {
|
||||
const issues = validate({ cpu: { sampleDuration: "15s" }, id: "cpu-test", type: "cpu" });
|
||||
expect(issues.some((i) => i.message.includes("默认 10s"))).toBe(true);
|
||||
});
|
||||
|
||||
test("sampleDuration < timeout 通过", () => {
|
||||
const issues = validate({ cpu: { sampleDuration: "1s" }, id: "cpu-test", timeout: "5s", type: "cpu" });
|
||||
expect(issues.some((i) => i.path.includes("sampleDuration"))).toBe(false);
|
||||
});
|
||||
|
||||
test("includePerCore 非布尔值报错", () => {
|
||||
const issues = validate({ cpu: { includePerCore: "yes" }, id: "cpu-test", type: "cpu" });
|
||||
expect(issues.some((i) => i.path.endsWith("includePerCore") && i.code === "invalid-type")).toBe(true);
|
||||
});
|
||||
|
||||
test("cpu 未知字段报错", () => {
|
||||
const issues = validate({ cpu: { extra: true }, id: "cpu-test", type: "cpu" });
|
||||
expect(issues.some((i) => i.path.endsWith("extra") && i.code === "unknown-field")).toBe(true);
|
||||
});
|
||||
|
||||
test("expect 未知字段报错", () => {
|
||||
const issues = validate({ cpu: {}, expect: { logicalCoreCount: { gte: 4 } }, id: "cpu-test", type: "cpu" });
|
||||
expect(issues.some((i) => i.path.endsWith("logicalCoreCount") && i.code === "unknown-field")).toBe(true);
|
||||
});
|
||||
|
||||
test("expect userPercent 未知字段报错", () => {
|
||||
const issues = validate({ cpu: {}, expect: { userPercent: { lte: 50 } }, id: "cpu-test", type: "cpu" });
|
||||
expect(issues.some((i) => i.path.endsWith("userPercent") && i.code === "unknown-field")).toBe(true);
|
||||
});
|
||||
|
||||
test("expect systemPercent 未知字段报错", () => {
|
||||
const issues = validate({ cpu: {}, expect: { systemPercent: { lte: 50 } }, id: "cpu-test", type: "cpu" });
|
||||
expect(issues.some((i) => i.path.endsWith("systemPercent") && i.code === "unknown-field")).toBe(true);
|
||||
});
|
||||
|
||||
test("expect 合法 ValueMatcher 通过", () => {
|
||||
const issues = validate({
|
||||
cpu: {},
|
||||
expect: { maxCoreUsagePercent: { lte: 95 }, usagePercent: { lte: 85 } },
|
||||
id: "cpu-test",
|
||||
type: "cpu",
|
||||
});
|
||||
expect(issues.filter((i) => i.path.includes("expect"))).toEqual([]);
|
||||
});
|
||||
|
||||
test("expect 非法 ValueMatcher 报错", () => {
|
||||
const issues = validate({ cpu: {}, expect: { usagePercent: [1, 2] }, id: "cpu-test", type: "cpu" });
|
||||
expect(issues.some((i) => i.path.includes("usagePercent"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -73,8 +73,20 @@ describe("CheckerRegistry", () => {
|
||||
const second = createDefaultCheckerRegistry();
|
||||
first.register(createChecker("custom"));
|
||||
|
||||
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.supportedTypes).toEqual([
|
||||
"http",
|
||||
"cmd",
|
||||
"db",
|
||||
"tcp",
|
||||
"icmp",
|
||||
"udp",
|
||||
"llm",
|
||||
"dns",
|
||||
"ws",
|
||||
"cpu",
|
||||
"custom",
|
||||
]);
|
||||
expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "dns", "ws", "cpu"]);
|
||||
expect(
|
||||
first.definitions.every((checker) => checker.schemas.authoring.config && checker.schemas.normalized.expect),
|
||||
).toBe(true);
|
||||
|
||||
Reference in New Issue
Block a user