1
0

Compare commits

...

3 Commits

Author SHA1 Message Date
145bb8fd04 feat: 新增 memory checker,支持系统级内存和交换空间检测 2026-05-27 00:05:06 +08:00
358f8d011a chore: 放宽权限配置 & 归档 openspec 变更记录 2026-05-26 22:36:33 +08:00
c2dcfab80c 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
2026-05-26 22:34:57 +08:00
40 changed files with 4188 additions and 15 deletions

View File

@@ -2,7 +2,10 @@
"$schema": "https://raw.githubusercontent.com/gotgenes/pi-permission-system/main/schemas/permissions.schema.json",
"permission": {
"*": "allow",
"write": "allow",
"edit": "allow",
"bash": {
"*": "allow",
"npm *": "deny",
"npx *": "deny",
"pnpm *": "deny",

View File

@@ -22,6 +22,7 @@
"react": "^19.2.6",
"react-dom": "^19.2.6",
"recharts": "^3.8.1",
"systeminformation": "^5.31.6",
"tdesign-icons-react": "^0.6.4",
"tdesign-react": "^1.16.9",
"xpath": "^0.0.34",
@@ -498,7 +499,7 @@
"cosmiconfig-typescript-loader": ["cosmiconfig-typescript-loader@6.3.0", "https://registry.npmmirror.com/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.3.0.tgz", { "dependencies": { "jiti": "2.6.1" }, "peerDependencies": { "@types/node": "*", "cosmiconfig": ">=9", "typescript": ">=5" } }, "sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA=="],
"croner": ["croner@10.0.1", "", {}, "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g=="],
"croner": ["croner@10.0.1", "https://registry.npmmirror.com/croner/-/croner-10.0.1.tgz", {}, "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g=="],
"cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
@@ -1134,6 +1135,8 @@
"synckit": ["synckit@0.11.12", "https://registry.npmmirror.com/synckit/-/synckit-0.11.12.tgz", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="],
"systeminformation": ["systeminformation@5.31.6", "https://registry.npmmirror.com/systeminformation/-/systeminformation-5.31.6.tgz", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-Uv2b2uGGM6ns+26czgW2cYRabYdnswM0ddSOOlryHOaelzsmDSet1iM/NT7VOYxW8x/BW+HkY+b1Ve2pLTSGSA=="],
"tar-stream": ["tar-stream@3.2.0", "https://registry.npmmirror.com/tar-stream/-/tar-stream-3.2.0.tgz", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg=="],
"tdesign-icons-react": ["tdesign-icons-react@0.6.4", "https://registry.npmmirror.com/tdesign-icons-react/-/tdesign-icons-react-0.6.4.tgz", { "dependencies": { "@babel/runtime": "^7.16.5", "classnames": "^2.2.6" }, "peerDependencies": { "react": ">=16.13.1", "react-dom": ">=16.13.1" } }, "sha512-USAoi9vBWcwcJT45VqR3dRqX1MeAsn/RhHVx4bLwplhrlvE80ZQ1N9V+6F3HqE1Qe9mMDbtRM8Ul80+lALScww=="],

View File

@@ -6,17 +6,19 @@ Checker 是 DiAL 的拨测执行单元。每个 target 通过 `type` 选择一
## 支持的类型
| 类型 | 用途 | 文档 |
| ------ | -------------------------------------- | --------------- |
| `http` | HTTP/HTTPS 应用层健康检查 | [HTTP](http.md) |
| `cmd` | 执行本地命令或脚本 | [Cmd](cmd.md) |
| `db` | PostgreSQL/MySQL/SQLite 连接和查询检查 | [DB](db.md) |
| `tcp` | TCP 端口可达性和 banner 探测 | [TCP](tcp.md) |
| `udp` | UDP payload 请求-响应检查 | [UDP](udp.md) |
| `icmp` | 基于系统 `ping` 的存活、延迟、丢包检查 | [ICMP](icmp.md) |
| `dns` | 本机解析或指定 DNS server 协议级检查 | [DNS](dns.md) |
| `llm` | 大模型服务应用层健康检查 | [LLM](llm.md) |
| `ws` | WebSocket 可达性和消息交互检查 | [WS](ws.md) |
| 类型 | 用途 | 文档 |
| -------- | -------------------------------------- | ------------------- |
| `http` | HTTP/HTTPS 应用层健康检查 | [HTTP](http.md) |
| `cmd` | 执行本地命令或脚本 | [Cmd](cmd.md) |
| `db` | PostgreSQL/MySQL/SQLite 连接和查询检查 | [DB](db.md) |
| `tcp` | TCP 端口可达性和 banner 探测 | [TCP](tcp.md) |
| `udp` | UDP payload 请求-响应检查 | [UDP](udp.md) |
| `icmp` | 基于系统 `ping` 的存活、延迟、丢包检查 | [ICMP](icmp.md) |
| `dns` | 本机解析或指定 DNS server 协议级检查 | [DNS](dns.md) |
| `llm` | 大模型服务应用层健康检查 | [LLM](llm.md) |
| `ws` | WebSocket 可达性和消息交互检查 | [WS](ws.md) |
| `cpu` | 本机 CPU 使用率健康检查 | [CPU](cpu.md) |
| `memory` | 本机系统内存使用状况检查 | [Memory](memory.md) |
## 选择建议
@@ -31,6 +33,8 @@ Checker 是 DiAL 的拨测执行单元。每个 target 通过 `type` 选择一
| 域名解析值、DNS RCODE、TTL、flags | `dns` |
| LLM API 是否可用、输出是否符合预期 | `llm` |
| WebSocket 可达性或消息交互验证 | `ws` |
| 本机 CPU 使用率健康检查 | `cpu` |
| 本机系统内存使用状况检查 | `memory` |
## 通用字段

74
docs/user/checkers/cpu.md Normal file
View 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 字段、行为或语义时,必须更新本文档。

View File

@@ -0,0 +1,119 @@
# Memory Checker
`type: memory` 用于检查本机系统级内存使用状况,包括物理内存和交换空间的使用率及字节数。
## 配置项
Memory checker 配置为空对象,无需额外参数:
```yaml
memory: {}
```
## expect 校验项
### 百分比字段
| 字段 | 说明 | 必填 | 默认值 |
| ------------------ | -------------------------------------------------------------------------- | ---- | ------ |
| `usagePercent` | 真实内存使用率 = `activeBytes / totalBytes × 100`,不含 buffers/cache 假象 | 否 | 无 |
| `usedPercent` | 原始已用百分比 = `usedBytes / totalBytes × 100`,包含 buffers/cache | 否 | 无 |
| `freePercent` | 空闲百分比 = `freeBytes / totalBytes × 100` | 否 | 无 |
| `activePercent` | 活跃内存百分比 = `activeBytes / totalBytes × 100` | 否 | 无 |
| `availablePercent` | 可用内存百分比 = `availableBytes / totalBytes × 100` | 否 | 无 |
| `swapUsagePercent` | 交换空间使用率,当系统无交换分区时为 `null` | 否 | 无 |
所有百分比字段范围为 `0-100`,使用 `ValueMatcher`
### 字节字段
| 字段 | 说明 | 必填 | 默认值 |
| ---------------- | ----------------------------------------- | ---- | ------ |
| `activeBytes` | 活跃内存字节数 | 否 | 无 |
| `usedBytes` | 已用内存字节数(含 buffers/cache | 否 | 无 |
| `freeBytes` | 空闲内存字节数 | 否 | 无 |
| `availableBytes` | 可用内存字节数 | 否 | 无 |
| `totalBytes` | 物理内存总字节数 | 否 | 无 |
| `swapUsedBytes` | 交换空间已用字节数,无交换分区时为 `null` | 否 | 无 |
| `swapFreeBytes` | 交换空间空闲字节数,无交换分区时为 `null` | 否 | 无 |
| `swapTotalBytes` | 交换空间总字节数,无交换分区时为 `0` | 否 | 无 |
| `buffcacheBytes` | 缓冲缓存字节数,部分平台可能为 `null` | 否 | 无 |
字节字段支持数字(字节数)或大小字符串(如 `"512MB"``"1GB"`),使用 `ValueMatcher`
### 通用字段
| 字段 | 说明 | 必填 | 默认值 |
| ------------ | ------------------------------------- | ---- | ------ |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
## 示例
检查内存使用率不超过 85%
```yaml
- id: "local-memory"
name: "本机内存"
type: memory
interval: "30s"
timeout: "5s"
memory: {}
expect:
usagePercent:
lte: 85
```
检查可用内存不低于 4GB
```yaml
- id: "local-memory-available"
name: "可用内存检查"
type: memory
memory: {}
expect:
availableBytes:
gte: "4GB"
```
同时检查内存和交换空间:
```yaml
- id: "local-memory-swap"
name: "内存和交换空间"
type: memory
memory: {}
expect:
usagePercent:
lte: 80
swapUsagePercent:
lte: 50
```
## 语义说明
Memory checker 通过 `systeminformation` 库读取系统内存数据,在 Linux、macOS 和 Windows 上均可运行。
- **`usagePercent`** 使用 `activeBytes / totalBytes` 计算,反映真实的内存压力,不受 Linux buffers/cache 缓存影响。推荐使用此字段进行内存健康检查。
- **`usedPercent`** 使用 `usedBytes / totalBytes` 计算,包含 buffers/cache。在 Linux 上此值通常高于 `usagePercent`
- **Swap 字段**:当系统未配置交换分区时,`swapTotalBytes``0``swapUsagePercent``null`(非 `0`)。
- **`buffcacheBytes`**:反映 Linux 的 buffers + cache 用量,在其他平台上可能为 `null`
Memory checker 是即时读取(非采样),无需 `sampleDuration`,执行速度远快于 CPU checker。
## 跨平台注意事项
- Windows 环境依赖 PowerShell 5+ 获取部分内存指标
- `buffcacheBytes` 在非 Linux 平台上可能返回 `null`
- 容器环境中内存数据可能不反映 cgroup 内存限制
## 不支持的功能
- 进程级内存使用(如 RSS、VSZ
- cgroup/container 内存限制精度
- 内存趋势采样和历史记录
- 内存条物理布局信息
- 详细内存分类slab、reclaimable、dirty 等)作为 expect 字段
## 更新触发条件
修改 Memory checker 配置、expect 字段、行为或语义时,必须更新本文档。

View File

@@ -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` |

View File

@@ -71,6 +71,7 @@
"react": "^19.2.6",
"react-dom": "^19.2.6",
"recharts": "^3.8.1",
"systeminformation": "^5.31.6",
"tdesign-icons-react": "^0.6.4",
"tdesign-react": "^1.16.9",
"xpath": "^0.0.34"

File diff suppressed because it is too large Load Diff

View File

@@ -353,3 +353,28 @@ 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
- id: "local-memory"
name: "本机内存"
type: memory
group: "基础设施"
interval: "30s"
timeout: "5s"
memory: {}
expect:
usagePercent:
lte: 85

View 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;
}

View 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 表示被中断abortedfalse 表示正常完成。
*/
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 });
});
}

View 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",
});
}

View File

@@ -0,0 +1 @@
export { CpuChecker } from "./execute";

View 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"]),
}),
};
}

View 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 },
);
}

View 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";
}

View 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;
}

View File

@@ -1,9 +1,11 @@
import { CommandChecker } from "./cmd";
import { CpuChecker } from "./cpu";
import { DbChecker } from "./db";
import { DnsChecker } from "./dns";
import { HttpChecker } from "./http";
import { IcmpChecker } from "./icmp";
import { LlmChecker } from "./llm";
import { MemoryChecker } from "./memory";
import { CheckerRegistry } from "./registry";
import { TcpChecker } from "./tcp";
import { UdpChecker } from "./udp";
@@ -19,6 +21,8 @@ const checkers = [
new LlmChecker(),
new DnsChecker(),
new WsChecker(),
new CpuChecker(),
new MemoryChecker(),
];
export function createDefaultCheckerRegistry(): CheckerRegistry {

View File

@@ -0,0 +1,60 @@
import type { Systeminformation } from "systeminformation";
import type { MemoryStats } from "./types";
export function calculateMemoryStats(data: Systeminformation.MemData): MemoryStats {
const totalBytes = data.total;
const usedBytes = data.used;
const activeBytes = data.active;
const availableBytes = data.available;
const freeBytes = data.free;
const usagePercent = totalBytes > 0 ? round1((activeBytes / totalBytes) * 100) : 0;
const usedPercent = totalBytes > 0 ? round1((usedBytes / totalBytes) * 100) : 0;
const freePercent = totalBytes > 0 ? round1((freeBytes / totalBytes) * 100) : 0;
const activePercent = totalBytes > 0 ? round1((activeBytes / totalBytes) * 100) : 0;
const availablePercent = totalBytes > 0 ? round1((availableBytes / totalBytes) * 100) : 0;
const swapTotalBytes = data.swaptotal;
const swapUsedBytes = data.swapused;
const swapFreeBytes = data.swapfree;
const swapUsagePercent = resolveSwapUsagePercent(swapTotalBytes, swapUsedBytes);
const buffcacheBytes = resolveNullableNumber(data.buffcache);
return {
activeBytes,
activePercent,
availableBytes,
availablePercent,
buffcacheBytes,
freeBytes,
freePercent,
swapFreeBytes: resolveNullableNumber(swapFreeBytes),
swapTotalBytes: resolveNullableNumber(swapTotalBytes),
swapUsagePercent,
swapUsedBytes: resolveNullableNumber(swapUsedBytes),
totalBytes,
usagePercent,
usedBytes,
usedPercent,
};
}
export async function readMemoryData(): Promise<Systeminformation.MemData> {
const si = await import("systeminformation");
return si.mem();
}
function resolveNullableNumber(value: number): null | number {
return value > 0 ? value : value === 0 ? 0 : null;
}
function resolveSwapUsagePercent(swapTotal: number, swapUsed: number): null | number {
if (swapTotal === 0) return null;
return round1((swapUsed / swapTotal) * 100);
}
function round1(value: number): number {
return Math.round(value * 10) / 10;
}

View File

@@ -0,0 +1,183 @@
import type { Systeminformation } from "systeminformation";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { MemoryStats, ResolvedMemoryExpectConfig, ResolvedMemoryTarget } from "./types";
import { errorFailure } from "../../expect/failure";
import { checkValueExpectation } from "../../expect/value";
import { calculateMemoryStats, readMemoryData } from "./calculate";
import {
checkActiveBytes,
checkActivePercent,
checkAvailableBytes,
checkAvailablePercent,
checkBuffcacheBytes,
checkFreeBytes,
checkFreePercent,
checkSwapFreeBytes,
checkSwapTotalBytes,
checkSwapUsagePercent,
checkSwapUsedBytes,
checkTotalBytes,
checkUsagePercent,
checkUsedBytes,
checkUsedPercent,
} from "./expect";
import { normalizeTargetExpect } from "./normalize";
import { memoryCheckerSchemas } from "./schema";
import { validateMemoryConfig } from "./validate";
export class MemoryChecker implements CheckerDefinition<ResolvedMemoryTarget> {
readonly configKey = "memory";
readonly schemas = memoryCheckerSchemas;
readonly type = "memory";
constructor(private readonly reader: () => Promise<Systeminformation.MemData> = readMemoryData) {}
buildDetail(observation: Record<string, unknown>): null | string {
const usage = observation["usagePercent"];
const usageStr = typeof usage === "number" ? formatNumber(usage) : "n/a";
const total = observation["totalBytes"];
const totalStr = typeof total === "number" ? formatBytes(total) : "n/a";
return `usage ${usageStr}%, total ${totalStr}`;
}
async execute(t: ResolvedMemoryTarget, _ctx: CheckerContext): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const start = performance.now();
let data: Systeminformation.MemData;
try {
data = await this.reader();
} catch (error) {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: errorFailure(
"memory",
"snapshot",
`内存数据读取失败: ${error instanceof Error ? error.message : String(error)}`,
),
matched: false,
observation: null,
targetId: t.id,
timestamp,
};
}
const durationMs = Math.round(performance.now() - start);
const stats = calculateMemoryStats(data);
const result = checkStats(stats, t.expect, durationMs);
const observation: Record<string, unknown> = {
activeBytes: stats.activeBytes,
activePercent: stats.activePercent,
availableBytes: stats.availableBytes,
availablePercent: stats.availablePercent,
buffcacheBytes: stats.buffcacheBytes,
error: null,
freeBytes: stats.freeBytes,
freePercent: stats.freePercent,
swapFreeBytes: stats.swapFreeBytes,
swapTotalBytes: stats.swapTotalBytes,
swapUsagePercent: stats.swapUsagePercent,
swapUsedBytes: stats.swapUsedBytes,
totalBytes: stats.totalBytes,
usagePercent: stats.usagePercent,
usedBytes: stats.usedBytes,
usedPercent: stats.usedPercent,
};
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): ResolvedMemoryTarget {
return {
description: null,
expect: target.expect as ResolvedMemoryExpectConfig | undefined,
group: target.group ?? "default",
id: target.id,
intervalMs: context.defaultIntervalMs,
memory: {},
name: target.name ?? null,
timeoutMs: context.defaultTimeoutMs,
type: "memory",
} satisfies ResolvedMemoryTarget;
}
serialize(t: ResolvedMemoryTarget): { config: string; target: string } {
return {
config: JSON.stringify(t.memory),
target: `memory`,
};
}
validate(input: CheckerValidationInput) {
return validateMemoryConfig(input);
}
}
function checkStats(stats: MemoryStats, expect: ResolvedMemoryExpectConfig | undefined, durationMs: number) {
let result = checkUsagePercent(stats.usagePercent, expect?.usagePercent);
if (!result.matched) return result;
result = checkUsedPercent(stats.usedPercent, expect?.usedPercent);
if (!result.matched) return result;
result = checkFreePercent(stats.freePercent, expect?.freePercent);
if (!result.matched) return result;
result = checkActivePercent(stats.activePercent, expect?.activePercent);
if (!result.matched) return result;
result = checkAvailablePercent(stats.availablePercent, expect?.availablePercent);
if (!result.matched) return result;
result = checkActiveBytes(stats.activeBytes, expect?.activeBytes);
if (!result.matched) return result;
result = checkUsedBytes(stats.usedBytes, expect?.usedBytes);
if (!result.matched) return result;
result = checkFreeBytes(stats.freeBytes, expect?.freeBytes);
if (!result.matched) return result;
result = checkAvailableBytes(stats.availableBytes, expect?.availableBytes);
if (!result.matched) return result;
result = checkTotalBytes(stats.totalBytes, expect?.totalBytes);
if (!result.matched) return result;
result = checkSwapUsagePercent(stats.swapUsagePercent, expect?.swapUsagePercent);
if (!result.matched) return result;
result = checkSwapUsedBytes(stats.swapUsedBytes, expect?.swapUsedBytes);
if (!result.matched) return result;
result = checkSwapFreeBytes(stats.swapFreeBytes, expect?.swapFreeBytes);
if (!result.matched) return result;
result = checkSwapTotalBytes(stats.swapTotalBytes, expect?.swapTotalBytes);
if (!result.matched) return result;
result = checkBuffcacheBytes(stats.buffcacheBytes, expect?.buffcacheBytes);
if (!result.matched) return result;
return checkValueExpectation(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
});
}
function formatBytes(bytes: number): string {
if (bytes >= 1073741824) return `${formatNumber(bytes / 1073741824)}GB`;
if (bytes >= 1048576) return `${formatNumber(bytes / 1048576)}MB`;
if (bytes >= 1024) return `${formatNumber(bytes / 1024)}KB`;
return `${bytes}B`;
}
function formatNumber(value: number): string {
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(1)));
}

View File

@@ -0,0 +1,123 @@
import type { ExpectationResult, ValueExpectation } from "../../expect/types";
import { checkValueExpectation } from "../../expect/value";
export function checkActiveBytes(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "内存活跃字节数不满足条件",
path: "activeBytes",
phase: "activeBytes",
});
}
export function checkActivePercent(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "内存活跃百分比不满足条件",
path: "activePercent",
phase: "active",
});
}
export function checkAvailableBytes(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "内存可用字节数不满足条件",
path: "availableBytes",
phase: "availableBytes",
});
}
export function checkAvailablePercent(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "内存可用百分比不满足条件",
path: "availablePercent",
phase: "available",
});
}
export function checkBuffcacheBytes(actual: null | number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "缓冲缓存字节数不满足条件",
path: "buffcacheBytes",
phase: "buffcacheBytes",
});
}
export function checkFreeBytes(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "内存空闲字节数不满足条件",
path: "freeBytes",
phase: "freeBytes",
});
}
export function checkFreePercent(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "内存空闲百分比不满足条件",
path: "freePercent",
phase: "free",
});
}
export function checkSwapFreeBytes(actual: null | number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "交换空间空闲字节数不满足条件",
path: "swapFreeBytes",
phase: "swapFreeBytes",
});
}
export function checkSwapTotalBytes(actual: null | number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "交换空间总字节数不满足条件",
path: "swapTotalBytes",
phase: "swapTotalBytes",
});
}
export function checkSwapUsagePercent(actual: null | number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "交换空间使用率不满足条件",
path: "swapUsagePercent",
phase: "swapUsage",
});
}
export function checkSwapUsedBytes(actual: null | number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "交换空间已用字节数不满足条件",
path: "swapUsedBytes",
phase: "swapUsedBytes",
});
}
export function checkTotalBytes(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "内存总字节数不满足条件",
path: "totalBytes",
phase: "totalBytes",
});
}
export function checkUsagePercent(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "内存使用率不满足条件",
path: "usagePercent",
phase: "usage",
});
}
export function checkUsedBytes(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "内存已用字节数不满足条件",
path: "usedBytes",
phase: "usedBytes",
});
}
export function checkUsedPercent(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "内存已用百分比不满足条件",
path: "usedPercent",
phase: "used",
});
}

View File

@@ -0,0 +1 @@
export { MemoryChecker } from "./execute";

View File

@@ -0,0 +1,72 @@
import { isNumber, isPlainObject, isString } from "es-toolkit";
import type { RawTargetConfig } from "../../types";
import { compactExpect, normalizeValue } from "../../expect/normalize";
import { parseSize } from "../../utils";
const BYTE_FIELDS = new Set([
"activeBytes",
"availableBytes",
"buffcacheBytes",
"freeBytes",
"swapFreeBytes",
"swapTotalBytes",
"swapUsedBytes",
"totalBytes",
"usedBytes",
]);
const PERCENT_FIELDS = new Set([
"activePercent",
"availablePercent",
"freePercent",
"swapUsagePercent",
"usagePercent",
"usedPercent",
]);
export function normalizeByteValue(value: unknown): unknown {
if (value === undefined) return undefined;
if (isString(value)) {
const parsed = parseSize(value);
return normalizeValue(parsed);
}
if (isNumber(value)) {
return normalizeValue(value);
}
if (isPlainObject(value)) {
const obj = value as Record<string, unknown>;
const converted: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
converted[k] = isString(v) ? parseSize(v) : v;
}
return normalizeValue(converted);
}
return normalizeValue(value);
}
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, normalizeAllFields(raw)),
};
}
function normalizeAllFields(raw: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const key of BYTE_FIELDS) {
result[key] = normalizeByteValue(raw[key]);
}
for (const key of PERCENT_FIELDS) {
result[key] = normalizeValue(raw[key]);
}
result["durationMs"] = normalizeValue(raw["durationMs"]);
return result;
}

View File

@@ -0,0 +1,55 @@
import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import {
createAuthoringFieldSchema,
createAuthoringValueExpectationSchema,
createNormalizedValueExpectationSchema,
sizeSchema,
} from "../../schema/fragments";
export const memoryCheckerSchemas: CheckerSchemas = {
authoring: {
config: createMemoryConfigSchema("authoring"),
expect: createMemoryExpectSchema("authoring"),
},
normalized: {
config: createMemoryConfigSchema("normalized"),
expect: createMemoryExpectSchema("normalized"),
},
};
function createMemoryConfigSchema(_kind: "authoring" | "normalized") {
return Type.Object({}, { additionalProperties: false });
}
function createMemoryExpectSchema(kind: "authoring" | "normalized") {
const valueSchema =
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema();
const byteValueSchema =
kind === "authoring" ? createAuthoringFieldSchema(sizeSchema) : createNormalizedValueExpectationSchema();
return Type.Object(
{
activeBytes: Type.Optional(byteValueSchema),
activePercent: Type.Optional(valueSchema),
availableBytes: Type.Optional(byteValueSchema),
availablePercent: Type.Optional(valueSchema),
buffcacheBytes: Type.Optional(byteValueSchema),
durationMs: Type.Optional(valueSchema),
freeBytes: Type.Optional(byteValueSchema),
freePercent: Type.Optional(valueSchema),
swapFreeBytes: Type.Optional(byteValueSchema),
swapTotalBytes: Type.Optional(byteValueSchema),
swapUsagePercent: Type.Optional(valueSchema),
swapUsedBytes: Type.Optional(byteValueSchema),
totalBytes: Type.Optional(byteValueSchema),
usagePercent: Type.Optional(valueSchema),
usedBytes: Type.Optional(byteValueSchema),
usedPercent: Type.Optional(valueSchema),
},
{ additionalProperties: false },
);
}

View File

@@ -0,0 +1,75 @@
import type { Systeminformation } from "systeminformation";
import type { RawValueExpectation, ValueExpectation } from "../../expect/types";
import type { ResolvedTargetBase } from "../../types";
export type MemoryDataReader = () => Promise<Systeminformation.MemData>;
export interface MemoryStats {
activeBytes: number;
activePercent: number;
availableBytes: number;
availablePercent: number;
buffcacheBytes: null | number;
freeBytes: number;
freePercent: number;
swapFreeBytes: null | number;
swapTotalBytes: null | number;
swapUsagePercent: null | number;
swapUsedBytes: null | number;
totalBytes: number;
usagePercent: number;
usedBytes: number;
usedPercent: number;
}
export interface RawMemoryExpectConfig {
activeBytes?: RawValueExpectation;
activePercent?: RawValueExpectation;
availableBytes?: RawValueExpectation;
availablePercent?: RawValueExpectation;
buffcacheBytes?: RawValueExpectation;
durationMs?: RawValueExpectation;
freeBytes?: RawValueExpectation;
freePercent?: RawValueExpectation;
swapFreeBytes?: RawValueExpectation;
swapTotalBytes?: RawValueExpectation;
swapUsagePercent?: RawValueExpectation;
swapUsedBytes?: RawValueExpectation;
totalBytes?: RawValueExpectation;
usagePercent?: RawValueExpectation;
usedBytes?: RawValueExpectation;
usedPercent?: RawValueExpectation;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface ResolvedMemoryConfig {}
export interface ResolvedMemoryExpectConfig {
activeBytes?: ValueExpectation;
activePercent?: ValueExpectation;
availableBytes?: ValueExpectation;
availablePercent?: ValueExpectation;
buffcacheBytes?: ValueExpectation;
durationMs?: ValueExpectation;
freeBytes?: ValueExpectation;
freePercent?: ValueExpectation;
swapFreeBytes?: ValueExpectation;
swapTotalBytes?: ValueExpectation;
swapUsagePercent?: ValueExpectation;
swapUsedBytes?: ValueExpectation;
totalBytes?: ValueExpectation;
usagePercent?: ValueExpectation;
usedBytes?: ValueExpectation;
usedPercent?: ValueExpectation;
}
export interface ResolvedMemoryTarget extends ResolvedTargetBase {
expect?: ResolvedMemoryExpectConfig;
group: string;
intervalMs: number;
memory: ResolvedMemoryConfig;
name: null | string;
timeoutMs: number;
type: "memory";
}

View File

@@ -0,0 +1,111 @@
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 { parseSize } from "../../utils";
const MEMORY_CONFIG_KEYS = new Set<string>([]);
const MEMORY_EXPECT_FIELDS = [
"activeBytes",
"activePercent",
"availableBytes",
"availablePercent",
"buffcacheBytes",
"durationMs",
"freeBytes",
"freePercent",
"swapFreeBytes",
"swapTotalBytes",
"swapUsagePercent",
"swapUsedBytes",
"totalBytes",
"usagePercent",
"usedBytes",
"usedPercent",
] as const;
const BYTE_EXPECT_FIELDS = new Set([
"activeBytes",
"availableBytes",
"buffcacheBytes",
"freeBytes",
"swapFreeBytes",
"swapTotalBytes",
"swapUsedBytes",
"totalBytes",
"usedBytes",
]);
const MEMORY_EXPECT_KEYS = new Set<string>(MEMORY_EXPECT_FIELDS);
export function validateMemoryConfig(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"] !== "memory") continue;
issues.push(...validateMemoryTarget(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 validateMemoryExpect(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");
for (const key of MEMORY_EXPECT_FIELDS) {
if (expect[key] !== undefined) {
issues.push(...validateRawValueExpectation(expect[key], joinPath(expectPath, key), targetName));
if (BYTE_EXPECT_FIELDS.has(key) && isString(expect[key])) {
try {
parseSize(expect[key]);
} catch {
issues.push(issue("invalid-value", joinPath(expectPath, key), "不是有效的字节大小格式", targetName));
}
}
}
}
for (const key of Object.keys(expect)) {
if (!MEMORY_EXPECT_KEYS.has(key)) {
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
}
}
return issues;
}
function validateMemoryTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const rawMemory = target["memory"];
if (!isPlainRecord(rawMemory)) {
issues.push(issue("required", joinPath(path, "memory"), "缺少 memory 配置分组", targetName));
} else {
for (const key of Object.keys(rawMemory)) {
if (!MEMORY_CONFIG_KEYS.has(key)) {
issues.push(issue("unknown-field", joinPath(joinPath(path, "memory"), key), "是未知字段", targetName));
}
}
}
issues.push(...validateMemoryExpect(target, path));
return issues;
}

View File

@@ -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");

View 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);
});
});

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);
});
});

View 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);
});
});

View 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 });
});
});

View 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);
});
});

View 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);
});
});

View File

@@ -0,0 +1,141 @@
import type { Systeminformation } from "systeminformation";
import { describe, expect, test } from "bun:test";
import { calculateMemoryStats } from "../../../../../src/server/checker/runner/memory/calculate";
function makeMemData(overrides: Partial<Systeminformation.MemData> = {}): Systeminformation.MemData {
return {
active: 4294967296,
available: 8589934592,
buffcache: 1073741824,
buffers: 536870912,
cached: 536870912,
dirty: null,
free: 4294967296,
reclaimable: 0,
slab: 0,
swapfree: 0,
swaptotal: 0,
swapused: 0,
total: 17179869184,
used: 8589934592,
writeback: null,
...overrides,
};
}
describe("calculateMemoryStats", () => {
test("usagePercent = activeBytes / totalBytes * 100", () => {
const stats = calculateMemoryStats(makeMemData({ active: 4294967296, total: 8589934592 }));
expect(stats.usagePercent).toBe(50);
});
test("usedPercent = usedBytes / totalBytes * 100", () => {
const stats = calculateMemoryStats(makeMemData({ total: 8589934592, used: 6442450944 }));
expect(stats.usedPercent).toBe(75);
});
test("freePercent = freeBytes / totalBytes * 100", () => {
const stats = calculateMemoryStats(makeMemData({ free: 2147483648, total: 8589934592 }));
expect(stats.freePercent).toBe(25);
});
test("activePercent = activeBytes / totalBytes * 100", () => {
const stats = calculateMemoryStats(makeMemData({ active: 3221225472, total: 8589934592 }));
expect(stats.activePercent).toBe(37.5);
});
test("availablePercent = availableBytes / totalBytes * 100", () => {
const stats = calculateMemoryStats(makeMemData({ available: 6442450944, total: 8589934592 }));
expect(stats.availablePercent).toBe(75);
});
test("保留 1 位小数", () => {
const stats = calculateMemoryStats(makeMemData({ active: 3000000000, total: 8000000000 }));
expect(stats.usagePercent).toBe(37.5);
});
test("round1 处理需要四舍五入的情况", () => {
const stats = calculateMemoryStats(makeMemData({ active: 3333333333, total: 10000000000 }));
expect(stats.usagePercent).toBe(33.3);
});
test("total 为 0 时百分比为 0", () => {
const stats = calculateMemoryStats(makeMemData({ active: 0, available: 0, free: 0, total: 0, used: 0 }));
expect(stats.usagePercent).toBe(0);
expect(stats.usedPercent).toBe(0);
expect(stats.freePercent).toBe(0);
});
test("buffcacheBytes 为 null 映射", () => {
const stats = calculateMemoryStats(makeMemData({ buffcache: 0 }));
expect(stats.buffcacheBytes).toBe(0);
});
test("buffcacheBytes 为正数时保留", () => {
const stats = calculateMemoryStats(makeMemData({ buffcache: 1073741824 }));
expect(stats.buffcacheBytes).toBe(1073741824);
});
test("所有字节字段正确映射", () => {
const data = makeMemData({
active: 1000,
available: 2000,
free: 3000,
total: 4000,
used: 3500,
});
const stats = calculateMemoryStats(data);
expect(stats.activeBytes).toBe(1000);
expect(stats.availableBytes).toBe(2000);
expect(stats.freeBytes).toBe(3000);
expect(stats.totalBytes).toBe(4000);
expect(stats.usedBytes).toBe(3500);
});
});
describe("calculateMemoryStats swap", () => {
test("swap 不可用swaptotal=0 时 swapUsagePercent=null", () => {
const stats = calculateMemoryStats(makeMemData({ swapfree: 0, swaptotal: 0, swapused: 0 }));
expect(stats.swapUsagePercent).toBe(null);
expect(stats.swapTotalBytes).toBe(0);
expect(stats.swapUsedBytes).toBe(0);
expect(stats.swapFreeBytes).toBe(0);
});
test("swap 总量为 0swapUsagePercent 为 null不是 0", () => {
const stats = calculateMemoryStats(makeMemData({ swaptotal: 0 }));
expect(stats.swapUsagePercent).toBe(null);
});
test("swap 已使用", () => {
const stats = calculateMemoryStats(
makeMemData({ swapfree: 1073741824, swaptotal: 4294967296, swapused: 3221225472 }),
);
expect(stats.swapUsagePercent).toBe(75);
expect(stats.swapTotalBytes).toBe(4294967296);
expect(stats.swapUsedBytes).toBe(3221225472);
expect(stats.swapFreeBytes).toBe(1073741824);
});
test("swap 未使用swapUsagePercent=0不是 null", () => {
const stats = calculateMemoryStats(makeMemData({ swapfree: 4294967296, swaptotal: 4294967296, swapused: 0 }));
expect(stats.swapUsagePercent).toBe(0);
expect(stats.swapUsedBytes).toBe(0);
expect(stats.swapFreeBytes).toBe(4294967296);
});
test("swap 部分使用保留 1 位小数", () => {
const stats = calculateMemoryStats(
makeMemData({ swapfree: 3000000000, swaptotal: 10000000000, swapused: 7000000000 }),
);
expect(stats.swapUsagePercent).toBe(70);
});
test("swap 合法 0 不被转换为 null", () => {
const stats = calculateMemoryStats(makeMemData({ swapfree: 4294967296, swaptotal: 4294967296, swapused: 0 }));
expect(stats.swapUsedBytes).toBe(0);
expect(stats.swapUsagePercent).toBe(0);
});
});

View File

@@ -0,0 +1,184 @@
import type { Systeminformation } from "systeminformation";
import { describe, expect, test } from "bun:test";
import type { RawTargetConfig } from "../../../../../src/server/checker/types";
import { MemoryChecker } from "../../../../../src/server/checker/runner/memory/execute";
function makeMemData(overrides: Partial<Systeminformation.MemData> = {}): Systeminformation.MemData {
return {
active: 4294967296,
available: 8589934592,
buffcache: 1073741824,
buffers: 536870912,
cached: 536870912,
dirty: null,
free: 4294967296,
reclaimable: 0,
slab: 0,
swapfree: 0,
swaptotal: 0,
swapused: 0,
total: 17179869184,
used: 8589934592,
writeback: null,
...overrides,
};
}
function makeResolveContext(
overrides: Partial<{ configDir: string; defaultIntervalMs: number; defaultTimeoutMs: number }> = {},
) {
return {
configDir: "/test",
defaultIntervalMs: 30000,
defaultTimeoutMs: 10000,
...overrides,
};
}
describe("MemoryChecker resolve", () => {
const checker = new MemoryChecker();
test("默认值memory 为空对象", () => {
const target: RawTargetConfig = { id: "mem-test", memory: {}, type: "memory" };
const resolved = checker.resolve(target, makeResolveContext());
expect(resolved.memory).toEqual({});
});
test("无 expect 时 expect 为 undefined", () => {
const target: RawTargetConfig = { id: "mem-test", memory: {}, type: "memory" };
const resolved = checker.resolve(target, makeResolveContext());
expect(resolved.expect).toBeUndefined();
});
test("保留 expect 字段", () => {
const target: RawTargetConfig = {
expect: { usagePercent: { lte: 85 } },
id: "mem-test",
memory: {},
type: "memory",
};
const resolved = checker.resolve(target, makeResolveContext());
expect(resolved.expect).toEqual({ usagePercent: { lte: 85 } });
});
test("type 为 memory", () => {
const target: RawTargetConfig = { id: "mem-test", memory: {}, type: "memory" };
const resolved = checker.resolve(target, makeResolveContext());
expect(resolved.type).toBe("memory");
});
});
describe("MemoryChecker execute", () => {
test("成功匹配", async () => {
const data = makeMemData({ active: 4294967296, total: 8589934592 });
const reader = () => Promise.resolve(data);
const checker = new MemoryChecker(reader);
const target: RawTargetConfig = { id: "mem-test", memory: {}, type: "memory" };
const resolved = checker.resolve(target, makeResolveContext());
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({
totalBytes: 8589934592,
usagePercent: 50,
});
});
test("usagePercent mismatch", async () => {
const data = makeMemData({ active: 7730941132, total: 8589934592 });
const reader = () => Promise.resolve(data);
const checker = new MemoryChecker(reader);
const target: RawTargetConfig = { id: "mem-test", memory: {}, type: "memory" };
const resolved = checker.resolve(target, makeResolveContext());
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("observation 包含所有字段", async () => {
const data = makeMemData();
const reader = () => Promise.resolve(data);
const checker = new MemoryChecker(reader);
const target: RawTargetConfig = { id: "mem-test", memory: {}, type: "memory" };
const resolved = checker.resolve(target, makeResolveContext());
const ctx = { signal: new AbortController().signal };
const result = await checker.execute(resolved, ctx);
const obs = result.observation!;
expect(obs).toHaveProperty("activeBytes");
expect(obs).toHaveProperty("activePercent");
expect(obs).toHaveProperty("availableBytes");
expect(obs).toHaveProperty("availablePercent");
expect(obs).toHaveProperty("buffcacheBytes");
expect(obs).toHaveProperty("freeBytes");
expect(obs).toHaveProperty("freePercent");
expect(obs).toHaveProperty("swapFreeBytes");
expect(obs).toHaveProperty("swapTotalBytes");
expect(obs).toHaveProperty("swapUsagePercent");
expect(obs).toHaveProperty("swapUsedBytes");
expect(obs).toHaveProperty("totalBytes");
expect(obs).toHaveProperty("usagePercent");
expect(obs).toHaveProperty("usedBytes");
expect(obs).toHaveProperty("usedPercent");
});
test("reader reject 返回失败结果", async () => {
const reader = () => Promise.reject(new Error("read error"));
const checker = new MemoryChecker(reader);
const target: RawTargetConfig = { id: "mem-test", memory: {}, type: "memory" };
const resolved = checker.resolve(target, makeResolveContext());
const ctx = { signal: new AbortController().signal };
const result = await checker.execute(resolved, ctx);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("memory");
expect(result.failure?.path).toBe("snapshot");
expect(result.observation).toBeNull();
});
test("detail 格式", async () => {
const data = makeMemData({ active: 4294967296, total: 8589934592 });
const reader = () => Promise.resolve(data);
const checker = new MemoryChecker(reader);
const target: RawTargetConfig = { id: "mem-test", memory: {}, type: "memory" };
const resolved = checker.resolve(target, makeResolveContext());
const ctx = { signal: new AbortController().signal };
const result = await checker.execute(resolved, ctx);
const detail = checker.buildDetail(result.observation!);
expect(detail).toContain("usage");
expect(detail).toContain("%");
expect(detail).toContain("total");
});
});
describe("MemoryChecker serialize", () => {
test("序列化输出", () => {
const checker = new MemoryChecker();
const target: RawTargetConfig = { id: "mem-test", memory: {}, type: "memory" };
const resolved = checker.resolve(target, makeResolveContext());
const result = checker.serialize(resolved);
expect(result.target).toBe("memory");
const config = JSON.parse(result.config) as Record<string, unknown>;
expect(config).toEqual({});
});
});

View File

@@ -0,0 +1,149 @@
import { describe, expect, test } from "bun:test";
import {
checkActiveBytes,
checkActivePercent,
checkAvailableBytes,
checkAvailablePercent,
checkBuffcacheBytes,
checkFreeBytes,
checkFreePercent,
checkSwapFreeBytes,
checkSwapTotalBytes,
checkSwapUsagePercent,
checkSwapUsedBytes,
checkTotalBytes,
checkUsagePercent,
checkUsedBytes,
checkUsedPercent,
} from "../../../../../src/server/checker/runner/memory/expect";
describe("Memory 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("checkUsedPercent 匹配", () => {
expect(checkUsedPercent(75, { lte: 80 }).matched).toBe(true);
});
test("checkUsedPercent 不匹配", () => {
const result = checkUsedPercent(85, { lte: 80 });
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("used");
});
test("checkFreePercent 匹配", () => {
expect(checkFreePercent(25, { gte: 15 }).matched).toBe(true);
});
test("checkFreePercent 不匹配", () => {
const result = checkFreePercent(10, { gte: 15 });
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("free");
});
test("checkActivePercent 匹配", () => {
expect(checkActivePercent(50, { lte: 85 }).matched).toBe(true);
});
test("checkAvailablePercent 匹配", () => {
expect(checkAvailablePercent(50, { gte: 20 }).matched).toBe(true);
});
test("undefined matcher 直接通过", () => {
expect(checkUsagePercent(99.9, undefined).matched).toBe(true);
expect(checkFreePercent(0, undefined).matched).toBe(true);
});
});
describe("Memory expect checks - 字节字段", () => {
test("checkActiveBytes 匹配", () => {
expect(checkActiveBytes(4294967296, { lte: 8589934592 }).matched).toBe(true);
});
test("checkUsedBytes 不匹配", () => {
const result = checkUsedBytes(10737418240, { lte: 8589934592 });
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("usedBytes");
});
test("checkFreeBytes 匹配", () => {
expect(checkFreeBytes(4294967296, { gte: 2147483648 }).matched).toBe(true);
});
test("checkAvailableBytes 匹配", () => {
expect(checkAvailableBytes(6442450944, { gte: 4294967296 }).matched).toBe(true);
});
test("checkTotalBytes 匹配", () => {
expect(checkTotalBytes(17179869184, { gte: 8589934592 }).matched).toBe(true);
});
});
describe("Memory expect checks - swap 字段", () => {
test("checkSwapUsagePercent null 通过 gte 检查 (Number(null)=0)", () => {
expect(checkSwapUsagePercent(null, { gte: 0 }).matched).toBe(true);
});
test("checkSwapUsagePercent null 不匹配大于 0 的 gte", () => {
const result = checkSwapUsagePercent(null, { gte: 1 });
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("swapUsage");
});
test("checkSwapUsagePercent 有值时正常匹配", () => {
expect(checkSwapUsagePercent(50, { lte: 80 }).matched).toBe(true);
});
test("checkSwapUsagePercent 有值时不匹配", () => {
const result = checkSwapUsagePercent(90, { lte: 80 });
expect(result.matched).toBe(false);
});
test("checkSwapUsedBytes null 通过 gte:0 (Number(null)=0)", () => {
expect(checkSwapUsedBytes(null, { gte: 0 }).matched).toBe(true);
});
test("checkSwapUsedBytes 0 通过 gte:0", () => {
expect(checkSwapUsedBytes(0, { gte: 0 }).matched).toBe(true);
});
test("checkSwapFreeBytes null 通过 gte:0", () => {
expect(checkSwapFreeBytes(null, { gte: 0 }).matched).toBe(true);
});
test("checkSwapTotalBytes null 匹配 equals:null", () => {
expect(checkSwapTotalBytes(null, { equals: null }).matched).toBe(true);
});
test("checkSwapTotalBytes 0 匹配 equals:0", () => {
expect(checkSwapTotalBytes(0, { equals: 0 }).matched).toBe(true);
});
test("checkSwapTotalBytes null 不匹配 equals:0", () => {
expect(checkSwapTotalBytes(null, { equals: 0 }).matched).toBe(false);
});
});
describe("Memory expect checks - buffcacheBytes", () => {
test("checkBuffcacheBytes 有值时匹配", () => {
expect(checkBuffcacheBytes(1073741824, { lte: 2147483648 }).matched).toBe(true);
});
test("checkBuffcacheBytes null 通过 gte:0 (Number(null)=0)", () => {
expect(checkBuffcacheBytes(null, { gte: 0 }).matched).toBe(true);
});
test("checkBuffcacheBytes null 不匹配 gte:1", () => {
const result = checkBuffcacheBytes(null, { gte: 1 });
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("buffcacheBytes");
});
});

View File

@@ -0,0 +1,72 @@
import { describe, expect, test } from "bun:test";
import { normalizeTargetExpect } from "../../../../../src/server/checker/runner/memory/normalize";
describe("normalizeTargetExpect (memory)", () => {
test("无 expect 直接返回", () => {
const target = { id: "test", memory: {}, type: "memory" };
expect(normalizeTargetExpect(target)).toEqual(target);
});
test("expect 为非对象直接返回", () => {
const target = { expect: "not-an-object", id: "test", memory: {}, type: "memory" };
expect(normalizeTargetExpect(target)).toEqual(target);
});
test("字节大小字符串 512MB 转换为数字", () => {
const target = { expect: { usedBytes: "512MB" }, id: "test", memory: {}, type: "memory" };
const result = normalizeTargetExpect(target);
expect((result.expect as Record<string, unknown>)["usedBytes"]).toEqual({ equals: 536870912 });
});
test("字节大小字符串 1GB 转换为数字", () => {
const target = { expect: { totalBytes: "1GB" }, id: "test", memory: {}, type: "memory" };
const result = normalizeTargetExpect(target);
expect((result.expect as Record<string, unknown>)["totalBytes"]).toEqual({ equals: 1073741824 });
});
test("数字字节 matcher 保持不变", () => {
const target = { expect: { usedBytes: 1073741824 }, id: "test", memory: {}, type: "memory" };
const result = normalizeTargetExpect(target);
expect((result.expect as Record<string, unknown>)["usedBytes"]).toEqual({ equals: 1073741824 });
});
test("百分比 matcher 正常展开", () => {
const target = { expect: { usagePercent: 85 }, id: "test", memory: {}, type: "memory" };
const result = normalizeTargetExpect(target);
expect((result.expect as Record<string, unknown>)["usagePercent"]).toEqual({ equals: 85 });
});
test("matcher 对象保持不变", () => {
const target = { expect: { usagePercent: { lte: 85 } }, id: "test", memory: {}, type: "memory" };
const result = normalizeTargetExpect(target);
expect((result.expect as Record<string, unknown>)["usagePercent"]).toEqual({ lte: 85 });
});
test("字节 matcher 对象内字符串转换", () => {
const target = { expect: { usedBytes: { gte: "512MB" } }, id: "test", memory: {}, type: "memory" };
const result = normalizeTargetExpect(target);
expect((result.expect as Record<string, unknown>)["usedBytes"]).toEqual({ gte: 536870912 });
});
test("多个字段同时处理", () => {
const target = {
expect: { freePercent: 25, totalBytes: "16GB", usagePercent: { lte: 85 } },
id: "test",
memory: {},
type: "memory",
};
const result = normalizeTargetExpect(target);
const expectObj = result.expect as Record<string, unknown>;
expect(expectObj["freePercent"]).toEqual({ equals: 25 });
expect(expectObj["totalBytes"]).toEqual({ equals: 17179869184 });
expect(expectObj["usagePercent"]).toEqual({ lte: 85 });
});
});
describe("normalizeTargetExpect (memory) 错误", () => {
test("非法大小字符串抛出", () => {
const target = { expect: { usedBytes: "abc" }, id: "test", memory: {}, type: "memory" };
expect(() => normalizeTargetExpect(target)).toThrow();
});
});

View File

@@ -0,0 +1,95 @@
import Ajv from "ajv";
import { describe, expect, test } from "bun:test";
import { memoryCheckerSchemas } from "../../../../../src/server/checker/runner/memory/schema";
const ajv = new Ajv({ strict: false });
describe("Memory checker schema", () => {
test("authoring config 空配置通过", () => {
const validate = ajv.compile(memoryCheckerSchemas.authoring.config);
expect(validate({})).toBe(true);
});
test("normalized config 空配置通过", () => {
const validate = ajv.compile(memoryCheckerSchemas.normalized.config);
expect(validate({})).toBe(true);
});
test("config 拒绝额外字段", () => {
const validate = ajv.compile(memoryCheckerSchemas.authoring.config);
expect(validate({ extraField: true })).toBe(false);
});
test("authoring expect 允许百分比 ValueMatcher 简写", () => {
const validate = ajv.compile(memoryCheckerSchemas.authoring.expect);
expect(validate({ usagePercent: 85 })).toBe(true);
expect(validate({ usagePercent: { lte: 85 } })).toBe(true);
});
test("authoring expect 允许字节字段字符串", () => {
const validate = ajv.compile(memoryCheckerSchemas.authoring.expect);
expect(validate({ usedBytes: "512MB" })).toBe(true);
expect(validate({ totalBytes: "1GB" })).toBe(true);
});
test("authoring expect 允许字节字段数字", () => {
const validate = ajv.compile(memoryCheckerSchemas.authoring.expect);
expect(validate({ usedBytes: 536870912 })).toBe(true);
});
test("normalized expect 允许 matcher 对象", () => {
const validate = ajv.compile(memoryCheckerSchemas.normalized.expect);
expect(validate({ freePercent: { gte: 15 }, usagePercent: { lte: 85 } })).toBe(true);
});
test("expect 拒绝未知字段", () => {
const validate = ajv.compile(memoryCheckerSchemas.authoring.expect);
expect(validate({ unknownField: 1 })).toBe(false);
});
test("expect 空对象通过", () => {
const validate = ajv.compile(memoryCheckerSchemas.normalized.expect);
expect(validate({})).toBe(true);
});
test("expect 允许所有合法百分比字段", () => {
const validate = ajv.compile(memoryCheckerSchemas.normalized.expect);
expect(
validate({
activePercent: { lte: 80 },
availablePercent: { gte: 20 },
freePercent: { gte: 15 },
swapUsagePercent: { lte: 50 },
usagePercent: { lte: 85 },
usedPercent: { lte: 90 },
}),
).toBe(true);
});
test("expect 允许所有合法字节字段", () => {
const validate = ajv.compile(memoryCheckerSchemas.normalized.expect);
expect(
validate({
activeBytes: { lte: 8589934592 },
availableBytes: { gte: 4294967296 },
freeBytes: { gte: 2147483648 },
swapFreeBytes: { gte: 0 },
swapTotalBytes: { lte: 4294967296 },
swapUsedBytes: { lte: 2147483648 },
totalBytes: { equals: 17179869184 },
usedBytes: { lte: 8589934592 },
}),
).toBe(true);
});
test("expect 允许 durationMs 字段", () => {
const validate = ajv.compile(memoryCheckerSchemas.normalized.expect);
expect(validate({ durationMs: { lte: 5000 } })).toBe(true);
});
test("expect 允许 buffcacheBytes 字段", () => {
const validate = ajv.compile(memoryCheckerSchemas.normalized.expect);
expect(validate({ buffcacheBytes: { lte: 2147483648 } })).toBe(true);
});
});

View File

@@ -0,0 +1,87 @@
import { describe, expect, test } from "bun:test";
import type { RawTargetConfig } from "../../../../../src/server/checker/types";
import { validateMemoryConfig } from "../../../../../src/server/checker/runner/memory/validate";
function validate(target: RawTargetConfig) {
return validateMemoryConfig({ targets: [target] });
}
describe("validateMemoryConfig", () => {
test("有效配置无错误", () => {
expect(validate({ id: "mem-test", memory: {}, type: "memory" })).toEqual([]);
});
test("缺少 memory 配置分组", () => {
const issues = validate({ id: "mem-test", type: "memory" });
expect(issues.some((i) => i.path.endsWith("memory") && i.code === "required")).toBe(true);
});
test("memory 未知字段报错", () => {
const issues = validate({ id: "mem-test", memory: { extra: true }, type: "memory" });
expect(issues.some((i) => i.path.endsWith("extra") && i.code === "unknown-field")).toBe(true);
});
test("expect 未知字段报错", () => {
const issues = validate({ expect: { logicalCoreCount: { gte: 4 } }, id: "mem-test", memory: {}, type: "memory" });
expect(issues.some((i) => i.path.endsWith("logicalCoreCount") && i.code === "unknown-field")).toBe(true);
});
test("expect 合法 ValueMatcher 通过", () => {
const issues = validate({
expect: { usagePercent: { lte: 85 }, usedBytes: { lte: 8589934592 } },
id: "mem-test",
memory: {},
type: "memory",
});
expect(issues.filter((i) => i.path.includes("expect"))).toEqual([]);
});
test("expect 非法 ValueMatcher 报错", () => {
const issues = validate({ expect: { usagePercent: [1, 2] }, id: "mem-test", memory: {}, type: "memory" });
expect(issues.some((i) => i.path.includes("usagePercent"))).toBe(true);
});
test("expect 合法字节大小字符串通过", () => {
const issues = validate({ expect: { usedBytes: "512MB" }, id: "mem-test", memory: {}, type: "memory" });
expect(issues.filter((i) => i.path.includes("usedBytes"))).toEqual([]);
});
test("expect 非法字节大小字符串报错", () => {
const issues = validate({ expect: { usedBytes: "abc" }, id: "mem-test", memory: {}, type: "memory" });
expect(issues.some((i) => i.path.includes("usedBytes") && i.message.includes("字节大小"))).toBe(true);
});
test("expect 所有合法字段通过", () => {
const issues = validate({
expect: {
activeBytes: { lte: 8589934592 },
activePercent: { lte: 80 },
availableBytes: { gte: 4294967296 },
availablePercent: { gte: 20 },
buffcacheBytes: { lte: 2147483648 },
durationMs: { lte: 5000 },
freeBytes: { gte: 2147483648 },
freePercent: { gte: 15 },
swapFreeBytes: { gte: 0 },
swapTotalBytes: { lte: 4294967296 },
swapUsagePercent: { lte: 50 },
swapUsedBytes: { lte: 2147483648 },
totalBytes: { equals: 17179869184 },
usagePercent: { lte: 85 },
usedBytes: { lte: 8589934592 },
usedPercent: { lte: 90 },
},
id: "mem-test",
memory: {},
type: "memory",
});
expect(issues).toEqual([]);
});
test("非 memory type 的 target 不校验", () => {
const issues = validate({ id: "other-test", type: "http" });
expect(issues).toEqual([]);
});
});

View File

@@ -73,8 +73,33 @@ 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",
"memory",
"custom",
]);
expect(second.supportedTypes).toEqual([
"http",
"cmd",
"db",
"tcp",
"icmp",
"udp",
"llm",
"dns",
"ws",
"cpu",
"memory",
]);
expect(
first.definitions.every((checker) => checker.schemas.authoring.config && checker.schemas.normalized.expect),
).toBe(true);