Compare commits
11 Commits
a6504d5a62
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f8fd8bd9c | |||
| 3390eb5e8d | |||
| 145bb8fd04 | |||
| 358f8d011a | |||
| c2dcfab80c | |||
| f38286d74d | |||
| 08b61cbf47 | |||
| c120690cf1 | |||
| 77c6015b3a | |||
| c1db793073 | |||
| 714b635aef |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -403,6 +403,9 @@ cython_debug/
|
|||||||
!.claude/settings.json
|
!.claude/settings.json
|
||||||
.opencode
|
.opencode
|
||||||
.codex
|
.codex
|
||||||
|
.pi/*
|
||||||
|
!.pi/mcp.json
|
||||||
|
!.pi/extensions
|
||||||
openspec/changes/archive
|
openspec/changes/archive
|
||||||
temp
|
temp
|
||||||
.agents
|
.agents
|
||||||
|
|||||||
19
.pi/extensions/pi-permission-system/config.json
Normal file
19
.pi/extensions/pi-permission-system/config.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"$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",
|
||||||
|
"pnpx *": "deny"
|
||||||
|
},
|
||||||
|
"external_directory": {
|
||||||
|
"*": "ask",
|
||||||
|
"/tmp/*": "allow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
.pi/mcp.json
Normal file
8
.pi/mcp.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"tdesign-mcp-server": {
|
||||||
|
"command": "bunx",
|
||||||
|
"args": ["tdesign-mcp-server@latest"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
131
CONTRIBUTING.md
131
CONTRIBUTING.md
@@ -1,131 +0,0 @@
|
|||||||
# Contributing
|
|
||||||
|
|
||||||
本文档是 DiAL 的贡献入口,重点指导新增或修改 checker。通用开发命令和全局规则见 [DEVELOPMENT.md](DEVELOPMENT.md),完整开发专题见 [docs/development/](docs/development/README.md)。
|
|
||||||
|
|
||||||
## 通用贡献规则
|
|
||||||
|
|
||||||
- 使用中文编写注释、文档和项目内交流内容。
|
|
||||||
- 仅使用 `bun` 作为包管理器,禁止使用 npm、pnpm、yarn。
|
|
||||||
- 运行工具使用 `bunx`,禁止使用 npx、pnpx。
|
|
||||||
- 新增代码优先复用已有组件、工具和依赖库,不引入新依赖。
|
|
||||||
- 新增逻辑必须编写测试,不允许跳过测试。
|
|
||||||
- 每次代码变更必须执行文档影响分析,并按影响范围更新对应文档或说明无需更新。
|
|
||||||
|
|
||||||
## 新增或修改 Checker 前必读
|
|
||||||
|
|
||||||
| 文档 | 用途 |
|
|
||||||
| ------------------------------------------------------- | ---------------------------------------------------- |
|
|
||||||
| [README.md](README.md) | 项目定位、快速开始和用户入口 |
|
|
||||||
| [DEVELOPMENT.md](DEVELOPMENT.md) | 开发入口、常用命令、质量门禁和全局规则 |
|
|
||||||
| [docs/README.md](docs/README.md) | 文档索引和文档归属矩阵 |
|
|
||||||
| [Checker 开发](docs/development/checker-development.md) | checker 实现机制和详细 checklist |
|
|
||||||
| [配置文件](docs/user/configuration.md) | YAML 顶层结构、变量和 target 通用字段 |
|
|
||||||
| [校验规则](docs/user/expectations.md) | ValueMatcher、ContentExpectations、KeyedExpectations |
|
|
||||||
| [Checker 用户文档](docs/user/checkers/README.md) | 已支持 checker 的配置和示例 |
|
|
||||||
|
|
||||||
还应阅读现有同类 checker 的实现和测试,例如 `src/server/checker/runner/http/`、`src/server/checker/runner/cmd/` 和对应 `tests/server/checker/runner/` 目录。
|
|
||||||
|
|
||||||
## Checker 设计原则
|
|
||||||
|
|
||||||
- 每个 checker 必须自包含在 `src/server/checker/runner/<type>/`。
|
|
||||||
- checker 专属类型、schema、validate、execute、expect 和协议辅助逻辑放在同一目录。
|
|
||||||
- 注册只修改 `src/server/checker/runner/index.ts`,中间层不新增 type switch。
|
|
||||||
- schema 层只描述契约,语义规则放入 `validate.ts`。
|
|
||||||
- `resolve()` 只做默认值填充、路径解析和单位转换,不执行校验。
|
|
||||||
- `execute()` 必须支持 `CheckerContext.signal` 超时取消。
|
|
||||||
- expect 字段必须选择合适断言模型,不为了统一而滥用 ValueMatcher。
|
|
||||||
- failure phase 命名遵循去单位后缀规则,例如 `durationMs` 对应 `duration`。
|
|
||||||
|
|
||||||
## 文件清单
|
|
||||||
|
|
||||||
新增 checker 通常需要创建或修改:
|
|
||||||
|
|
||||||
```text
|
|
||||||
src/server/checker/runner/<type>/types.ts
|
|
||||||
src/server/checker/runner/<type>/schema.ts
|
|
||||||
src/server/checker/runner/<type>/validate.ts
|
|
||||||
src/server/checker/runner/<type>/execute.ts
|
|
||||||
src/server/checker/runner/<type>/expect.ts
|
|
||||||
src/server/checker/runner/<type>/index.ts
|
|
||||||
src/server/checker/runner/index.ts
|
|
||||||
tests/server/checker/runner/<type>/
|
|
||||||
probes.example.yaml
|
|
||||||
probe-config.schema.json
|
|
||||||
docs/user/checkers/<type>.md
|
|
||||||
docs/user/checkers/README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
如果修改通用断言模型、开发机制或文档同步规则,还需要更新 `docs/user/expectations.md`、`docs/development/checker-development.md`、本文件或 `docs/README.md`。
|
|
||||||
|
|
||||||
## 分层要求
|
|
||||||
|
|
||||||
| 层 | 职责 |
|
|
||||||
| ----------------- | -------------------------------------------- |
|
|
||||||
| `types.ts` | Raw/Resolved target 和 expect 类型 |
|
|
||||||
| `schema.ts` | TypeBox Authoring/Normalized schema |
|
|
||||||
| `validate.ts` | JSON Schema 无法表达的语义校验 |
|
|
||||||
| `execute.ts` | Checker 类,包含 resolve、execute、serialize |
|
|
||||||
| `expect.ts` | checker 专用断言 |
|
|
||||||
| `runner/index.ts` | 注册 checker |
|
|
||||||
|
|
||||||
## expect 模型选择
|
|
||||||
|
|
||||||
| 场景 | 模型 |
|
|
||||||
| ------------------------------------ | ------------------- |
|
|
||||||
| 状态类结果且集合小而稳定 | enum 或 boolean |
|
|
||||||
| 单值数字指标或字符串元数据 | ValueMatcher |
|
|
||||||
| 文本、JSON、HTML、XML 或半结构化内容 | ContentExpectations |
|
|
||||||
| 动态键值表 | KeyedExpectations |
|
|
||||||
|
|
||||||
详细说明见 [校验规则](docs/user/expectations.md) 和 [Checker 开发](docs/development/checker-development.md)。
|
|
||||||
|
|
||||||
## 测试要求
|
|
||||||
|
|
||||||
| 测试类别 | 覆盖内容 |
|
|
||||||
| ------------ | ---------------------------------------- |
|
|
||||||
| 契约测试 | TypeBox schema 与 JSON Schema 导出一致性 |
|
|
||||||
| 语义校验测试 | 合法和非法配置 |
|
|
||||||
| resolve 测试 | 默认值合并、路径解析、单位转换 |
|
|
||||||
| execute 测试 | 成功、失败、超时、expect 组合 |
|
|
||||||
| 注册测试 | registry 注册行为 |
|
|
||||||
| 配置加载测试 | 含新 checker 的 YAML 完整加载流程 |
|
|
||||||
|
|
||||||
## 文档同步矩阵
|
|
||||||
|
|
||||||
| 变更 | 必须更新 |
|
|
||||||
| ------------------------ | ------------------------------------------------------------------------------------- |
|
|
||||||
| 新增 checker | `docs/user/checkers/<type>.md`、`docs/user/checkers/README.md`、`probes.example.yaml` |
|
|
||||||
| 修改 checker 配置字段 | 对应 checker 用户文档、schema、测试、示例 |
|
|
||||||
| 修改 checker expect 字段 | 对应 checker 用户文档,必要时更新 `docs/user/expectations.md` |
|
|
||||||
| 修改通用 expect 模型 | `docs/user/expectations.md`、`docs/development/checker-development.md` |
|
|
||||||
| 修改 checker 开发机制 | `docs/development/checker-development.md`、本文件 |
|
|
||||||
| 修改文档同步规则 | `docs/README.md`、`openspec/config.yaml` |
|
|
||||||
|
|
||||||
## 验证命令
|
|
||||||
|
|
||||||
新增或修改 checker 后通常需要运行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run schema
|
|
||||||
bun run schema:check
|
|
||||||
bun run check
|
|
||||||
bun run verify
|
|
||||||
```
|
|
||||||
|
|
||||||
如果因环境限制无法执行完整验证,必须在收尾说明中记录未执行项和原因。
|
|
||||||
|
|
||||||
## 完成 checklist
|
|
||||||
|
|
||||||
```text
|
|
||||||
□ checker 类型、schema、validate、resolve、execute、serialize 已实现
|
|
||||||
□ checker 已在 runner/index.ts 注册
|
|
||||||
□ 配置契约、语义校验和 JSON Schema 导出已同步
|
|
||||||
□ probes.example.yaml 已添加或更新示例
|
|
||||||
□ tests/server/checker/runner/<type>/ 已覆盖契约、校验、resolve、execute、注册和配置加载
|
|
||||||
□ docs/user/checkers/<type>.md 已添加或更新
|
|
||||||
□ docs/user/checkers/README.md 已添加或更新
|
|
||||||
□ 文档影响分析已完成,必要文档已同步
|
|
||||||
□ bun run schema 和 bun run schema:check 已通过
|
|
||||||
□ bun run check 已通过
|
|
||||||
□ bun run verify 已通过或记录未执行原因
|
|
||||||
```
|
|
||||||
115
DEVELOPMENT.md
115
DEVELOPMENT.md
@@ -1,115 +0,0 @@
|
|||||||
# DiAL 开发入口
|
|
||||||
|
|
||||||
本文档是 DiAL 项目的开发入口,保留常用命令、全局规则和专题导航。详细架构、前端、后端、构建、测试和 checker 开发说明位于 [`docs/development/`](docs/development/README.md)。用户使用说明见 [README.md](README.md) 和 [docs/README.md](docs/README.md)。
|
|
||||||
|
|
||||||
## 常用命令
|
|
||||||
|
|
||||||
| 命令 | 说明 |
|
|
||||||
| -------------------------------- | ---------------------------------------- |
|
|
||||||
| `bun install` | 安装依赖 |
|
|
||||||
| `bun run dev probes.yaml` | 启动双进程开发环境 |
|
|
||||||
| `bun run dev:server probes.yaml` | 仅启动后端 API server |
|
|
||||||
| `bun run dev:web` | 仅启动 Vite dev server |
|
|
||||||
| `bun run schema` | 生成 `probe-config.schema.json` |
|
|
||||||
| `bun run schema:check` | 检查导出 schema 是否同步 |
|
|
||||||
| `bun run typecheck` | TypeScript 类型检查 |
|
|
||||||
| `bun run lint` | ESLint 和 Prettier 格式检查 |
|
|
||||||
| `bun test` | 运行全部测试 |
|
|
||||||
| `bun run check` | `schema:check + typecheck + lint + test` |
|
|
||||||
| `bun run build` | 构建生产可执行文件 |
|
|
||||||
| `bun run verify` | `check + build` 完整验证 |
|
|
||||||
| `bun run release` | 跨平台发布打包 |
|
|
||||||
| `bun run clean` | 清理构建缓存与产物 |
|
|
||||||
|
|
||||||
## 质量门禁
|
|
||||||
|
|
||||||
代码变更必须按影响范围执行验证。常规变更优先运行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run check
|
|
||||||
```
|
|
||||||
|
|
||||||
正式提交或影响构建、部署、发布、前后端集成的变更运行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run verify
|
|
||||||
```
|
|
||||||
|
|
||||||
新增或修改配置 schema 时必须运行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run schema
|
|
||||||
bun run schema:check
|
|
||||||
```
|
|
||||||
|
|
||||||
## 全局工程规则
|
|
||||||
|
|
||||||
- 使用中文编写注释、文档和项目内交流内容。
|
|
||||||
- 仅使用 `bun` 作为包管理器,禁止使用 npm、pnpm、yarn。
|
|
||||||
- 运行工具使用 `bunx`,禁止使用 npx、pnpx。
|
|
||||||
- 新增代码优先复用已有组件、工具和依赖库,不引入新依赖;确需新增依赖时先说明原因。
|
|
||||||
- 后端优先使用 Bun 内置 API,其次是 es-toolkit、主流三方库、项目公共工具,最后才自行实现。
|
|
||||||
- 前端样式优先使用 TDesign 组件、组件 props、TDesign CSS tokens、`styles.css` CSS 类,最后才自行开发组件。
|
|
||||||
- 前端禁止组件内联 `style`、覆盖 TDesign 内部类名、使用 `!important`、硬编码色值。
|
|
||||||
- 当前项目未上线,不需要为旧行为做向前兼容,除非用户明确要求。
|
|
||||||
|
|
||||||
## 包管理、依赖与提交
|
|
||||||
|
|
||||||
- 仅使用 `bun` 安装依赖和运行项目脚本,锁文件为 `bun.lock`。
|
|
||||||
- 运行外部工具使用 `bunx`。
|
|
||||||
- 新增依赖前先确认 Bun 内置 API、es-toolkit、现有三方库和项目公共工具是否已满足需求。
|
|
||||||
- Git 提交信息使用中文,格式为 `类型: 简短描述`。
|
|
||||||
- 提交类型限定为 `feat`、`fix`、`refactor`、`docs`、`style`、`test`、`chore`。
|
|
||||||
- 多行提交描述时,标题和正文之间空一行。
|
|
||||||
|
|
||||||
## 目录边界
|
|
||||||
|
|
||||||
| 目录 | 约定 |
|
|
||||||
| ------------------- | ---------------------------------------------------------- |
|
|
||||||
| `src/server/` | Bun 后端代码,不能 import `src/web/`,HTML import 集成除外 |
|
|
||||||
| `src/web/` | React Dashboard,不能 import `src/server/` 运行时实现 |
|
|
||||||
| `src/shared/` | 前后端共享 TypeScript 类型 |
|
|
||||||
| `scripts/` | 独立运行脚本,可 import 项目源码 |
|
|
||||||
| `tests/` | 测试目录,结构镜像 `src/` |
|
|
||||||
| `docs/user/` | 用户使用、配置、部署、checker 和排障文档 |
|
|
||||||
| `docs/development/` | 架构、开发规范、构建发布和测试质量 |
|
|
||||||
| `openspec/` | OpenSpec 变更管理与规格文档 |
|
|
||||||
|
|
||||||
## 文档影响分析
|
|
||||||
|
|
||||||
每次代码变更都必须执行文档影响分析。
|
|
||||||
|
|
||||||
| 如果变更影响 | 更新 |
|
|
||||||
| --------------------------------------------------- | ------------------------------------------ |
|
|
||||||
| 用户可见行为、配置、checker、expect、部署、状态模型 | `docs/user/` 对应文档 |
|
|
||||||
| 开发流程、架构、测试、构建发布、checker 开发机制 | `docs/development/` 或 `CONTRIBUTING.md` |
|
|
||||||
| 项目定位、快速开始、核心能力列表、文档导航 | `README.md` |
|
|
||||||
| 开发入口、质量门禁、全局规则、专题导航 | `DEVELOPMENT.md` |
|
|
||||||
| 文档同步规则 | `docs/README.md` 和 `openspec/config.yaml` |
|
|
||||||
|
|
||||||
如果无需更新文档,必须在收尾说明中说明原因。详细规则见 [文档总览](docs/README.md)。
|
|
||||||
|
|
||||||
## OpenSpec 协作规则
|
|
||||||
|
|
||||||
- 本项目 OpenSpec 使用 `fast-drive` schema,变更文档只包含 `design.md` 和 `tasks.md`,不创建 `proposal.md` 或 `specs/*.md`。
|
|
||||||
- `design.md` 是 scope、requirements、decisions、guardrails、execution direction 和 verification expectations 的 source of truth。
|
|
||||||
- `tasks.md` 必须从 `design.md` 派生,一行一个 checkbox 任务。
|
|
||||||
- 实现阶段按 `tasks.md` 顺序执行,完成后立即标记任务状态。
|
|
||||||
|
|
||||||
## 专题文档
|
|
||||||
|
|
||||||
| 主题 | 文档 |
|
|
||||||
| ---------------- | ---------------------------------------------------------------------------------- |
|
|
||||||
| 开发文档索引 | [docs/development/README.md](docs/development/README.md) |
|
|
||||||
| 架构与边界 | [docs/development/architecture.md](docs/development/architecture.md) |
|
|
||||||
| 后端开发 | [docs/development/backend.md](docs/development/backend.md) |
|
|
||||||
| 前端开发 | [docs/development/frontend.md](docs/development/frontend.md) |
|
|
||||||
| Checker 开发机制 | [docs/development/checker-development.md](docs/development/checker-development.md) |
|
|
||||||
| 构建与发布 | [docs/development/build-release.md](docs/development/build-release.md) |
|
|
||||||
| 测试与质量 | [docs/development/testing-quality.md](docs/development/testing-quality.md) |
|
|
||||||
| 文档总览 | [docs/README.md](docs/README.md) |
|
|
||||||
| 贡献指南 | [CONTRIBUTING.md](CONTRIBUTING.md) |
|
|
||||||
|
|
||||||
## 已知限制
|
|
||||||
|
|
||||||
当前不做告警通知、拨测目标动态增删、认证鉴权和分布式部署。
|
|
||||||
11
README.md
11
README.md
@@ -61,7 +61,7 @@ targets:
|
|||||||
lte: 5000
|
lte: 5000
|
||||||
```
|
```
|
||||||
|
|
||||||
完整配置、checker 和 expect 规则参见 [配置文件](docs/user/configuration.md)、[Checker 参考](docs/user/checkers/README.md) 和 [校验规则](docs/user/expectations.md)。
|
完整配置、checker、expect 和部署说明参见 [用户文档](docs/user/README.md)、[配置文件](docs/user/configuration.md)、[Checker 参考](docs/user/checkers/README.md) 和 [校验规则](docs/user/expectations.md)。
|
||||||
|
|
||||||
## 生产运行
|
## 生产运行
|
||||||
|
|
||||||
@@ -75,14 +75,15 @@ Docker、跨平台发布包和运行时注意事项参见 [部署文档](docs/us
|
|||||||
## 文档导航
|
## 文档导航
|
||||||
|
|
||||||
| 入口 | 内容 |
|
| 入口 | 内容 |
|
||||||
| -------------------------------------------- | ---------------------------------------------------- |
|
| -------------------------------------------- | ------------------------------------------- |
|
||||||
| [文档总览](docs/README.md) | 全部文档入口和文档归属矩阵 |
|
| [文档总览](docs/README.md) | 全部文档入口和文档归属矩阵 |
|
||||||
|
| [用户文档](docs/user/README.md) | 配置、部署、expect、排障和 checker 使用入口 |
|
||||||
| [配置文件](docs/user/configuration.md) | YAML 结构、变量、server、targets 通用字段 |
|
| [配置文件](docs/user/configuration.md) | YAML 结构、变量、server、targets 通用字段 |
|
||||||
| [Checker 参考](docs/user/checkers/README.md) | 所有 checker 的配置、expect 和示例 |
|
| [Checker 参考](docs/user/checkers/README.md) | 所有 checker 的配置、expect 和示例 |
|
||||||
| [校验规则](docs/user/expectations.md) | ValueMatcher、ContentExpectations、KeyedExpectations |
|
| [校验规则](docs/user/expectations.md) | expect 规则、状态判定、failure、observation |
|
||||||
| [部署文档](docs/user/deployment.md) | 构建、Docker、发布包和容器运行边界 |
|
| [部署文档](docs/user/deployment.md) | 构建、Docker、发布包和容器运行边界 |
|
||||||
| [状态模型](docs/user/status-model.md) | UP/DOWN、failure、observation、detail |
|
|
||||||
| [故障排查](docs/user/troubleshooting.md) | 常见运行问题和排查入口 |
|
| [故障排查](docs/user/troubleshooting.md) | 常见运行问题和排查入口 |
|
||||||
|
| [开发文档](docs/development/README.md) | 开发入口、常用命令、质量门禁和专题索引 |
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
@@ -91,7 +92,7 @@ bun run check # schema:check + typecheck + lint + test
|
|||||||
bun run verify # check + build
|
bun run verify # check + build
|
||||||
```
|
```
|
||||||
|
|
||||||
开发入口参见 [DEVELOPMENT.md](DEVELOPMENT.md)。新增或修改 checker 前请先阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
开发入口参见 [开发文档](docs/development/README.md)。新增或修改 checker 前请先阅读 [Checker 开发](docs/development/checker.md)。
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
6
bun.lock
6
bun.lock
@@ -14,6 +14,7 @@
|
|||||||
"ai": "^6",
|
"ai": "^6",
|
||||||
"ajv": "^8.20.0",
|
"ajv": "^8.20.0",
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
|
"croner": "^10.0.1",
|
||||||
"es-toolkit": "^1.46.1",
|
"es-toolkit": "^1.46.1",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
|
"systeminformation": "^5.31.6",
|
||||||
"tdesign-icons-react": "^0.6.4",
|
"tdesign-icons-react": "^0.6.4",
|
||||||
"tdesign-react": "^1.16.9",
|
"tdesign-react": "^1.16.9",
|
||||||
"xpath": "^0.0.34",
|
"xpath": "^0.0.34",
|
||||||
@@ -497,6 +499,8 @@
|
|||||||
|
|
||||||
"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=="],
|
"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", "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=="],
|
"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=="],
|
||||||
|
|
||||||
"css-select": ["css-select@5.2.2", "https://registry.npmmirror.com/css-select/-/css-select-5.2.2.tgz", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
|
"css-select": ["css-select@5.2.2", "https://registry.npmmirror.com/css-select/-/css-select-5.2.2.tgz", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
|
||||||
@@ -1131,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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
|
|||||||
157
docs/README.md
157
docs/README.md
@@ -2,40 +2,23 @@
|
|||||||
|
|
||||||
本文档是 DiAL 的文档路由入口。AI 工具和开发者应先阅读本文件判断本次任务需要读取和更新哪些专题文档,再按任务类型读取最小必要上下文。
|
本文档是 DiAL 的文档路由入口。AI 工具和开发者应先阅读本文件判断本次任务需要读取和更新哪些专题文档,再按任务类型读取最小必要上下文。
|
||||||
|
|
||||||
## 读者入口
|
## 目录索引
|
||||||
|
|
||||||
| 读者 | 推荐入口 |
|
|
||||||
| -------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| 首次使用者 | [README 快速开始](../README.md#快速开始) |
|
|
||||||
| 配置编写者 | [配置文件](user/configuration.md)、[Checker 参考](user/checkers/README.md)、[校验规则](user/expectations.md) |
|
|
||||||
| 部署维护者 | [部署文档](user/deployment.md)、[故障排查](user/troubleshooting.md) |
|
|
||||||
| 项目开发者 | [开发文档索引](development/README.md)、[DEVELOPMENT.md](../DEVELOPMENT.md) |
|
|
||||||
| Checker 贡献者 | [CONTRIBUTING.md](../CONTRIBUTING.md)、[Checker 开发](development/checker-development.md) |
|
|
||||||
| AI 工具维护者 | 本文件的任务路由与文档归属矩阵、[OpenSpec 配置](../openspec/config.yaml) |
|
|
||||||
|
|
||||||
## 按任务阅读路径
|
|
||||||
|
|
||||||
| 任务 | 必读文档 |
|
|
||||||
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| 新增 checker | [CONTRIBUTING.md](../CONTRIBUTING.md)、[Checker 开发](development/checker-development.md)、[Checker 参考](user/checkers/README.md)、相近 checker 用户文档 |
|
|
||||||
| 修改 checker 配置 | 对应 `docs/user/checkers/<type>.md`、[配置文件](user/configuration.md)、[校验规则](user/expectations.md) |
|
|
||||||
| 修改 expect 机制 | [校验规则](user/expectations.md)、[后端开发](development/backend.md)、[Checker 开发](development/checker-development.md) |
|
|
||||||
| 修改前端 | [DEVELOPMENT.md](../DEVELOPMENT.md)、[前端开发](development/frontend.md)、[测试与质量](development/testing-quality.md) |
|
|
||||||
| 修改后端 API、store、engine、logger | [DEVELOPMENT.md](../DEVELOPMENT.md)、[后端开发](development/backend.md)、[测试与质量](development/testing-quality.md) |
|
|
||||||
| 修改构建、Docker、release | [构建与发布](development/build-release.md)、[部署文档](user/deployment.md) |
|
|
||||||
| 修改配置 schema | [配置文件](user/configuration.md)、相关 checker 文档、[后端开发](development/backend.md)、[测试与质量](development/testing-quality.md) |
|
|
||||||
| 修改文档规则 | 本文件、[DEVELOPMENT.md](../DEVELOPMENT.md)、[OpenSpec 配置](../openspec/config.yaml) |
|
|
||||||
|
|
||||||
## 目录结构
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
docs/
|
docs/
|
||||||
README.md
|
README.md
|
||||||
|
development/
|
||||||
|
README.md
|
||||||
|
architecture.md
|
||||||
|
backend.md
|
||||||
|
frontend.md
|
||||||
|
release.md
|
||||||
|
checker.md
|
||||||
user/
|
user/
|
||||||
deployment.md
|
README.md
|
||||||
configuration.md
|
configuration.md
|
||||||
|
deployment.md
|
||||||
expectations.md
|
expectations.md
|
||||||
status-model.md
|
|
||||||
troubleshooting.md
|
troubleshooting.md
|
||||||
checkers/
|
checkers/
|
||||||
README.md
|
README.md
|
||||||
@@ -47,40 +30,75 @@ docs/
|
|||||||
icmp.md
|
icmp.md
|
||||||
dns.md
|
dns.md
|
||||||
llm.md
|
llm.md
|
||||||
development/
|
|
||||||
README.md
|
|
||||||
architecture.md
|
|
||||||
backend.md
|
|
||||||
frontend.md
|
|
||||||
checker-development.md
|
|
||||||
build-release.md
|
|
||||||
testing-quality.md
|
|
||||||
prompts/
|
|
||||||
README.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`docs/prompts/` 是提示词资产目录,不属于常规开发流程和用户使用文档。代码、配置、部署或 checker 变更不需要更新该目录,除非任务明确要求维护提示词资产。
|
||||||
|
|
||||||
|
## 入口文档
|
||||||
|
|
||||||
|
| 入口 | 定位 |
|
||||||
|
| ------------------------------------------- | ------------------------------------------ |
|
||||||
|
| [项目 README](../README.md) | 项目整体介绍、快速开始、核心能力、文档引导 |
|
||||||
|
| [开发文档](development/README.md) | 开发入口、全局规则、常用命令、质量门禁 |
|
||||||
|
| [用户文档](user/README.md) | 用户使用入口、配置、部署、expect、排障 |
|
||||||
|
| [Checker 用户参考](user/checkers/README.md) | 各 checker 的配置项、expect 字段和示例 |
|
||||||
|
|
||||||
|
## 按任务阅读路径
|
||||||
|
|
||||||
|
| 任务 | 必读文档 |
|
||||||
|
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| 修改项目介绍或快速开始 | [项目 README](../README.md)、本文档 |
|
||||||
|
| 修改开发流程、质量门禁或工程规则 | [开发文档](development/README.md)、本文档、[OpenSpec 配置](../openspec/config.yaml) |
|
||||||
|
| 修改架构边界或启动流程 | [开发文档](development/README.md)、[架构与边界](development/architecture.md) |
|
||||||
|
| 修改后端 API、store、engine、logger | [开发文档](development/README.md)、[后端开发](development/backend.md) |
|
||||||
|
| 修改前端 | [开发文档](development/README.md)、[前端开发](development/frontend.md) |
|
||||||
|
| 新增或修改 checker | [Checker 开发](development/checker.md)、[Checker 用户参考](user/checkers/README.md)、相近 checker 文档 |
|
||||||
|
| 修改配置 schema | [配置文件](user/configuration.md)、[后端开发](development/backend.md)、相关 checker 文档 |
|
||||||
|
| 修改 expect 或状态模型 | [校验规则](user/expectations.md)、[后端开发](development/backend.md)、[Checker 开发](development/checker.md) |
|
||||||
|
| 修改构建、Docker、release | [构建与发布](development/release.md)、[部署文档](user/deployment.md) |
|
||||||
|
| 修改故障处理或运行依赖 | [故障排查](user/troubleshooting.md)、相关用户文档 |
|
||||||
|
| 修改文档规则或文档目录结构 | 本文档、[OpenSpec 配置](../openspec/config.yaml) |
|
||||||
|
|
||||||
## 文档归属矩阵
|
## 文档归属矩阵
|
||||||
|
|
||||||
| 变更类型 | 默认更新位置 |
|
| 变更类型 | 默认更新位置 |
|
||||||
| ------------------------------------------------------------- | ------------------------------------------------------------ |
|
| -------------------------------------------------------------- | -------------------------------------------------------------- |
|
||||||
| 项目定位、核心能力、快速开始、文档导航变化 | `README.md` |
|
| 项目定位、核心能力、快速开始、顶层文档导航 | `README.md` |
|
||||||
| 用户安装、首次运行、基础使用流程变化 | `README.md` |
|
| 文档路由、文档更新规则、文档归属矩阵 | `docs/README.md`、`openspec/config.yaml` |
|
||||||
| YAML 顶层结构、server、variables、targets 通用字段变化 | `docs/user/configuration.md` |
|
| 开发入口、常用命令、质量门禁、全局工程规则、OpenSpec 约定 | `docs/development/README.md` |
|
||||||
| checker 配置、expect 字段、示例变化 | `docs/user/checkers/` 对应文档 |
|
| 架构边界、启动流程、运行时流程、前后端边界 | `docs/development/architecture.md` |
|
||||||
| ValueMatcher、ContentExpectations、KeyedExpectations 规则变化 | `docs/user/expectations.md` |
|
| 后端 API、共享类型、store、engine、logger、expect 基础设施 | `docs/development/backend.md` |
|
||||||
| 构建产物运行、Docker、发布包、运行时能力变化 | `docs/user/deployment.md` |
|
| 前端技术栈、组件、样式、数据层、前端测试 | `docs/development/frontend.md` |
|
||||||
| UP/DOWN 判定、failure、observation、detail 行为变化 | `docs/user/status-model.md` |
|
| checker 开发机制、schema/validate/resolve/execute/expect 约定 | `docs/development/checker.md` |
|
||||||
| 常见运行问题、依赖命令、容器权限、配置校验问题变化 | `docs/user/troubleshooting.md` |
|
| 构建、发布、Dockerfile、脚本、前后端静态资源集成 | `docs/development/release.md` |
|
||||||
| 开发入口、常用命令、质量门禁、全局规则变化 | `DEVELOPMENT.md` |
|
| YAML 顶层结构、server、variables、targets 通用字段 | `docs/user/configuration.md` |
|
||||||
| 架构边界、启动流程、运行时流程变化 | `docs/development/architecture.md` |
|
| checker 配置、expect 字段、示例、用户可见 checker 行为 | `docs/user/checkers/<type>.md`、`docs/user/checkers/README.md` |
|
||||||
| 后端 API、store、engine、logger、expect 实现机制变化 | `docs/development/backend.md` |
|
| ValueMatcher、ContentExpectations、KeyedExpectations、状态模型 | `docs/user/expectations.md` |
|
||||||
| 前端技术栈、组件、样式、数据层规范变化 | `docs/development/frontend.md` |
|
| 构建产物运行、Docker 参数、发布包、运行时依赖 | `docs/user/deployment.md` |
|
||||||
| 新增或修改 checker 的开发机制变化 | `CONTRIBUTING.md`、`docs/development/checker-development.md` |
|
| 常见运行问题、依赖命令、容器权限、配置校验问题 | `docs/user/troubleshooting.md` |
|
||||||
| 构建、发布、脚本、项目配置维护方式变化 | `docs/development/build-release.md` |
|
|
||||||
| 测试、lint、typecheck、hooks、格式化规范变化 | `docs/development/testing-quality.md` |
|
## development 文档如何更新
|
||||||
| 包管理、依赖、目录、提交、OpenSpec 约定变化 | `DEVELOPMENT.md` |
|
|
||||||
| 文档同步规则、文档影响分析规则变化 | `docs/README.md`、`openspec/config.yaml` |
|
开发文档解释“如何实现和维护”。代码变更影响开发者理解、开发流程、测试方式或架构边界时,必须更新 `docs/development/` 对应文档。
|
||||||
| AI 提示词资产变化 | `docs/prompts/` |
|
|
||||||
|
- 全局规则、常用命令、质量门禁、目录边界、OpenSpec 约定更新到 `docs/development/README.md`。
|
||||||
|
- 架构图、启动链路、运行时流程、前后端边界更新到 `docs/development/architecture.md`。
|
||||||
|
- 后端 API、配置加载、store、engine、logger、expect 基础设施和后端测试规范更新到 `docs/development/backend.md`。
|
||||||
|
- 前端技术栈、组件边界、数据流、样式规则和前端测试规范更新到 `docs/development/frontend.md`。
|
||||||
|
- checker 开发机制、文件结构、schema、validate、resolve、execute、expect、测试 checklist 更新到 `docs/development/checker.md`。
|
||||||
|
- 构建、Docker、release、脚本和发布验证更新到 `docs/development/release.md`。
|
||||||
|
- 不新增“杂项”开发文档;优先把内容放入上述最贴近的专题,确需新增专题时先更新本文档和 `openspec/config.yaml`。
|
||||||
|
|
||||||
|
## user 文档如何更新
|
||||||
|
|
||||||
|
用户文档解释“如何使用”和“用户能观察到什么”。变更影响用户配置、运行、部署、checker 行为、expect 规则、状态结果或排障方式时,必须更新 `docs/user/` 对应文档。
|
||||||
|
|
||||||
|
- 配置事实来源是 TypeBox schema、`probe-config.schema.json`、语义校验器和测试;`docs/user/configuration.md` 负责解释顶层结构和通用字段。
|
||||||
|
- checker 专属字段和示例只在 `docs/user/checkers/<type>.md` 完整展开,`docs/user/checkers/README.md` 只维护类型索引和选择建议。
|
||||||
|
- expect 断言模型、UP/DOWN、`failure`、`observation`、快速失败顺序更新到 `docs/user/expectations.md`。
|
||||||
|
- Docker、生产运行、发布包和运行时依赖更新到 `docs/user/deployment.md`。
|
||||||
|
- 常见错误和排查路径更新到 `docs/user/troubleshooting.md`。
|
||||||
|
- 用户文档避免解释内部实现细节,需要实现细节时链接到 `docs/development/`。
|
||||||
|
|
||||||
## 文档影响分析
|
## 文档影响分析
|
||||||
|
|
||||||
@@ -88,30 +106,13 @@ docs/
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
代码或配置变更
|
代码或配置变更
|
||||||
├─ 用户能感知吗?
|
-> 用户能感知吗?更新 docs/user/ 或 README.md
|
||||||
│ ├─ 配置 / checker / expect -> docs/user/
|
-> 开发者需要知道吗?更新 docs/development/
|
||||||
│ ├─ 部署 / 运行 / release -> docs/user/deployment.md
|
-> 文档规则变化吗?更新 docs/README.md 和 openspec/config.yaml
|
||||||
│ ├─ 状态 / observation / failure -> docs/user/status-model.md
|
-> 都不是?收尾说明写明无需更新文档及原因
|
||||||
│ └─ 项目入口变化 -> README.md
|
|
||||||
├─ 开发者需要知道吗?
|
|
||||||
│ ├─ checker 机制 -> CONTRIBUTING.md + docs/development/checker-development.md
|
|
||||||
│ ├─ 架构边界 -> docs/development/architecture.md
|
|
||||||
│ ├─ 后端机制 -> docs/development/backend.md
|
|
||||||
│ ├─ 前端机制 -> docs/development/frontend.md
|
|
||||||
│ ├─ 构建测试质量 -> docs/development/testing-quality.md
|
|
||||||
│ └─ 开发入口规则 -> DEVELOPMENT.md
|
|
||||||
└─ 都不是
|
|
||||||
└─ 收尾说明无需更新文档及原因
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 维护原则
|
同一事实只在最贴近读者的文档中完整展开,其他文档使用链接引用。根目录 README 保持轻量,不承载完整配置参考、checker 表或实现教程。
|
||||||
|
|
||||||
- 根目录入口文档保持轻量,不承载完整配置参考和实现教程。
|
|
||||||
- 用户文档解释“如何使用”,开发文档解释“如何实现和维护”。
|
|
||||||
- 配置事实来源是 TypeBox schema、`probe-config.schema.json`、语义校验器和测试;文档负责解释和示例。
|
|
||||||
- 每次代码变更都必须做文档影响分析;有影响时更新对应专题文档,无影响时在收尾说明中写明原因。
|
|
||||||
- 同一字段表只在最贴近读者的文档中完整展开,其他文档用链接引用。
|
|
||||||
- README 不承载完整配置表和 checker 表,DEVELOPMENT 不承载完整架构百科和 checker 教程。
|
|
||||||
|
|
||||||
## 收尾说明示例
|
## 收尾说明示例
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,115 @@
|
|||||||
# 开发文档
|
# 开发文档
|
||||||
|
|
||||||
本目录承载 DiAL 的开发和维护专题。日常开发入口见 [`../../DEVELOPMENT.md`](../../DEVELOPMENT.md),新增或修改 checker 前先阅读 [`../../CONTRIBUTING.md`](../../CONTRIBUTING.md)。
|
本文档是 DiAL 的开发入口。AI 工具和开发者应先阅读 [`../README.md`](../README.md) 判断文档归属,再阅读本文和最小必要专题。
|
||||||
|
|
||||||
|
适用场景:修改源码、测试、构建脚本、开发流程、架构边界、checker 开发机制或项目工程规则。
|
||||||
|
|
||||||
## 专题索引
|
## 专题索引
|
||||||
|
|
||||||
| 文档 | 内容 |
|
| 文档 | 内容 |
|
||||||
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
|
| ---------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||||
| [architecture.md](architecture.md) | 项目结构、启动流程、运行时流程、HTTP 请求流程、前后端边界 |
|
| [architecture.md](architecture.md) | 项目结构、启动流程、运行时流程、HTTP 请求流程、前后端边界 |
|
||||||
| [backend.md](backend.md) | 后端库优先级、API 路由、共享 helpers、类型规范、配置契约、store、engine、logger、expect、错误模型 |
|
| [backend.md](backend.md) | 后端库优先级、API 路由、共享 helpers、类型规范、配置契约、store、engine、logger、expect、错误模型 |
|
||||||
| [frontend.md](frontend.md) | React、TDesign、TanStack Query、组件、样式和前端测试规范 |
|
| [frontend.md](frontend.md) | React、TDesign、TanStack Query、组件、样式和前端测试规范 |
|
||||||
| [checker-development.md](checker-development.md) | 新增或修改 checker 的实现机制和完整 checklist |
|
| [checker.md](checker.md) | 新增或修改 checker 的实现机制、测试要求、文档同步和 checklist |
|
||||||
| [build-release.md](build-release.md) | 开发服务、前后端集成、构建、Docker、release、脚本、环境变量 |
|
| [release.md](release.md) | 开发服务、前后端集成、构建、Docker、release、脚本、环境变量 |
|
||||||
| [testing-quality.md](testing-quality.md) | lint、format、typecheck、test、hooks 和测试编写规范 |
|
| [../README.md](../README.md) | 文档路由、文档归属矩阵、development/user 文档更新规则 |
|
||||||
| [../../DEVELOPMENT.md](../../DEVELOPMENT.md) | 包管理、依赖、目录、提交、OpenSpec 和项目级约定 |
|
|
||||||
| [../README.md](../README.md) | 文档影响分析、文档归属矩阵和按任务阅读路径 |
|
## 常用命令
|
||||||
|
|
||||||
|
| 命令 | 说明 |
|
||||||
|
| -------------------------------- | ---------------------------------------- |
|
||||||
|
| `bun install` | 安装依赖 |
|
||||||
|
| `bun run dev probes.yaml` | 启动双进程开发环境 |
|
||||||
|
| `bun run dev:server probes.yaml` | 仅启动后端 API server |
|
||||||
|
| `bun run dev:web` | 仅启动 Vite dev server |
|
||||||
|
| `bun run schema` | 生成 `probe-config.schema.json` |
|
||||||
|
| `bun run schema:check` | 检查导出 schema 是否同步 |
|
||||||
|
| `bun run typecheck` | TypeScript 类型检查 |
|
||||||
|
| `bun run lint` | ESLint 和 Prettier 格式检查 |
|
||||||
|
| `bun run format` | Prettier 自动格式化 |
|
||||||
|
| `bun test` | 运行全部测试 |
|
||||||
|
| `bun run check` | `schema:check + typecheck + lint + test` |
|
||||||
|
| `bun run build` | 构建生产可执行文件 |
|
||||||
|
| `bun run verify` | `check + build` 完整验证 |
|
||||||
|
| `bun run release` | 跨平台发布打包 |
|
||||||
|
| `bun run clean` | 清理构建缓存与产物 |
|
||||||
|
|
||||||
|
## 质量门禁
|
||||||
|
|
||||||
|
代码变更必须按影响范围执行验证。
|
||||||
|
|
||||||
|
| 变更类型 | 必跑命令 |
|
||||||
|
| -------------------------------- | --------------------------------------------------------- |
|
||||||
|
| 常规代码变更 | `bun run check` |
|
||||||
|
| 构建、部署、发布、前后端集成变更 | `bun run verify` |
|
||||||
|
| 配置 schema 变化 | `bun run schema`、`bun run schema:check`、`bun run check` |
|
||||||
|
| checker 新增或修改 | `bun run schema`、`bun run schema:check`、`bun run check` |
|
||||||
|
| 仅文档变更 | 检查链接、索引和文档归属一致性 |
|
||||||
|
|
||||||
|
正式提交或影响构建产物时优先运行 `bun run verify`。如果因环境限制无法执行完整验证,必须在收尾说明中记录未执行项和原因。
|
||||||
|
|
||||||
|
## 全局工程规则
|
||||||
|
|
||||||
|
- 使用中文编写注释、文档和项目内交流内容。
|
||||||
|
- 仅使用 `bun` 作为包管理器,禁止使用 npm、pnpm、yarn。
|
||||||
|
- 运行工具使用 `bunx`,禁止使用 npx、pnpx。
|
||||||
|
- 新增代码优先复用已有组件、工具和依赖库,不引入新依赖;确需新增依赖时先说明原因。
|
||||||
|
- 后端优先使用 Bun 内置 API,其次是 es-toolkit、标准 Web API、主流三方库,最后才自行实现。
|
||||||
|
- 前端样式优先使用 TDesign 组件、组件 props、TDesign CSS tokens、`styles.css` CSS 类,最后才自行开发组件。
|
||||||
|
- 前端禁止组件内联 `style`、覆盖 TDesign 内部类名、使用 `!important`、硬编码色值。
|
||||||
|
- 当前项目未上线,不需要为旧行为做向前兼容,除非用户明确要求。
|
||||||
|
|
||||||
|
## 包管理、依赖与提交
|
||||||
|
|
||||||
|
- 仅使用 `bun` 安装依赖和运行项目脚本,锁文件为 `bun.lock`。
|
||||||
|
- 新增依赖前先确认 Bun 内置 API、es-toolkit、标准 Web API、现有三方库和项目公共工具是否已满足需求。
|
||||||
|
- Git 提交信息使用中文,格式为 `类型: 简短描述`。
|
||||||
|
- 提交类型限定为 `feat`、`fix`、`refactor`、`docs`、`style`、`test`、`chore`。
|
||||||
|
- 多行提交描述时,标题和正文之间空一行。
|
||||||
|
|
||||||
|
## 目录边界
|
||||||
|
|
||||||
|
| 目录 | 约定 |
|
||||||
|
| ------------------- | ---------------------------------------------------------- |
|
||||||
|
| `src/server/` | Bun 后端代码,不能 import `src/web/`,HTML import 集成除外 |
|
||||||
|
| `src/web/` | React Dashboard,不能 import `src/server/` 运行时实现 |
|
||||||
|
| `src/shared/` | 前后端共享 TypeScript 类型 |
|
||||||
|
| `scripts/` | 独立运行脚本,可 import 项目源码 |
|
||||||
|
| `tests/` | 测试目录,结构镜像 `src/` |
|
||||||
|
| `docs/user/` | 用户使用、配置、部署、checker 和排障文档 |
|
||||||
|
| `docs/development/` | 架构、后端、前端、发布和 checker 开发文档 |
|
||||||
|
| `openspec/` | OpenSpec 变更管理与规格文档 |
|
||||||
|
|
||||||
|
## 文档影响分析
|
||||||
|
|
||||||
|
每次代码变更都必须执行文档影响分析。
|
||||||
|
|
||||||
|
| 如果变更影响 | 更新 |
|
||||||
|
| --------------------------------------------------- | ------------------------------------------ |
|
||||||
|
| 用户可见行为、配置、checker、expect、部署、状态模型 | `docs/user/` 对应文档 |
|
||||||
|
| 开发流程、架构、测试、构建发布、checker 开发机制 | `docs/development/` 对应文档 |
|
||||||
|
| 项目定位、快速开始、核心能力列表、文档导航 | `README.md` |
|
||||||
|
| 文档同步规则或文档归属矩阵 | `docs/README.md` 和 `openspec/config.yaml` |
|
||||||
|
|
||||||
|
如果无需更新文档,必须在收尾说明中说明原因。详细规则见 [文档总览](../README.md)。
|
||||||
|
|
||||||
|
## OpenSpec 协作规则
|
||||||
|
|
||||||
|
- 本项目 OpenSpec 使用 `fast-drive` schema,变更文档只包含 `design.md` 和 `tasks.md`,不创建 `proposal.md` 或 `specs/*.md`。
|
||||||
|
- `design.md` 是 scope、requirements、decisions、guardrails、execution direction 和 verification expectations 的 source of truth。
|
||||||
|
- `tasks.md` 必须从 `design.md` 派生,一行一个 checkbox 任务。
|
||||||
|
- 实现阶段按 `tasks.md` 顺序执行,完成后立即标记任务状态。
|
||||||
|
|
||||||
## 事实来源
|
## 事实来源
|
||||||
|
|
||||||
| 主题 | 事实来源 |
|
| 主题 | 事实来源 |
|
||||||
| ---------------- | ---------------------------------------------------------- |
|
| -------------- | ---------------------------------------------------------- |
|
||||||
| 代码结构和实现 | `src/`、`scripts/`、`tests/` |
|
| 代码结构和实现 | `src/`、`scripts/`、`tests/` |
|
||||||
| 配置 schema | TypeBox fragments、`probe-config.schema.json`、schema 测试 |
|
| 配置 schema | TypeBox fragments、`probe-config.schema.json`、schema 测试 |
|
||||||
| 项目全局规则 | `openspec/config.yaml`、`DEVELOPMENT.md`、本目录专题文档 |
|
| 项目全局规则 | `openspec/config.yaml`、本文档、本目录专题文档 |
|
||||||
| checker 贡献流程 | `CONTRIBUTING.md`、`checker-development.md` |
|
| checker 流程 | [checker.md](checker.md) |
|
||||||
|
|
||||||
|
## 更新触发条件
|
||||||
|
|
||||||
|
修改常用命令、质量门禁、全局工程规则、目录边界、OpenSpec 协作方式或开发文档索引时,必须更新本文档。
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# 架构与边界
|
# 架构与边界
|
||||||
|
|
||||||
|
本文档说明 DiAL 的项目结构、启动链路、运行时流程、HTTP 请求流程和前后端边界。
|
||||||
|
|
||||||
|
适用场景:修改目录边界、启动流程、运行时调度、HTTP server、前后端集成方式或主要模块职责。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -104,3 +108,7 @@ Request
|
|||||||
| `src/server/checker/expect/` | 跨 checker 复用的断言基础设施 |
|
| `src/server/checker/expect/` | 跨 checker 复用的断言基础设施 |
|
||||||
| `src/web/` | React Dashboard |
|
| `src/web/` | React Dashboard |
|
||||||
| `src/shared/api.ts` | 前后端共享 API 类型 |
|
| `src/shared/api.ts` | 前后端共享 API 类型 |
|
||||||
|
|
||||||
|
## 更新触发条件
|
||||||
|
|
||||||
|
修改项目结构、启动流程、运行时流程、HTTP 请求流程、前后端边界或主要模块职责时,必须更新本文档。
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# 后端开发
|
# 后端开发
|
||||||
|
|
||||||
|
本文档说明 DiAL 后端的 API、配置加载、存储、拨测引擎、日志、expect 和错误模型开发约定。
|
||||||
|
|
||||||
|
适用场景:修改 `src/server/`、`src/shared/api.ts`、后端测试、配置契约、API 响应、store、engine、logger 或 expect 基础设施。
|
||||||
|
|
||||||
## 库使用优先级
|
## 库使用优先级
|
||||||
|
|
||||||
| 优先级 | 来源 | 典型用途 |
|
| 优先级 | 来源 | 典型用途 |
|
||||||
@@ -111,13 +115,28 @@ Ajv 保持严格拒绝模式:`allErrors: true`、不启用类型强制转换
|
|||||||
| `ContentExpectations` | 返回内容或半结构化内容 | body、stdout、stderr、banner、response、output、result |
|
| `ContentExpectations` | 返回内容或半结构化内容 | body、stdout、stderr、banner、response、output、result |
|
||||||
| `KeyedExpectations` | 动态键值断言 | headers、DB rows 列值 |
|
| `KeyedExpectations` | 动态键值断言 | headers、DB rows 列值 |
|
||||||
|
|
||||||
详细 checker 开发流程见 [Checker 开发](checker-development.md)。
|
详细 checker 开发流程见 [Checker 开发](checker.md)。
|
||||||
|
|
||||||
## 错误模型
|
## 错误模型
|
||||||
|
|
||||||
| 类型 | 结构 |
|
- API 错误:`{ error: "描述", status: <code> }`
|
||||||
| ------------ | ----------------------------------- | ------------------------------------------------------- |
|
- CheckFailure:`{ kind: "error" | "mismatch", phase, path, expected?, actual?, message }`
|
||||||
| API 错误 | `{ error: "描述", status: <code> }` |
|
|
||||||
| CheckFailure | `{ kind: "error" | "mismatch", phase, path, expected?, actual?, message }` |
|
|
||||||
|
|
||||||
expect 校验失败记录首个失败原因;网络、超时、进程崩溃统一为 `kind: "error"`。
|
expect 校验失败记录首个失败原因;网络、超时、进程崩溃统一为 `kind: "error"`。
|
||||||
|
|
||||||
|
## 后端测试与验证
|
||||||
|
|
||||||
|
| 变更类型 | 测试重点 |
|
||||||
|
| ---------------------- | ---------------------------------------- |
|
||||||
|
| API 路由 | `tests/server/app.test.ts` 集成行为 |
|
||||||
|
| 配置 schema 或语义校验 | schema 导出、合法配置、非法配置 |
|
||||||
|
| store | SQLite 写入、查询、分页、聚合和清理 |
|
||||||
|
| engine | 调度、并发、超时、结果写入和状态变化日志 |
|
||||||
|
| expect 基础设施 | matcher 语义、快速失败、错误信息 |
|
||||||
|
| checker runner | 见 [Checker 开发](checker.md#测试要求) |
|
||||||
|
|
||||||
|
后端运行时代码统一通过注入的 Logger 输出日志,禁止直接使用 `console.*`。新增或修改后端逻辑通常需要运行 `bun run check`;影响构建产物或前后端集成时运行 `bun run verify`。
|
||||||
|
|
||||||
|
## 更新触发条件
|
||||||
|
|
||||||
|
修改后端 API、共享类型、配置契约、store、engine、logger、expect 基础设施、错误模型或后端测试规范时,必须更新本文档。
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
# Checker 开发
|
|
||||||
|
|
||||||
Checker 是 DiAL 的核心扩展单元。每个 checker 是 `src/server/checker/runner/<type>/` 下的自包含目录,包含该 checker 所需的类型、schema、校验、执行逻辑和断言。
|
|
||||||
|
|
||||||
新增或修改 checker 前请同时阅读 [`../../CONTRIBUTING.md`](../../CONTRIBUTING.md)、[配置文件](../user/configuration.md)、[校验规则](../user/expectations.md) 和 [Checker 用户文档](../user/checkers/README.md)。
|
|
||||||
|
|
||||||
## 架构目标
|
|
||||||
|
|
||||||
```text
|
|
||||||
checkerRegistry
|
|
||||||
├── runner/index.ts
|
|
||||||
├── schema/builder.ts
|
|
||||||
├── schema/validate.ts
|
|
||||||
├── config-loader.ts
|
|
||||||
├── engine.ts
|
|
||||||
└── store.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
注册后,中间层通过 registry 自动委托 schema 生成、契约校验、配置 resolve、执行和序列化。新增 checker 不应在中间层新增 `switch/case` 或类型分支。
|
|
||||||
|
|
||||||
## 标准文件结构
|
|
||||||
|
|
||||||
| 文件 | 职责 |
|
|
||||||
| ------------- | ----------------------------------------------------- |
|
|
||||||
| `index.ts` | 模块入口,re-export Checker 类 |
|
|
||||||
| `types.ts` | Checker 专属类型 |
|
|
||||||
| `schema.ts` | TypeBox 契约 schema,包含 config 和 expect |
|
|
||||||
| `validate.ts` | 启动期语义校验 |
|
|
||||||
| `execute.ts` | Checker 类,实现 resolve、execute、serialize |
|
|
||||||
| `expect.ts` | Checker 专用断言函数 |
|
|
||||||
| 其他文件 | 协议解析、编码、provider 适配、平台命令封装等专属逻辑 |
|
|
||||||
|
|
||||||
## 类型定义
|
|
||||||
|
|
||||||
在 `types.ts` 中定义:
|
|
||||||
|
|
||||||
- `RawXxxTargetConfig`
|
|
||||||
- `RawXxxExpectConfig`
|
|
||||||
- `ResolvedXxxExpectConfig`
|
|
||||||
- `ResolvedXxxTarget extends ResolvedTargetBase`
|
|
||||||
|
|
||||||
不需要修改顶层 `checker/types.ts`。base interface 使用 index signature 支持扩展。
|
|
||||||
|
|
||||||
## Schema
|
|
||||||
|
|
||||||
checker 必须提供 `CheckerSchemas`,包含 Authoring 和 Normalized 两套 config/expect 片段。Authoring 描述用户 YAML 可写 DSL,Normalized 描述 normalizer 输出。
|
|
||||||
|
|
||||||
常用 fragments:
|
|
||||||
|
|
||||||
| Fragment | 用途 |
|
|
||||||
| ----------------------------------- | ------------------------- |
|
|
||||||
| `durationSchema` | 时长字符串 |
|
|
||||||
| `sizeSchema` | 大小单位 |
|
|
||||||
| `statusCodePatternSchema` | HTTP 状态码或范围 |
|
|
||||||
| `stringMapSchema` | headers、env 等字符串映射 |
|
|
||||||
| `createValueMatcherSchema()` | ValueMatcher |
|
|
||||||
| `createContentExpectationsSchema()` | ContentExpectations |
|
|
||||||
| `createKeyedExpectationsSchema()` | KeyedExpectations |
|
|
||||||
|
|
||||||
默认对象策略为 `additionalProperties: false`。只有明确的动态键值表可以开放任意键名。
|
|
||||||
|
|
||||||
## 语义校验
|
|
||||||
|
|
||||||
在 `validate.ts` 中实现 JSON Schema 无法表达的规则,统一返回 `ConfigValidationIssue[]`,不要直接拼接最终错误字符串。
|
|
||||||
|
|
||||||
共享校验工具包括:
|
|
||||||
|
|
||||||
| 函数 | 用途 |
|
|
||||||
| -------------------------------- | ---------------------------- |
|
|
||||||
| `validateRawValueExpectation` | 校验 Raw ValueExpectation |
|
|
||||||
| `validateRawContentExpectations` | 校验 ContentExpectations |
|
|
||||||
| `validateRawKeyedExpectations` | 校验 KeyedExpectations |
|
|
||||||
| `validateJsonPath` | 校验项目支持的 JSONPath 子集 |
|
|
||||||
| `isJsonValue` | 判断合法 JSON value |
|
|
||||||
|
|
||||||
## resolve 规范
|
|
||||||
|
|
||||||
`resolve()` 只做内置默认值填充、路径解析、单位转换,不执行校验。输入已经通过 Normalized schema 和语义校验,expect 已是 normalized 形态。
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const expect = target.expect as ResolvedXxxExpectConfig | undefined;
|
|
||||||
const resolvedExpect: ResolvedXxxExpectConfig = expect
|
|
||||||
? { ...expect, status: expect.status ?? [200] }
|
|
||||||
: { status: [200] };
|
|
||||||
```
|
|
||||||
|
|
||||||
返回值使用 `satisfies ResolvedXxxTarget` 确保类型正确。
|
|
||||||
|
|
||||||
## execute 规范
|
|
||||||
|
|
||||||
- 始终记录 `timestamp` 和 `start = performance.now()`。
|
|
||||||
- 通过 `ctx.signal` 支持超时取消。
|
|
||||||
- 首个 expect 失败即停止,返回带 `failure` 的结果。
|
|
||||||
- 成功时 `failure: null, matched: true`。
|
|
||||||
- 异常时使用 `errorFailure()`。
|
|
||||||
- 不匹配时使用 `mismatchFailure()`。
|
|
||||||
- `expected` 参数应传用户可读值,必要时使用 `displayValueExpectation()`。
|
|
||||||
|
|
||||||
## expect 字段选择
|
|
||||||
|
|
||||||
| 场景 | 模型 |
|
|
||||||
| ------------------------------------ | ------------------- |
|
|
||||||
| 状态类结果且集合小而稳定 | enum 或 boolean |
|
|
||||||
| 单值数字指标或字符串元数据 | ValueMatcher |
|
|
||||||
| 文本、JSON、HTML、XML 或半结构化内容 | ContentExpectations |
|
|
||||||
| 动态键值表 | KeyedExpectations |
|
|
||||||
|
|
||||||
不要为了统一而把状态类字段改成 ValueMatcher。一个 expect 字段只能对应一种断言模型。
|
|
||||||
|
|
||||||
## 注册
|
|
||||||
|
|
||||||
1. 创建 `src/server/checker/runner/<type>/index.ts`。
|
|
||||||
2. 在 `src/server/checker/runner/index.ts` 添加导入。
|
|
||||||
3. 在 registry 初始化数组中添加 checker 实例。
|
|
||||||
|
|
||||||
注册后,schema builder、validate、config-loader、engine、store 会自动按 registry 分发。
|
|
||||||
|
|
||||||
## 测试要求
|
|
||||||
|
|
||||||
测试文件放在 `tests/server/checker/runner/<type>/`,结构镜像源文件。
|
|
||||||
|
|
||||||
| 测试类别 | 覆盖内容 |
|
|
||||||
| ------------ | ---------------------------------------- |
|
|
||||||
| 契约测试 | TypeBox schema 与 JSON Schema 导出一致性 |
|
|
||||||
| 语义校验测试 | 合法和非法配置 |
|
|
||||||
| resolve 测试 | 默认值合并、路径解析、单位转换 |
|
|
||||||
| execute 测试 | 成功、失败、超时、expect 组合 |
|
|
||||||
| 注册测试 | registry 注册行为 |
|
|
||||||
| 配置加载测试 | 含新 checker 的 YAML 完整加载流程 |
|
|
||||||
|
|
||||||
## 文档和 schema 更新
|
|
||||||
|
|
||||||
新增或修改 checker 时通常需要更新:
|
|
||||||
|
|
||||||
- `probes.example.yaml`
|
|
||||||
- `probe-config.schema.json`,通过 `bun run schema` 生成
|
|
||||||
- `docs/user/checkers/<type>.md`
|
|
||||||
- `docs/user/checkers/README.md`
|
|
||||||
- `docs/user/expectations.md`,仅当断言模型或通用规则变化
|
|
||||||
- `docs/development/checker-development.md`,仅当开发机制变化
|
|
||||||
- `CONTRIBUTING.md`,仅当贡献流程或 checklist 变化
|
|
||||||
|
|
||||||
## 完成检查清单
|
|
||||||
|
|
||||||
```text
|
|
||||||
□ src/server/checker/runner/<type>/types.ts
|
|
||||||
□ src/server/checker/runner/<type>/schema.ts
|
|
||||||
□ src/server/checker/runner/<type>/validate.ts
|
|
||||||
□ src/server/checker/runner/<type>/execute.ts
|
|
||||||
□ src/server/checker/runner/<type>/expect.ts
|
|
||||||
□ src/server/checker/runner/<type>/index.ts
|
|
||||||
□ src/server/checker/runner/index.ts
|
|
||||||
□ tests/server/checker/runner/<type>/
|
|
||||||
□ probes.example.yaml
|
|
||||||
□ probe-config.schema.json
|
|
||||||
□ docs/user/checkers/<type>.md
|
|
||||||
□ bun run schema
|
|
||||||
□ bun run schema:check
|
|
||||||
□ bun run check
|
|
||||||
□ bun run verify
|
|
||||||
```
|
|
||||||
221
docs/development/checker.md
Normal file
221
docs/development/checker.md
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
# Checker 开发
|
||||||
|
|
||||||
|
Checker 是 DiAL 的核心扩展单元。每个 checker 是 `src/server/checker/runner/<type>/` 下的自包含目录,包含类型、schema、语义校验、执行逻辑、序列化和断言。
|
||||||
|
|
||||||
|
适用场景:新增 checker、修改 checker 配置或 expect、调整 checker 注册机制、改动 checker 测试或用户文档同步规则。
|
||||||
|
|
||||||
|
新增或修改 checker 前必须阅读 [开发入口](README.md)、[配置文件](../user/configuration.md)、[校验规则](../user/expectations.md) 和 [Checker 用户文档](../user/checkers/README.md)。还应阅读现有同类 checker 的实现和测试,例如 `src/server/checker/runner/http/` 与 `tests/server/checker/runner/http/`。
|
||||||
|
|
||||||
|
## 设计原则
|
||||||
|
|
||||||
|
- 每个 checker 必须自包含在 `src/server/checker/runner/<type>/`。
|
||||||
|
- checker 专属类型、schema、validate、execute、expect、normalize 和协议辅助逻辑放在同一目录。
|
||||||
|
- 注册只修改 `src/server/checker/runner/index.ts`,中间层不新增 type switch。
|
||||||
|
- schema 层只描述契约,语义规则放入 `validate.ts`。
|
||||||
|
- `resolve()` 只做默认值填充、路径解析和单位转换,不执行校验。
|
||||||
|
- `execute()` 必须支持 `CheckerContext.signal` 超时取消。
|
||||||
|
- expect 字段必须选择合适断言模型,不为了统一而滥用 ValueMatcher。
|
||||||
|
- failure phase 命名遵循去单位后缀规则,例如 `durationMs` 对应 `duration`。
|
||||||
|
|
||||||
|
## 架构目标
|
||||||
|
|
||||||
|
```text
|
||||||
|
checkerRegistry
|
||||||
|
├── runner/index.ts
|
||||||
|
├── schema/builder.ts
|
||||||
|
├── schema/validate.ts
|
||||||
|
├── config-loader.ts
|
||||||
|
├── engine.ts
|
||||||
|
└── store.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
注册后,中间层通过 registry 自动委托 schema 生成、契约校验、配置 normalize、配置 resolve、执行和序列化。新增 checker 不应在中间层新增 `switch/case` 或类型分支。
|
||||||
|
|
||||||
|
## 标准文件结构
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
| -------------- | ------------------------------------------------------- |
|
||||||
|
| `index.ts` | 模块入口,re-export Checker 类 |
|
||||||
|
| `types.ts` | Checker 专属类型 |
|
||||||
|
| `schema.ts` | TypeBox 契约 schema,包含 config 和 expect |
|
||||||
|
| `validate.ts` | 启动期语义校验 |
|
||||||
|
| `normalize.ts` | Checker 专属 authoring expect 归一化 |
|
||||||
|
| `execute.ts` | Checker 类,实现 normalize、resolve、execute、serialize |
|
||||||
|
| `expect.ts` | Checker 专用断言函数 |
|
||||||
|
| 其他文件 | 协议解析、编码、provider 适配、平台命令封装等专属逻辑 |
|
||||||
|
|
||||||
|
## 类型定义
|
||||||
|
|
||||||
|
在 `types.ts` 中定义:
|
||||||
|
|
||||||
|
- `RawXxxTargetConfig`
|
||||||
|
- `RawXxxExpectConfig`
|
||||||
|
- `ResolvedXxxExpectConfig`
|
||||||
|
- `ResolvedXxxTarget extends ResolvedTargetBase`
|
||||||
|
|
||||||
|
不需要修改顶层 `checker/types.ts`。base interface 使用 index signature 支持扩展。
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
checker 必须提供 `CheckerSchemas`,包含 Authoring 和 Normalized 两套 config/expect 片段。Authoring 描述用户 YAML 可写 DSL,Normalized 描述 normalizer 输出。
|
||||||
|
|
||||||
|
常用 fragments:
|
||||||
|
|
||||||
|
| Fragment | 用途 |
|
||||||
|
| ----------------------------------- | ------------------------- |
|
||||||
|
| `durationSchema` | 时长字符串 |
|
||||||
|
| `sizeSchema` | 大小单位 |
|
||||||
|
| `statusCodePatternSchema` | HTTP 状态码或范围 |
|
||||||
|
| `stringMapSchema` | headers、env 等字符串映射 |
|
||||||
|
| `createValueMatcherSchema()` | ValueMatcher |
|
||||||
|
| `createContentExpectationsSchema()` | ContentExpectations |
|
||||||
|
| `createKeyedExpectationsSchema()` | KeyedExpectations |
|
||||||
|
|
||||||
|
默认对象策略为 `additionalProperties: false`。只有明确的动态键值表可以开放任意键名。
|
||||||
|
|
||||||
|
## 语义校验
|
||||||
|
|
||||||
|
在 `validate.ts` 中实现 JSON Schema 无法表达的规则,统一返回 `ConfigValidationIssue[]`,不要直接拼接最终错误字符串。
|
||||||
|
|
||||||
|
共享校验工具包括:
|
||||||
|
|
||||||
|
| 函数 | 用途 |
|
||||||
|
| -------------------------------- | ---------------------------- |
|
||||||
|
| `validateRawValueExpectation` | 校验 Raw ValueExpectation |
|
||||||
|
| `validateRawContentExpectations` | 校验 ContentExpectations |
|
||||||
|
| `validateRawKeyedExpectations` | 校验 KeyedExpectations |
|
||||||
|
| `validateJsonPath` | 校验项目支持的 JSONPath 子集 |
|
||||||
|
| `isJsonValue` | 判断合法 JSON value |
|
||||||
|
|
||||||
|
## normalize 规范
|
||||||
|
|
||||||
|
`normalize()` 在 `CheckerDefinition` 中定义为必需方法,负责将 authoring expect DSL 转换为 normalized 形态。输入为变量已解析后的 target,输出为适配 normalized schema 的 target。该方法在 `resolve()` 和 normalized contract 校验之前执行。
|
||||||
|
|
||||||
|
在 `normalize.ts` 中实现 `normalizeTargetExpect` 函数,`execute.ts` 中的 `normalize` 方法委托到该函数。
|
||||||
|
|
||||||
|
共享 normalize helper 位于 `src/server/checker/expect/normalize.ts`:
|
||||||
|
|
||||||
|
| 函数 | 用途 |
|
||||||
|
| ------------------ | -------------------------------------------------------- |
|
||||||
|
| `compactExpect` | 合并两个 expect record,过滤 undefined 字段 |
|
||||||
|
| `normalizeValue` | ValueMatcher 原始值简写展开为 `{equals: value}` |
|
||||||
|
| `normalizeContent` | ContentExpectations 简写展开为 normalized 形态 |
|
||||||
|
| `normalizeKeyed` | KeyedExpectations 对象形态展开为 `[{key, matcher}]` 数组 |
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { compactExpect, normalizeContent, normalizeKeyed, 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, {
|
||||||
|
/* checker 专属字段映射 */
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
expect 字段的归一化规则:ValueMatcher 字段调用 `normalizeValue()`,ContentExpectations 字段调用 `normalizeContent()`,KeyedExpectations 字段调用 `normalizeKeyed()`,boolean/enum/array 等非断言模型字段直接透传。
|
||||||
|
|
||||||
|
## resolve 规范
|
||||||
|
|
||||||
|
`resolve()` 只做内置默认值填充、路径解析、单位转换,不执行校验。输入已经通过 Normalized schema 和语义校验,expect 已是 normalized 形态。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const expect = target.expect as ResolvedXxxExpectConfig | undefined;
|
||||||
|
const resolvedExpect: ResolvedXxxExpectConfig = expect
|
||||||
|
? { ...expect, status: expect.status ?? [200] }
|
||||||
|
: { status: [200] };
|
||||||
|
```
|
||||||
|
|
||||||
|
返回值使用 `satisfies ResolvedXxxTarget` 确保类型正确。
|
||||||
|
|
||||||
|
## execute 规范
|
||||||
|
|
||||||
|
- 始终记录 `timestamp` 和 `start = performance.now()`。
|
||||||
|
- 通过 `ctx.signal` 支持超时取消。
|
||||||
|
- 首个 expect 失败即停止,返回带 `failure` 的结果。
|
||||||
|
- 成功时 `failure: null, matched: true`。
|
||||||
|
- 异常时使用 `errorFailure()`。
|
||||||
|
- 不匹配时使用 `mismatchFailure()`。
|
||||||
|
- `expected` 参数应传用户可读值,必要时使用 `displayValueExpectation()`。
|
||||||
|
|
||||||
|
## expect 字段选择
|
||||||
|
|
||||||
|
| 场景 | 模型 |
|
||||||
|
| ------------------------------------ | ------------------- |
|
||||||
|
| 状态类结果且集合小而稳定 | enum 或 boolean |
|
||||||
|
| 单值数字指标或字符串元数据 | ValueMatcher |
|
||||||
|
| 文本、JSON、HTML、XML 或半结构化内容 | ContentExpectations |
|
||||||
|
| 动态键值表 | KeyedExpectations |
|
||||||
|
|
||||||
|
不要为了统一而把状态类字段改成 ValueMatcher。一个 expect 字段只能对应一种断言模型。
|
||||||
|
|
||||||
|
## 注册
|
||||||
|
|
||||||
|
1. 创建 `src/server/checker/runner/<type>/index.ts`。
|
||||||
|
2. 在 `src/server/checker/runner/index.ts` 添加导入。
|
||||||
|
3. 在 registry 初始化数组中添加 checker 实例。
|
||||||
|
|
||||||
|
注册后,schema builder、validate、config-loader、engine、store 会自动按 registry 分发。
|
||||||
|
|
||||||
|
## 测试要求
|
||||||
|
|
||||||
|
测试文件放在 `tests/server/checker/runner/<type>/`,结构镜像源文件。
|
||||||
|
|
||||||
|
| 测试类别 | 覆盖内容 |
|
||||||
|
| -------------- | ---------------------------------------------------- |
|
||||||
|
| 契约测试 | TypeBox schema 与 JSON Schema 导出一致性 |
|
||||||
|
| 语义校验测试 | 合法和非法配置 |
|
||||||
|
| normalize 测试 | authoring expect 简写展开和 normalized contract 通过 |
|
||||||
|
| resolve 测试 | 默认值合并、路径解析、单位转换 |
|
||||||
|
| execute 测试 | 成功、失败、超时、expect 组合 |
|
||||||
|
| 注册测试 | registry 注册行为 |
|
||||||
|
| 配置加载测试 | 含新 checker 的 YAML 完整加载流程 |
|
||||||
|
|
||||||
|
## 文档和 schema 更新
|
||||||
|
|
||||||
|
新增或修改 checker 时通常需要更新:
|
||||||
|
|
||||||
|
- `probes.example.yaml`
|
||||||
|
- `probe-config.schema.json`,通过 `bun run schema` 生成
|
||||||
|
- `docs/user/checkers/<type>.md`
|
||||||
|
- `docs/user/checkers/README.md`
|
||||||
|
- `docs/user/expectations.md`,仅当断言模型、状态模型或通用规则变化
|
||||||
|
- `docs/user/configuration.md`,仅当 target 通用字段或配置加载形态变化
|
||||||
|
- `docs/development/checker.md`,仅当 checker 开发机制、测试要求或 checklist 变化
|
||||||
|
- `docs/README.md` 和 `openspec/config.yaml`,仅当文档同步规则变化
|
||||||
|
|
||||||
|
## 验证命令
|
||||||
|
|
||||||
|
新增或修改 checker 后通常需要运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run schema
|
||||||
|
bun run schema:check
|
||||||
|
bun run check
|
||||||
|
```
|
||||||
|
|
||||||
|
影响构建、Docker 或发布包时追加运行 `bun run verify`。
|
||||||
|
|
||||||
|
## 完成检查清单
|
||||||
|
|
||||||
|
```text
|
||||||
|
□ checker 类型、schema、validate、normalize、resolve、execute、serialize 已实现
|
||||||
|
□ checker 已在 runner/index.ts 注册
|
||||||
|
□ 配置契约、语义校验和 JSON Schema 导出已同步
|
||||||
|
□ probes.example.yaml 已添加或更新示例
|
||||||
|
□ tests/server/checker/runner/<type>/ 已覆盖契约、校验、normalize、resolve、execute、注册和配置加载
|
||||||
|
□ docs/user/checkers/<type>.md 已添加或更新
|
||||||
|
□ docs/user/checkers/README.md 已添加或更新
|
||||||
|
□ 文档影响分析已完成,必要文档已同步
|
||||||
|
□ bun run schema 和 bun run schema:check 已通过
|
||||||
|
□ bun run check 已通过
|
||||||
|
□ bun run verify 已通过或记录未执行原因
|
||||||
|
```
|
||||||
|
|
||||||
|
## 更新触发条件
|
||||||
|
|
||||||
|
修改 checker 开发机制、目录结构、schema/validate/normalize/resolve/execute/expect 约定、测试要求、验证命令或文档同步 checklist 时,必须更新本文档。
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
# 前端开发
|
# 前端开发
|
||||||
|
|
||||||
|
本文档说明 DiAL Dashboard 的 React、TDesign、TanStack Query、组件、样式和前端测试约定。
|
||||||
|
|
||||||
|
适用场景:修改 `src/web/`、前端共享类型使用方式、Dashboard 数据流、组件结构、样式规则或前端测试。
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
| 层面 | 技术 | 用途 |
|
| 层面 | 技术 | 用途 |
|
||||||
@@ -108,10 +112,19 @@ async function fetchJson<T>(url: string): Promise<T> {
|
|||||||
- 严禁使用 `!important`。
|
- 严禁使用 `!important`。
|
||||||
- 颜色统一使用 TDesign CSS tokens,不使用硬编码色值。
|
- 颜色统一使用 TDesign CSS tokens,不使用硬编码色值。
|
||||||
|
|
||||||
## 前端测试
|
## 前端测试与验证
|
||||||
|
|
||||||
- 测试目录为 `tests/web/`,结构对应 `src/web/`。
|
- 测试目录为 `tests/web/`,结构对应 `src/web/`。
|
||||||
- 重点测试 `constants/` 中的纯函数。
|
- 单元测试重点覆盖 `constants/`、`utils/` 和 hooks 中的纯逻辑。
|
||||||
- 组件测试使用 jsdom 和 `@testing-library/react`。
|
- 组件测试使用 jsdom 和 `@testing-library/react`。
|
||||||
- 测试用户行为而非实现细节。
|
- 测试用户行为而非实现细节。
|
||||||
- 只 mock 系统边界,例如 `fetch`。
|
- 只 mock 系统边界,例如 `fetch`。
|
||||||
|
- 使用真实的 QueryClientProvider 包裹依赖 TanStack Query 的组件。
|
||||||
|
- 异步错误断言使用 helper 或显式 try/catch,避免依赖 Bun `expect(...).rejects` 与 `await-thenable` 规则的类型不匹配。
|
||||||
|
- 组件测试环境由 `tests/setup.ts` 和 `bunfig.toml` preload 提供,包含 ResizeObserver、IntersectionObserver、matchMedia、attachEvent 和 Recharts mock。
|
||||||
|
|
||||||
|
前端逻辑变更通常需要运行 `bun run check`。影响生产静态资源、前后端集成或构建流程时运行 `bun run verify`。
|
||||||
|
|
||||||
|
## 更新触发条件
|
||||||
|
|
||||||
|
修改前端技术栈、组件边界、数据流、样式规则、测试环境或前端验证方式时,必须更新本文档。
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# 构建与发布
|
# 构建与发布
|
||||||
|
|
||||||
|
本文档说明开发服务、前后端集成、生产构建、Docker 镜像、跨平台 release 和相关脚本维护方式。
|
||||||
|
|
||||||
|
适用场景:修改 `scripts/`、构建流程、Dockerfile、静态资源集成、release 打包、运行时环境变量或部署产物。
|
||||||
|
|
||||||
## 开发期运行
|
## 开发期运行
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -105,3 +109,19 @@ release 流程:
|
|||||||
- `scripts/build-common.ts` 中的 import specifier 输出必须使用 `/` 分隔符。
|
- `scripts/build-common.ts` 中的 import specifier 输出必须使用 `/` 分隔符。
|
||||||
- 跨平台路径测试不得用当前平台 `path.sep` 伪装其他平台,应使用 `node:path.win32` 或等价注入方式模拟。
|
- 跨平台路径测试不得用当前平台 `path.sep` 伪装其他平台,应使用 `node:path.win32` 或等价注入方式模拟。
|
||||||
- 如本地 Docker 环境不支持 buildx 或多架构模拟,需在变更记录中说明未执行原因。
|
- 如本地 Docker 环境不支持 buildx 或多架构模拟,需在变更记录中说明未执行原因。
|
||||||
|
|
||||||
|
## 发布验证
|
||||||
|
|
||||||
|
| 变更类型 | 验证方式 |
|
||||||
|
| ---------------- | --------------------------------------- |
|
||||||
|
| 构建脚本 | `bun run verify` |
|
||||||
|
| release 脚本 | `bun run release` 或指定受影响 target |
|
||||||
|
| Dockerfile | 本地 `docker build`,无法执行时说明原因 |
|
||||||
|
| 静态资源集成 | `bun run build`,必要时启动产物手动验证 |
|
||||||
|
| 配置 schema 同步 | `bun run schema:check` |
|
||||||
|
|
||||||
|
影响用户部署方式、Docker 运行参数、发布包内容或运行时依赖时,必须同步更新 [用户部署文档](../user/deployment.md)。
|
||||||
|
|
||||||
|
## 更新触发条件
|
||||||
|
|
||||||
|
修改开发服务、前后端集成、构建产物、Docker 镜像、release target、脚本参数或发布验证方式时,必须更新本文档。
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
# 测试与质量
|
|
||||||
|
|
||||||
## 质量命令
|
|
||||||
|
|
||||||
| 命令 | 说明 |
|
|
||||||
| ---------------------- | -------------------------------------------------------------- |
|
|
||||||
| `bun run lint` | ESLint 检查,含类型感知规则、导入排序、导入验证、Prettier 格式 |
|
|
||||||
| `bun run format` | Prettier 自动格式化 |
|
|
||||||
| `bun run schema:check` | 检查 `probe-config.schema.json` 是否与 TypeBox fragments 同步 |
|
|
||||||
| `bun run typecheck` | TypeScript 类型检查 |
|
|
||||||
| `bun test` | 运行所有测试 |
|
|
||||||
| `bun run check` | `schema:check + typecheck + lint + test` |
|
|
||||||
| `bun run verify` | `check + build` |
|
|
||||||
|
|
||||||
## ESLint
|
|
||||||
|
|
||||||
配置文件:`eslint.config.js`。
|
|
||||||
|
|
||||||
| 配置来源 | 用途 |
|
|
||||||
| -------------------------------------------- | ---------------------------------------- |
|
|
||||||
| `@eslint/js` recommended | JavaScript 基础规则 |
|
|
||||||
| `typescript-eslint` recommended-type-checked | TypeScript 类型感知规则 |
|
|
||||||
| `typescript-eslint` stylistic-type-checked | TypeScript 风格规则 |
|
|
||||||
| `eslint-plugin-perfectionist` | 导入语句和命名导出排序 |
|
|
||||||
| `eslint-plugin-import` | 导入路径验证、循环依赖检测、重复导入合并 |
|
|
||||||
| `eslint-plugin-prettier` | 将 Prettier 格式集成为 ESLint 规则 |
|
|
||||||
|
|
||||||
后端运行时代码禁止直接使用 `console.*`,请通过注入的 Logger 实例输出日志。
|
|
||||||
|
|
||||||
## Prettier
|
|
||||||
|
|
||||||
配置文件:`.prettierrc.json`。显式声明格式化参数,包括 `printWidth: 120`、`semi: true`、`singleQuote: false`、`trailingComma: "all"`、`endOfLine: "lf"`。
|
|
||||||
|
|
||||||
## TypeScript 严格标志
|
|
||||||
|
|
||||||
| 标志 | 值 | 说明 |
|
|
||||||
| ------------------------------------ | ----- | ------------------------------- |
|
|
||||||
| `strict` | true | 全局严格模式 |
|
|
||||||
| `noUnusedLocals` | true | 未使用局部变量视为错误 |
|
|
||||||
| `noUnusedParameters` | false | 保留关闭 |
|
|
||||||
| `noPropertyAccessFromIndexSignature` | true | 索引签名必须用括号访问 |
|
|
||||||
| `noUncheckedIndexedAccess` | true | 数组和 Map 访问必须运行时检查 |
|
|
||||||
| `noImplicitOverride` | true | 覆盖父类方法必须显式 `override` |
|
|
||||||
| `verbatimModuleSyntax` | true | 强制 `import type` 纯类型导入 |
|
|
||||||
|
|
||||||
## Git hooks
|
|
||||||
|
|
||||||
| Hook | 行为 |
|
|
||||||
| ------------ | ------------------------------------------ |
|
|
||||||
| `pre-commit` | lint-staged 对变更文件运行 eslint/prettier |
|
|
||||||
| `commit-msg` | commitlint 校验提交信息格式 |
|
|
||||||
|
|
||||||
提交信息格式为 `类型: 简短描述`,类型限定为 `feat`、`fix`、`refactor`、`docs`、`style`、`test`、`chore`。
|
|
||||||
|
|
||||||
## 测试分层
|
|
||||||
|
|
||||||
| 层级 | 覆盖范围 | 位置 |
|
|
||||||
| -------- | ---------------------- | ----------------------------------------------------------------------------- |
|
|
||||||
| 单元测试 | 后端函数、纯函数、常量 | `tests/server/**/*.test.ts`、`tests/web/{constants,utils,hooks}/**/*.test.ts` |
|
|
||||||
| 组件测试 | React 组件渲染和交互 | `tests/web/components/**/*.test.tsx` |
|
|
||||||
|
|
||||||
## 测试命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun test
|
|
||||||
bun test tests/server
|
|
||||||
bun test tests/web
|
|
||||||
bun run check
|
|
||||||
bun run verify
|
|
||||||
```
|
|
||||||
|
|
||||||
## 组件测试环境
|
|
||||||
|
|
||||||
组件测试使用 jsdom,配置位于 `tests/setup.ts`,通过 `bunfig.toml` preload 加载。
|
|
||||||
|
|
||||||
包含的 polyfill 和 mock:
|
|
||||||
|
|
||||||
- ResizeObserver
|
|
||||||
- IntersectionObserver
|
|
||||||
- matchMedia
|
|
||||||
- attachEvent
|
|
||||||
- recharts 图表 mock
|
|
||||||
|
|
||||||
## 编写规范
|
|
||||||
|
|
||||||
- 优先使用 `@testing-library/react` 的语义化查询。
|
|
||||||
- 测试用户行为而非实现细节。
|
|
||||||
- 只 mock 系统边界。
|
|
||||||
- 使用真实的 QueryClientProvider 包裹组件。
|
|
||||||
- 组件测试文件命名为 `tests/web/components/ComponentName.test.tsx`。
|
|
||||||
- 异步错误断言使用 helper 或显式 try/catch,避免依赖 Bun `expect(...).rejects` 与 `await-thenable` 规则的类型不匹配。
|
|
||||||
- polyfill 中的 intentional no-op 使用显式可解释写法。
|
|
||||||
- 对 `process.exit` 等系统 API 使用 `spyOn` 受控 mock。
|
|
||||||
35
docs/user/README.md
Normal file
35
docs/user/README.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# 用户文档
|
||||||
|
|
||||||
|
本文档是 DiAL 的用户使用入口,说明如何阅读配置、部署、expect 规则、故障排查和各 checker 参考。
|
||||||
|
|
||||||
|
适用场景:编写 YAML 配置、部署 DiAL、理解拨测结果、排查运行问题、查询某个 checker 的字段和示例。
|
||||||
|
|
||||||
|
## 文档索引
|
||||||
|
|
||||||
|
| 文档 | 内容 |
|
||||||
|
| ---------------------------------------- | ------------------------------------------------- |
|
||||||
|
| [configuration.md](configuration.md) | YAML 顶层结构、变量、server、targets 通用字段 |
|
||||||
|
| [deployment.md](deployment.md) | 生产构建、Docker、ICMP 权限、发布包运行方式 |
|
||||||
|
| [expectations.md](expectations.md) | expect 规则、状态判定、failure、observation |
|
||||||
|
| [troubleshooting.md](troubleshooting.md) | 配置校验、变量、ICMP、CMD、Docker、证书和正则问题 |
|
||||||
|
| [checkers/README.md](checkers/README.md) | 各 checker 的配置项、expect 字段和示例 |
|
||||||
|
|
||||||
|
## 按任务阅读
|
||||||
|
|
||||||
|
| 任务 | 建议阅读 |
|
||||||
|
| --------------------- | ---------------------------------------------------------------------- |
|
||||||
|
| 首次运行 | [项目快速开始](../../README.md#快速开始)、[配置文件](configuration.md) |
|
||||||
|
| 编写配置 | [配置文件](configuration.md)、[Checker 参考](checkers/README.md) |
|
||||||
|
| 编写 expect | [校验规则](expectations.md)、对应 checker 文档 |
|
||||||
|
| 容器或生产部署 | [部署](deployment.md)、[故障排查](troubleshooting.md) |
|
||||||
|
| 排查启动或运行问题 | [故障排查](troubleshooting.md)、相关 checker 文档 |
|
||||||
|
| 查询 checker 专属字段 | [Checker 参考](checkers/README.md) |
|
||||||
|
|
||||||
|
## 用户文档更新规则
|
||||||
|
|
||||||
|
- 配置结构、变量、server、probes、targets 通用字段变化时,更新 [configuration.md](configuration.md)。
|
||||||
|
- checker 配置项、expect 字段、示例或运行行为变化时,更新 `checkers/<type>.md` 和 [checkers/README.md](checkers/README.md)。
|
||||||
|
- expect 模型、状态判定、failure、observation 或快速失败顺序变化时,更新 [expectations.md](expectations.md)。
|
||||||
|
- 构建产物运行方式、Docker 参数、镜像内置依赖、发布包结构变化时,更新 [deployment.md](deployment.md)。
|
||||||
|
- 常见错误、运行依赖、权限、证书或配置校验排查方式变化时,更新 [troubleshooting.md](troubleshooting.md)。
|
||||||
|
- 用户文档只解释“如何使用”和“用户能观察到什么”,实现细节放入 [`../development/`](../development/README.md)。
|
||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
Checker 是 DiAL 的拨测执行单元。每个 target 通过 `type` 选择一个 checker,并配置对应的专属字段和 `expect` 规则。
|
Checker 是 DiAL 的拨测执行单元。每个 target 通过 `type` 选择一个 checker,并配置对应的专属字段和 `expect` 规则。
|
||||||
|
|
||||||
|
适用场景:查询 checker 类型选择、专属配置、expect 字段、示例和各 checker 文档入口。
|
||||||
|
|
||||||
## 支持的类型
|
## 支持的类型
|
||||||
|
|
||||||
| 类型 | 用途 | 文档 |
|
| 类型 | 用途 | 文档 |
|
||||||
| ------ | -------------------------------------- | --------------- |
|
| -------- | -------------------------------------- | ------------------- |
|
||||||
| `http` | HTTP/HTTPS 应用层健康检查 | [HTTP](http.md) |
|
| `http` | HTTP/HTTPS 应用层健康检查 | [HTTP](http.md) |
|
||||||
| `cmd` | 执行本地命令或脚本 | [Cmd](cmd.md) |
|
| `cmd` | 执行本地命令或脚本 | [Cmd](cmd.md) |
|
||||||
| `db` | PostgreSQL/MySQL/SQLite 连接和查询检查 | [DB](db.md) |
|
| `db` | PostgreSQL/MySQL/SQLite 连接和查询检查 | [DB](db.md) |
|
||||||
@@ -14,6 +16,9 @@ Checker 是 DiAL 的拨测执行单元。每个 target 通过 `type` 选择一
|
|||||||
| `icmp` | 基于系统 `ping` 的存活、延迟、丢包检查 | [ICMP](icmp.md) |
|
| `icmp` | 基于系统 `ping` 的存活、延迟、丢包检查 | [ICMP](icmp.md) |
|
||||||
| `dns` | 本机解析或指定 DNS server 协议级检查 | [DNS](dns.md) |
|
| `dns` | 本机解析或指定 DNS server 协议级检查 | [DNS](dns.md) |
|
||||||
| `llm` | 大模型服务应用层健康检查 | [LLM](llm.md) |
|
| `llm` | 大模型服务应用层健康检查 | [LLM](llm.md) |
|
||||||
|
| `ws` | WebSocket 可达性和消息交互检查 | [WS](ws.md) |
|
||||||
|
| `cpu` | 本机 CPU 使用率健康检查 | [CPU](cpu.md) |
|
||||||
|
| `memory` | 本机系统内存使用状况检查 | [Memory](memory.md) |
|
||||||
|
|
||||||
## 选择建议
|
## 选择建议
|
||||||
|
|
||||||
@@ -27,6 +32,9 @@ Checker 是 DiAL 的拨测执行单元。每个 target 通过 `type` 选择一
|
|||||||
| 主机可达性、延迟、丢包率 | `icmp` |
|
| 主机可达性、延迟、丢包率 | `icmp` |
|
||||||
| 域名解析值、DNS RCODE、TTL、flags | `dns` |
|
| 域名解析值、DNS RCODE、TTL、flags | `dns` |
|
||||||
| LLM API 是否可用、输出是否符合预期 | `llm` |
|
| LLM API 是否可用、输出是否符合预期 | `llm` |
|
||||||
|
| WebSocket 可达性或消息交互验证 | `ws` |
|
||||||
|
| 本机 CPU 使用率健康检查 | `cpu` |
|
||||||
|
| 本机系统内存使用状况检查 | `memory` |
|
||||||
|
|
||||||
## 通用字段
|
## 通用字段
|
||||||
|
|
||||||
@@ -35,3 +43,7 @@ Checker 是 DiAL 的拨测执行单元。每个 target 通过 `type` 选择一
|
|||||||
## 通用断言模型
|
## 通用断言模型
|
||||||
|
|
||||||
各 checker 的 `expect` 字段复用 `ValueMatcher`、`ContentExpectations` 和 `KeyedExpectations`。详情见 [校验规则](../expectations.md)。
|
各 checker 的 `expect` 字段复用 `ValueMatcher`、`ContentExpectations` 和 `KeyedExpectations`。详情见 [校验规则](../expectations.md)。
|
||||||
|
|
||||||
|
## 更新触发条件
|
||||||
|
|
||||||
|
新增、移除或修改 checker 类型、用途、选择建议、通用字段或通用断言模型时,必须更新本文档。checker 专属字段变化还必须同步更新对应 `checkers/<type>.md`。
|
||||||
|
|||||||
74
docs/user/checkers/cpu.md
Normal file
74
docs/user/checkers/cpu.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# CPU Checker
|
||||||
|
|
||||||
|
`type: cpu` 用于检查本机 CPU 使用率,基于两次系统快照计算总体和每核心的忙碌比例。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| -------------------- | -------------------------------- | ---- | ------- |
|
||||||
|
| `cpu.sampleDuration` | CPU 采样窗口,支持时长格式 | 否 | `1s` |
|
||||||
|
| `cpu.includePerCore` | 是否在结果中输出每核心使用率数组 | 否 | `false` |
|
||||||
|
|
||||||
|
`sampleDuration` 必须小于 target 的 `timeout`。
|
||||||
|
|
||||||
|
## expect 校验项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| --------------------- | ----------------------------------------------------------------------------------------------- | ---- | ------ |
|
||||||
|
| `usagePercent` | 总体 CPU 使用率,范围 `0-100`,使用 `ValueMatcher` | 否 | 无 |
|
||||||
|
| `idlePercent` | 总体 CPU 空闲率,与 `usagePercent` 互补,两者之和恒为 100(`idlePercent + usagePercent = 100`) | 否 | 无 |
|
||||||
|
| `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 字段、行为或语义时,必须更新本文档。
|
||||||
119
docs/user/checkers/mem.md
Normal file
119
docs/user/checkers/mem.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# Mem Checker
|
||||||
|
|
||||||
|
`type: mem` 用于检查本机系统级内存使用状况,包括物理内存和交换空间的使用率及字节数。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
Mem checker 配置为空对象,无需额外参数:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mem: {}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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: mem
|
||||||
|
interval: "30s"
|
||||||
|
timeout: "5s"
|
||||||
|
mem: {}
|
||||||
|
expect:
|
||||||
|
usagePercent:
|
||||||
|
lte: 85
|
||||||
|
```
|
||||||
|
|
||||||
|
检查可用内存不低于 4GB:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: "local-memory-available"
|
||||||
|
name: "可用内存检查"
|
||||||
|
type: mem
|
||||||
|
mem: {}
|
||||||
|
expect:
|
||||||
|
availableBytes:
|
||||||
|
gte: "4GB"
|
||||||
|
```
|
||||||
|
|
||||||
|
同时检查内存和交换空间:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: "local-memory-swap"
|
||||||
|
name: "内存和交换空间"
|
||||||
|
type: mem
|
||||||
|
mem: {}
|
||||||
|
expect:
|
||||||
|
usagePercent:
|
||||||
|
lte: 80
|
||||||
|
swapUsagePercent:
|
||||||
|
lte: 50
|
||||||
|
```
|
||||||
|
|
||||||
|
## 语义说明
|
||||||
|
|
||||||
|
Mem 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`。
|
||||||
|
|
||||||
|
Mem checker 是即时读取(非采样),无需 `sampleDuration`,执行速度远快于 CPU checker。虽然读取本身很快,但仍受 target `timeout` 约束——若底层系统调用悬挂或阻塞超过 `timeout`,checker 会返回 `mem/timeout` failure。
|
||||||
|
|
||||||
|
## 跨平台注意事项
|
||||||
|
|
||||||
|
- Windows 环境依赖 PowerShell 5+ 获取部分内存指标
|
||||||
|
- `buffcacheBytes` 在非 Linux 平台上可能返回 `null`
|
||||||
|
- 容器环境中内存数据可能不反映 cgroup 内存限制
|
||||||
|
|
||||||
|
## 不支持的功能
|
||||||
|
|
||||||
|
- 进程级内存使用(如 RSS、VSZ)
|
||||||
|
- cgroup/container 内存限制精度
|
||||||
|
- 内存趋势采样和历史记录
|
||||||
|
- 内存条物理布局信息
|
||||||
|
- 详细内存分类(slab、reclaimable、dirty 等)作为 expect 字段
|
||||||
|
|
||||||
|
## 更新触发条件
|
||||||
|
|
||||||
|
修改 Mem checker 配置、expect 字段、行为或语义时,必须更新本文档。
|
||||||
81
docs/user/checkers/ws.md
Normal file
81
docs/user/checkers/ws.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# WS Checker
|
||||||
|
|
||||||
|
`type: ws` 用于 WebSocket 服务可达性检查和消息交互验证。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| -------------------- | ---------------------------------------------- | ---- | ------- |
|
||||||
|
| `ws.url` | 目标 URL,必须以 `ws://` 或 `wss://` 开头 | 是 | 无 |
|
||||||
|
| `ws.headers` | 握手 HTTP 头 | 否 | `{}` |
|
||||||
|
| `ws.subprotocols` | 子协议协商 | 否 | `[]` |
|
||||||
|
| `ws.ignoreSSL` | 忽略 TLS 证书校验 | 否 | `false` |
|
||||||
|
| `ws.send` | 发送的 text 消息,配置后进入请求-响应模式 | 否 | 无 |
|
||||||
|
| `ws.receiveTimeout` | 等待响应超时,毫秒 | 否 | `5000` |
|
||||||
|
| `ws.maxMessageBytes` | 单条消息最大字节数,支持 `KB`、`MB`、`GB` 单位 | 否 | `4KB` |
|
||||||
|
|
||||||
|
## expect 校验项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| ------------------ | --------------------------------------------------------------------- | ---- | ------ |
|
||||||
|
| `connected` | 期望连接结果,`true` 可达或 `false` 期望不可达 | 否 | `true` |
|
||||||
|
| `handshakeHeaders` | 握手响应头校验,使用 `KeyedExpectations` | 否 | 无 |
|
||||||
|
| `message` | 收到的消息内容校验,使用 `ContentExpectations` 数组,需配置 `ws.send` | 否 | 无 |
|
||||||
|
| `connectTimeMs` | 连接建立耗时校验,使用 `ValueMatcher` | 否 | 无 |
|
||||||
|
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
|
||||||
|
|
||||||
|
## 两种模式
|
||||||
|
|
||||||
|
不配置 `ws.send` 时只做可达性检查(连接后立即关闭),配置 `ws.send` 后进入请求-响应模式(发送消息并等待首条响应)。
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
可达性检查:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: "ws-reachability"
|
||||||
|
name: "WebSocket 服务可达"
|
||||||
|
type: ws
|
||||||
|
ws:
|
||||||
|
url: "wss://api.example.com/ws"
|
||||||
|
expect:
|
||||||
|
durationMs:
|
||||||
|
lte: 3000
|
||||||
|
```
|
||||||
|
|
||||||
|
带鉴权的请求-响应:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: "ws-echo"
|
||||||
|
name: "WebSocket Echo 检查"
|
||||||
|
type: ws
|
||||||
|
ws:
|
||||||
|
url: "wss://echo.example.com/ws"
|
||||||
|
headers:
|
||||||
|
Authorization: "Bearer ${TOKEN}"
|
||||||
|
subprotocols: ["json"]
|
||||||
|
send: '{"action":"ping"}'
|
||||||
|
receiveTimeout: 3000
|
||||||
|
expect:
|
||||||
|
handshakeHeaders:
|
||||||
|
Sec-WebSocket-Protocol:
|
||||||
|
equals: "json"
|
||||||
|
message:
|
||||||
|
- json:
|
||||||
|
path: "$.action"
|
||||||
|
equals: "pong"
|
||||||
|
durationMs:
|
||||||
|
lte: 5000
|
||||||
|
```
|
||||||
|
|
||||||
|
期望不可达:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: "ws-internal-down"
|
||||||
|
name: "内部服务已下线"
|
||||||
|
type: ws
|
||||||
|
ws:
|
||||||
|
url: "ws://internal.monitor:9443/ws"
|
||||||
|
expect:
|
||||||
|
connected: false
|
||||||
|
```
|
||||||
@@ -80,10 +80,10 @@ targets:
|
|||||||
|
|
||||||
## 内置默认值
|
## 内置默认值
|
||||||
|
|
||||||
| 字段 | 默认值 |
|
| 字段 | 默认值 | 约束 |
|
||||||
| ---------- | ------ |
|
| ---------- | ------ | ----------------------- |
|
||||||
| `interval` | `30s` |
|
| `interval` | `30s` | 最小 `10s` |
|
||||||
| `timeout` | `10s` |
|
| `timeout` | `10s` | 必须小于等于 `interval` |
|
||||||
|
|
||||||
各 checker 专属默认值见 [Checker 参考](checkers/README.md)。
|
各 checker 专属默认值见 [Checker 参考](checkers/README.md)。
|
||||||
|
|
||||||
@@ -92,10 +92,10 @@ targets:
|
|||||||
`variables` 是顶层动态键值表,key 必须符合 `[a-zA-Z_][a-zA-Z0-9_]*`,value 仅支持 string、number、boolean。`server`、`probes` 和 `targets` 中的字符串值可引用变量。
|
`variables` 是顶层动态键值表,key 必须符合 `[a-zA-Z_][a-zA-Z0-9_]*`,value 仅支持 string、number、boolean。`server`、`probes` 和 `targets` 中的字符串值可引用变量。
|
||||||
|
|
||||||
| 语法 | 说明 |
|
| 语法 | 说明 |
|
||||||
| --------- | ------------------------- | ------------------------------------------ |
|
| ----------------- | ------------------------------------------ |
|
||||||
| `${key}` | 引用 variables 或环境变量 |
|
| `${key}` | 引用 variables 或环境变量 |
|
||||||
| `${key | default}` | variables 和环境变量都不存在时使用默认值 |
|
| `${key\|default}` | variables 和环境变量都不存在时使用默认值 |
|
||||||
| `${key | }` | variables 和环境变量都不存在时使用空字符串 |
|
| `${key\|}` | variables 和环境变量都不存在时使用空字符串 |
|
||||||
| `$${key}` | 转义输出字面量 `${key}` |
|
| `$${key}` | 转义输出字面量 `${key}` |
|
||||||
|
|
||||||
解析优先级为 `variables -> process.env -> 默认值`。三者均不存在时配置校验失败。字段值完整等于单个变量引用时会保留 number、boolean、string 类型;部分拼接时统一转为字符串。
|
解析优先级为 `variables -> process.env -> 默认值`。三者均不存在时配置校验失败。字段值完整等于单个变量引用时会保留 number、boolean、string 类型;部分拼接时统一转为字符串。
|
||||||
@@ -121,10 +121,10 @@ targets:
|
|||||||
| `id` | 目标唯一标识,最长 30 字符,支持字母数字、下划线、连字符,不参与变量替换 | 是 | 无 |
|
| `id` | 目标唯一标识,最长 30 字符,支持字母数字、下划线、连字符,不参与变量替换 | 是 | 无 |
|
||||||
| `name` | 展示名称,最长 30 字符,支持变量替换,可省略或显式 null;前端展示时 null 回退到 `id` | 否 | 无 |
|
| `name` | 展示名称,最长 30 字符,支持变量替换,可省略或显式 null;前端展示时 null 回退到 `id` | 否 | 无 |
|
||||||
| `description` | 目标描述,最长 500 字符,支持变量替换,可省略或显式 null,允许空字符串 | 否 | 无 |
|
| `description` | 目标描述,最长 500 字符,支持变量替换,可省略或显式 null,允许空字符串 | 否 | 无 |
|
||||||
| `type` | 目标类型:`http`、`cmd`、`db`、`tcp`、`udp`、`dns`、`icmp`、`llm` | 是 | 无 |
|
| `type` | 目标类型:`http`、`cmd`、`db`、`tcp`、`udp`、`dns`、`icmp`、`llm`、`ws`、`cpu` | 是 | 无 |
|
||||||
| `group` | 分组名称 | 否 | `default` |
|
| `group` | 分组名称 | 否 | `default` |
|
||||||
| `interval` | 拨测间隔 | 否 | `30s` |
|
| `interval` | 拨测间隔,最小 `10s` | 否 | `30s` |
|
||||||
| `timeout` | 超时时间 | 否 | `10s` |
|
| `timeout` | 超时时间,必须小于等于 `interval` | 否 | `10s` |
|
||||||
|
|
||||||
## Checker 专属配置
|
## Checker 专属配置
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,62 @@
|
|||||||
# 校验规则
|
# 校验规则
|
||||||
|
|
||||||
|
本文档说明 `expect` 规则、状态判定、failure、observation 和各 checker 的快速失败顺序。
|
||||||
|
|
||||||
|
适用场景:编写 `expect`、理解 UP/DOWN、排查 mismatch/error、查看返回结果中的 `failure` 和 `observation`。
|
||||||
|
|
||||||
`expect` 描述拨测结果必须满足的条件。不同 checker 暴露不同字段,但共享三类基础断言模型:`ValueMatcher`、`ContentExpectations` 和 `KeyedExpectations`。
|
`expect` 描述拨测结果必须满足的条件。不同 checker 暴露不同字段,但共享三类基础断言模型:`ValueMatcher`、`ContentExpectations` 和 `KeyedExpectations`。
|
||||||
|
|
||||||
|
## 状态判定
|
||||||
|
|
||||||
|
DiAL 使用单层状态模型。
|
||||||
|
|
||||||
|
| 状态 | 含义 |
|
||||||
|
| ------ | ---------------------------------------- |
|
||||||
|
| `UP` | 拨测结果符合 `expect` 规则 |
|
||||||
|
| `DOWN` | 拨测结果不符合 `expect` 规则,或执行失败 |
|
||||||
|
|
||||||
|
执行失败(网络错误、超时、进程崩溃)和 expect 不匹配都统一为 `DOWN`,通过 `failure.kind` 区分原因。
|
||||||
|
|
||||||
|
| `failure.kind` | 含义 |
|
||||||
|
| -------------- | ---------------------------------------- |
|
||||||
|
| `error` | 网络、超时、进程、协议解析或内部执行错误 |
|
||||||
|
| `mismatch` | 拨测完成,但结果不满足 expect |
|
||||||
|
|
||||||
|
## API 结果字段
|
||||||
|
|
||||||
|
API 返回的检查结果包含 `detail` 和 `observation`。
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
| ------------- | ------------------------------------------------------------ |
|
||||||
|
| `detail` | 后端按 checker 类型从结构化 observation 动态生成的人可读摘要 |
|
||||||
|
| `observation` | 保存该次检查的结构化观测数据 |
|
||||||
|
| `failure` | 保存首个错误或不匹配原因 |
|
||||||
|
| `matched` | 是否符合 expect |
|
||||||
|
| `durationMs` | 本次检查耗时 |
|
||||||
|
| `timestamp` | 本次检查时间 |
|
||||||
|
|
||||||
|
`detail` 不写入 SQLite。存储层仅持久化 `observation` JSON、`failure` JSON、匹配状态、耗时和时间戳。
|
||||||
|
|
||||||
|
## observation 示例
|
||||||
|
|
||||||
|
不同 checker 的 observation 字段不同,常见信息包括:
|
||||||
|
|
||||||
|
| Checker | observation 内容示例 |
|
||||||
|
| ------- | ------------------------------------------------------------------ |
|
||||||
|
| HTTP | 状态码、响应头、按需读取的 body 预览 |
|
||||||
|
| Cmd | exit code、stdout/stderr 预览 |
|
||||||
|
| TCP | 连接结果、banner 摘要 |
|
||||||
|
| UDP | 响应内容、来源地址、响应大小 |
|
||||||
|
| ICMP | 存活结果、丢包率、平均延迟、最大延迟 |
|
||||||
|
| DNS | RCODE、记录值、TTL、flags、CNAME 链 |
|
||||||
|
| LLM | HTTP 状态、模型输出、finish reason、token usage、流式首 token 时间 |
|
||||||
|
| WS | 连接结果、连接耗时、握手头、消息内容、消息大小 |
|
||||||
|
|
||||||
|
Dashboard 基于存储的检查结果计算实时状态、可用率、耗时趋势、P95、状态条和故障段等指标。指标语义由后端应用层实现,SQLite 主要负责存储、筛选、排序、分页和基础聚合。
|
||||||
|
|
||||||
## ContentExpectations
|
## ContentExpectations
|
||||||
|
|
||||||
`body`、`stdout`、`stderr`、`banner`、`response`、`output`、`result` 等返回内容字段均使用数组。
|
`body`、`stdout`、`stderr`、`banner`、`response`、`output`、`result`、`message` 等返回内容字段均使用数组。
|
||||||
|
|
||||||
| 规则 | 说明 |
|
| 规则 | 说明 |
|
||||||
| ---------- | ------------------------------------------------------ |
|
| ---------- | ------------------------------------------------------ |
|
||||||
@@ -88,6 +140,7 @@ expect:
|
|||||||
| DNS server | `responded -> rcode -> values -> valueCount -> answerCount -> ttlMin -> ttlMax -> authoritative -> recursionAvailable -> truncated -> authenticatedData -> result -> durationMs` |
|
| DNS server | `responded -> rcode -> values -> valueCount -> answerCount -> ttlMin -> ttlMax -> authoritative -> recursionAvailable -> truncated -> authenticatedData -> result -> durationMs` |
|
||||||
| LLM http | `status -> headers -> output -> finishReason -> rawFinishReason -> usage -> durationMs` |
|
| LLM http | `status -> headers -> output -> finishReason -> rawFinishReason -> usage -> durationMs` |
|
||||||
| LLM stream | `status -> headers -> stream.completed -> stream.firstTokenMs -> output -> finishReason -> rawFinishReason -> usage -> durationMs` |
|
| LLM stream | `status -> headers -> stream.completed -> stream.firstTokenMs -> output -> finishReason -> rawFinishReason -> usage -> durationMs` |
|
||||||
|
| WS | `connected -> handshakeHeaders -> message -> connectTimeMs -> durationMs` |
|
||||||
|
|
||||||
## JSON Schema
|
## JSON Schema
|
||||||
|
|
||||||
@@ -102,3 +155,7 @@ expect:
|
|||||||
旧字段 `maxDurationMs`、`maxPacketLoss`、`maxAvgLatencyMs`、`maxMaxLatencyMs` 和旧正则字段 `match` 已移除,请分别改用 `durationMs`、ICMP matcher 字段和 `regex`。
|
旧字段 `maxDurationMs`、`maxPacketLoss`、`maxAvgLatencyMs`、`maxMaxLatencyMs` 和旧正则字段 `match` 已移除,请分别改用 `durationMs`、ICMP matcher 字段和 `regex`。
|
||||||
|
|
||||||
非法配置会阻止启动并输出错误信息。除动态键值表(`headers`、`env`、`variables`)外,未知字段会导致启动失败,请使用 YAML 注释表达说明。
|
非法配置会阻止启动并输出错误信息。除动态键值表(`headers`、`env`、`variables`)外,未知字段会导致启动失败,请使用 YAML 注释表达说明。
|
||||||
|
|
||||||
|
## 更新触发条件
|
||||||
|
|
||||||
|
修改 expect 断言模型、状态判定、failure 字段、observation 字段、快速失败顺序或已移除字段说明时,必须更新本文档。
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
# 目标状态判定
|
|
||||||
|
|
||||||
DiAL 使用单层状态模型。
|
|
||||||
|
|
||||||
| 状态 | 含义 |
|
|
||||||
| ------ | ---------------------------------------- |
|
|
||||||
| `UP` | 拨测结果符合 `expect` 规则 |
|
|
||||||
| `DOWN` | 拨测结果不符合 `expect` 规则,或执行失败 |
|
|
||||||
|
|
||||||
执行失败(网络错误、超时、进程崩溃)和 expect 不匹配都统一为 `DOWN`,通过 `failure.kind` 区分原因。
|
|
||||||
|
|
||||||
| `failure.kind` | 含义 |
|
|
||||||
| -------------- | ---------------------------------------- |
|
|
||||||
| `error` | 网络、超时、进程、协议解析或内部执行错误 |
|
|
||||||
| `mismatch` | 拨测完成,但结果不满足 expect |
|
|
||||||
|
|
||||||
## API 结果字段
|
|
||||||
|
|
||||||
API 返回的检查结果包含 `detail` 和 `observation`。
|
|
||||||
|
|
||||||
| 字段 | 说明 |
|
|
||||||
| ------------- | ------------------------------------------------------------ |
|
|
||||||
| `detail` | 后端按 checker 类型从结构化 observation 动态生成的人可读摘要 |
|
|
||||||
| `observation` | 保存该次检查的结构化观测数据 |
|
|
||||||
| `failure` | 保存首个错误或不匹配原因 |
|
|
||||||
| `matched` | 是否符合 expect |
|
|
||||||
| `durationMs` | 本次检查耗时 |
|
|
||||||
| `timestamp` | 本次检查时间 |
|
|
||||||
|
|
||||||
`detail` 不写入 SQLite。存储层仅持久化 `observation` JSON、`failure` JSON、匹配状态、耗时和时间戳。
|
|
||||||
|
|
||||||
## observation 示例
|
|
||||||
|
|
||||||
不同 checker 的 observation 字段不同,常见信息包括:
|
|
||||||
|
|
||||||
| Checker | observation 内容示例 |
|
|
||||||
| ------- | ------------------------------------------------------------------ |
|
|
||||||
| HTTP | 状态码、响应头、按需读取的 body 预览 |
|
|
||||||
| Cmd | exit code、stdout/stderr 预览 |
|
|
||||||
| TCP | 连接结果、banner 摘要 |
|
|
||||||
| UDP | 响应内容、来源地址、响应大小 |
|
|
||||||
| ICMP | 存活结果、丢包率、平均延迟、最大延迟 |
|
|
||||||
| DNS | RCODE、记录值、TTL、flags、CNAME 链 |
|
|
||||||
| LLM | HTTP 状态、模型输出、finish reason、token usage、流式首 token 时间 |
|
|
||||||
|
|
||||||
## 趋势与统计
|
|
||||||
|
|
||||||
Dashboard 基于存储的检查结果计算实时状态、可用率、耗时趋势、P95、状态条和故障段等指标。指标语义由后端应用层实现,SQLite 主要负责存储、筛选、排序、分页和基础聚合。
|
|
||||||
@@ -20,10 +20,10 @@ DiAL 启动时会校验 YAML 配置。除动态键值表(`headers`、`env`、`
|
|||||||
常见修复:
|
常见修复:
|
||||||
|
|
||||||
| 问题 | 修复 |
|
| 问题 | 修复 |
|
||||||
| -------------- | ----------------------------------- | --------- |
|
| -------------- | ----------------------------------- |
|
||||||
| 环境变量未设置 | 设置环境变量或在 `variables` 中声明 |
|
| 环境变量未设置 | 设置环境变量或在 `variables` 中声明 |
|
||||||
| 希望允许空值 | 使用 `${key | }` |
|
| 希望允许空值 | 使用 `${key\|}` |
|
||||||
| 希望提供默认值 | 使用 `${key | default}` |
|
| 希望提供默认值 | 使用 `${key\|default}` |
|
||||||
| 希望输出字面量 | 使用 `$${key}` |
|
| 希望输出字面量 | 使用 `$${key}` |
|
||||||
|
|
||||||
## ICMP checker 无法运行
|
## ICMP checker 无法运行
|
||||||
|
|||||||
@@ -3,20 +3,21 @@ schema: fast-drive
|
|||||||
context: |
|
context: |
|
||||||
- 使用中文(注释、文档、交流),面向中文开发者
|
- 使用中文(注释、文档、交流),面向中文开发者
|
||||||
- openspec文档的关键字按openspec规范使用,不要翻译为中文
|
- openspec文档的关键字按openspec规范使用,不要翻译为中文
|
||||||
|
- 本项目openspec使用fast-drive自定义schema,变更文档只包含design.md和tasks.md,无proposal.md和specs
|
||||||
- **优先阅读docs/README.md**判断文档归属和本次任务需要读取的专题文档
|
- **优先阅读docs/README.md**判断文档归属和本次任务需要读取的专题文档
|
||||||
- README.md用于项目概览、快速开始和用户入口;DEVELOPMENT.md用于开发入口、全局规则和质量门禁;CONTRIBUTING.md仅在新增或修改checker、贡献流程时必读
|
- README.md用于项目概览、快速开始和顶层文档引导;docs/user/README.md用于用户使用入口;docs/development/README.md用于开发入口、全局规则和质量门禁
|
||||||
- 所有代码风格、命名、注解、依赖、API等开发规范以DEVELOPMENT.md和docs/development/下对应专题文档为准
|
- 所有代码风格、命名、注解、依赖、API等开发规范以docs/development/README.md和docs/development/下对应专题文档为准
|
||||||
- 新增或修改checker时必须阅读CONTRIBUTING.md和docs/development/checker-development.md
|
- 新增或修改checker时必须阅读docs/development/checker.md、docs/user/checkers/README.md和相近checker用户文档
|
||||||
- 每次代码变更都必须执行文档影响分析:判断是否影响用户可见行为、配置格式、checker行为、expect规则、API、部署方式、开发流程、架构边界、测试规范或构建发布流程
|
- 每次代码变更都必须执行文档影响分析:判断是否影响用户可见行为、配置格式、checker行为、expect规则、API、部署方式、开发流程、架构边界、测试规范或构建发布流程
|
||||||
- 若影响用户使用方式、配置格式、checker行为、expect规则、部署方式或运行行为,必须同步更新docs/user/下对应文档;README.md仅在项目定位、快速开始、核心能力列表或文档导航变化时更新
|
- 若影响用户使用方式、配置格式、checker行为、expect规则、部署方式或运行行为,必须同步更新docs/user/下对应文档;README.md仅在项目定位、快速开始、核心能力列表或文档导航变化时更新
|
||||||
- 若影响开发流程、架构边界、质量门禁、测试规范、构建发布流程或checker开发机制,必须同步更新DEVELOPMENT.md、CONTRIBUTING.md或docs/development/下对应文档
|
- 若影响开发流程、架构边界、质量门禁、测试规范、构建发布流程或checker开发机制,必须同步更新docs/development/README.md或docs/development/下对应专题文档
|
||||||
- 若影响文档同步规则或文档归属矩阵,必须同步更新docs/README.md和openspec/config.yaml
|
- 若影响文档同步规则或文档归属矩阵,必须同步更新docs/README.md和openspec/config.yaml
|
||||||
- 若无需更新文档,必须在收尾说明中说明原因
|
- 若无需更新文档,必须在收尾说明中说明原因
|
||||||
- 新增代码优先复用已有组件、工具、依赖库,不引入新依赖
|
- 新增代码优先复用已有组件、工具、依赖库,不引入新依赖
|
||||||
- 新增的逻辑必须编写完善的测试,并保证测试的正确性,不允许跳过任何测试
|
- 新增的逻辑必须编写完善的测试,并保证测试的正确性,不允许跳过任何测试
|
||||||
- 这是基于bun实现的前端后一体化项目,使用bun作为唯一包管理器,严禁使用pnpm、npm,使用bunx运行工具,严禁使用npx、pnpx
|
- 这是基于bun实现的前端后一体化项目,使用bun作为唯一包管理器,严禁使用pnpm、npm,使用bunx运行工具,严禁使用npx、pnpx
|
||||||
- src/server目录下是基于bun实现的后端代码
|
- src/server目录下是基于bun实现的后端代码
|
||||||
- 后端库使用优先级:Bun 内置 API > es-toolkit > 主流三方库 > 项目公共工具 > 自行实现
|
- 后端库使用优先级:Bun 内置 API > es-toolkit > 标准 Web API > 主流三方库 > 项目公共工具 > 自行实现
|
||||||
- src/web目录下是基于Bun HTML import、React、TDesign实现的前端代码
|
- src/web目录下是基于Bun HTML import、React、TDesign实现的前端代码
|
||||||
- 前端样式开发优先级:TDesign组件 > 组件props > TDesign CSS tokens(--td-*) > styles.css CSS类 > 自行开发组件
|
- 前端样式开发优先级:TDesign组件 > 组件props > TDesign CSS tokens(--td-*) > styles.css CSS类 > 自行开发组件
|
||||||
- 前端严禁:组件内联style属性、CSS覆盖TD内部类名、使用!important、硬编码色值
|
- 前端严禁:组件内联style属性、CSS覆盖TD内部类名、使用!important、硬编码色值
|
||||||
@@ -27,8 +28,6 @@ context: |
|
|||||||
- (当前项目未上线,不需要考虑向前兼容)
|
- (当前项目未上线,不需要考虑向前兼容)
|
||||||
|
|
||||||
rules:
|
rules:
|
||||||
explore:
|
|
||||||
- 本项目openspec使用fast-drive自定义schema,变更文档只包含design.md和tasks.md,无proposal.md和specs
|
|
||||||
design:
|
design:
|
||||||
- 先前的讨论技术方案要尽可能体现在设计文档中,便于指导实现阶段不偏离已定的技术路线
|
- 先前的讨论技术方案要尽可能体现在设计文档中,便于指导实现阶段不偏离已定的技术路线
|
||||||
tasks:
|
tasks:
|
||||||
@@ -36,5 +35,5 @@ rules:
|
|||||||
- 如果是代码存在更新必须
|
- 如果是代码存在更新必须
|
||||||
- 执行完整的测试、代码检查、格式检查等质量保障手段
|
- 执行完整的测试、代码检查、格式检查等质量保障手段
|
||||||
- 执行文档影响分析,并按影响范围更新对应文档;若无需更新文档,必须在任务或收尾说明中明确写出原因
|
- 执行文档影响分析,并按影响范围更新对应文档;若无需更新文档,必须在任务或收尾说明中明确写出原因
|
||||||
- 新增或修改checker时必须更新docs/user/checkers/下对应用户文档,并在checker开发机制变化时更新CONTRIBUTING.md或docs/development/checker-development.md
|
- 新增或修改checker时必须更新docs/user/checkers/下对应用户文档,并在checker开发机制变化时更新docs/development/checker.md
|
||||||
- 新增或修改配置字段时必须更新probe-config.schema.json、probes.example.yaml、docs/user/configuration.md或对应checker文档
|
- 新增或修改配置字段时必须更新probe-config.schema.json、probes.example.yaml、docs/user/configuration.md或对应checker文档
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
"ai": "^6",
|
"ai": "^6",
|
||||||
"ajv": "^8.20.0",
|
"ajv": "^8.20.0",
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
|
"croner": "^10.0.1",
|
||||||
"es-toolkit": "^1.46.1",
|
"es-toolkit": "^1.46.1",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
@@ -70,6 +71,7 @@
|
|||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
|
"systeminformation": "^5.31.6",
|
||||||
"tdesign-icons-react": "^0.6.4",
|
"tdesign-icons-react": "^0.6.4",
|
||||||
"tdesign-react": "^1.16.9",
|
"tdesign-react": "^1.16.9",
|
||||||
"xpath": "^0.0.34"
|
"xpath": "^0.0.34"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -327,3 +327,54 @@ targets:
|
|||||||
finishReason: "stop"
|
finishReason: "stop"
|
||||||
output:
|
output:
|
||||||
- contains: "OK"
|
- contains: "OK"
|
||||||
|
|
||||||
|
# ========== WS targets ==========
|
||||||
|
|
||||||
|
- id: "ws-reachability"
|
||||||
|
name: "WebSocket 服务可达"
|
||||||
|
type: ws
|
||||||
|
group: "基础设施"
|
||||||
|
ws:
|
||||||
|
url: "wss://echo.websocket.org"
|
||||||
|
expect:
|
||||||
|
durationMs:
|
||||||
|
lte: 5000
|
||||||
|
|
||||||
|
- id: "ws-echo-check"
|
||||||
|
name: "WebSocket Echo 交互检查"
|
||||||
|
type: ws
|
||||||
|
group: "基础设施"
|
||||||
|
ws:
|
||||||
|
url: "wss://echo.websocket.org"
|
||||||
|
send: "hello"
|
||||||
|
receiveTimeout: 3000
|
||||||
|
expect:
|
||||||
|
message:
|
||||||
|
- 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: mem
|
||||||
|
group: "基础设施"
|
||||||
|
interval: "30s"
|
||||||
|
timeout: "5s"
|
||||||
|
mem: {}
|
||||||
|
expect:
|
||||||
|
usagePercent:
|
||||||
|
lte: 85
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ const DEFAULT_ROTATION_SIZE = "50MB";
|
|||||||
const DEFAULT_ROTATION_FREQUENCY: RotationFrequency = "daily";
|
const DEFAULT_ROTATION_FREQUENCY: RotationFrequency = "daily";
|
||||||
const DEFAULT_ROTATION_MAX_FILES = 14;
|
const DEFAULT_ROTATION_MAX_FILES = 14;
|
||||||
|
|
||||||
|
const MINIMUM_INTERVAL_MS = parseDuration("10s");
|
||||||
|
|
||||||
const VALID_LOG_LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"];
|
const VALID_LOG_LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"];
|
||||||
const VALID_ROTATION_FREQUENCIES: RotationFrequency[] = ["hourly", "daily", "weekly"];
|
const VALID_ROTATION_FREQUENCIES: RotationFrequency[] = ["hourly", "daily", "weekly"];
|
||||||
|
|
||||||
@@ -60,7 +62,7 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
|
|||||||
throw new Error("配置文件内容为空或格式无效");
|
throw new Error("配置文件内容为空或格式无效");
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeResult = normalizeAuthoringConfig(parsed);
|
const normalizeResult = normalizeAuthoringConfig(parsed, checkerRegistry);
|
||||||
if (normalizeResult.issues.length > 0) {
|
if (normalizeResult.issues.length > 0) {
|
||||||
throwConfigIssues(dedupeIssues(normalizeResult.issues));
|
throwConfigIssues(dedupeIssues(normalizeResult.issues));
|
||||||
}
|
}
|
||||||
@@ -208,6 +210,14 @@ function resolveTarget(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tryParseDuration(value: string): null | number {
|
||||||
|
try {
|
||||||
|
return parseDuration(value);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function validateConfig(config: NormalizedProbeConfig): ConfigValidationIssue[] {
|
function validateConfig(config: NormalizedProbeConfig): ConfigValidationIssue[] {
|
||||||
const issues: ConfigValidationIssue[] = [];
|
const issues: ConfigValidationIssue[] = [];
|
||||||
if (!Array.isArray(config.targets) || config.targets.length === 0) {
|
if (!Array.isArray(config.targets) || config.targets.length === 0) {
|
||||||
@@ -291,18 +301,21 @@ function validateConfig(config: NormalizedProbeConfig): ConfigValidationIssue[]
|
|||||||
: isString(targetIdValue)
|
: isString(targetIdValue)
|
||||||
? targetIdValue
|
? targetIdValue
|
||||||
: undefined;
|
: undefined;
|
||||||
validateDurationValue(
|
const intervalRaw = isString(targetRecord["interval"]) ? targetRecord["interval"] : undefined;
|
||||||
isString(targetRecord["interval"]) ? targetRecord["interval"] : undefined,
|
const timeoutRaw = isString(targetRecord["timeout"]) ? targetRecord["timeout"] : undefined;
|
||||||
`targets[${i}].interval`,
|
validateDurationValue(intervalRaw, `targets[${i}].interval`, issues, targetName);
|
||||||
issues,
|
validateDurationValue(timeoutRaw, `targets[${i}].timeout`, issues, targetName);
|
||||||
targetName,
|
|
||||||
);
|
const intervalMs = tryParseDuration(intervalRaw ?? DEFAULT_INTERVAL);
|
||||||
validateDurationValue(
|
const timeoutMs = tryParseDuration(timeoutRaw ?? DEFAULT_TIMEOUT);
|
||||||
isString(targetRecord["timeout"]) ? targetRecord["timeout"] : undefined,
|
|
||||||
`targets[${i}].timeout`,
|
if (intervalMs !== null && intervalMs < MINIMUM_INTERVAL_MS) {
|
||||||
issues,
|
issues.push(issue("invalid-value", `targets[${i}].interval`, "interval 不能小于 10s", targetName));
|
||||||
targetName,
|
}
|
||||||
);
|
|
||||||
|
if (intervalMs !== null && timeoutMs !== null && timeoutMs > intervalMs) {
|
||||||
|
issues.push(issue("invalid-value", `targets[${i}].timeout`, "timeout 不能大于 interval", targetName));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return issues;
|
return issues;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { groupBy, isError, Semaphore } from "es-toolkit";
|
import { isError, Semaphore } from "es-toolkit";
|
||||||
|
|
||||||
import type { Logger } from "../logger";
|
import type { Logger } from "../logger";
|
||||||
import type { ProbeStore } from "./store";
|
import type { ProbeStore } from "./store";
|
||||||
@@ -11,14 +11,15 @@ import { checkerRegistry } from "./runner";
|
|||||||
const PRUNE_INTERVAL_MS = 3600000;
|
const PRUNE_INTERVAL_MS = 3600000;
|
||||||
|
|
||||||
export class ProbeEngine {
|
export class ProbeEngine {
|
||||||
|
private abort: AbortController | null = null;
|
||||||
private lastMatched = new Map<string, boolean>();
|
private lastMatched = new Map<string, boolean>();
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
private pruneTimer: null | ReturnType<typeof setInterval> = null;
|
||||||
private retentionMs: number;
|
private retentionMs: number;
|
||||||
private semaphore: Semaphore;
|
private semaphore: Semaphore;
|
||||||
private store: ProbeStore;
|
private store: ProbeStore;
|
||||||
private targetIds = new Set<string>();
|
private targetIds = new Set<string>();
|
||||||
private targets: ResolvedTargetBase[];
|
private targets: ResolvedTargetBase[];
|
||||||
private timers: Array<ReturnType<typeof setInterval>> = [];
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
store: ProbeStore,
|
store: ProbeStore,
|
||||||
@@ -37,32 +38,28 @@ export class ProbeEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
const groups = groupBy(this.targets, (t) => t.intervalMs);
|
this.abort = new AbortController();
|
||||||
|
const signal = this.abort.signal;
|
||||||
|
|
||||||
for (const [intervalMs, groupTargets] of Object.entries(groups)) {
|
for (const target of this.targets) {
|
||||||
void this.probeGroup(groupTargets);
|
void this.runLoop(target, signal);
|
||||||
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
void this.probeGroup(groupTargets);
|
|
||||||
}, Number(intervalMs));
|
|
||||||
|
|
||||||
this.timers.push(timer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.retentionMs > 0) {
|
if (this.retentionMs > 0) {
|
||||||
this.store.prune(this.retentionMs);
|
this.store.prune(this.retentionMs);
|
||||||
const pruneTimer = setInterval(() => {
|
this.pruneTimer = setInterval(() => {
|
||||||
this.store.prune(this.retentionMs);
|
this.store.prune(this.retentionMs);
|
||||||
}, PRUNE_INTERVAL_MS);
|
}, PRUNE_INTERVAL_MS);
|
||||||
this.timers.push(pruneTimer);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
for (const timer of this.timers) {
|
this.abort?.abort();
|
||||||
clearInterval(timer);
|
this.abort = null;
|
||||||
|
if (this.pruneTimer) {
|
||||||
|
clearInterval(this.pruneTimer);
|
||||||
|
this.pruneTimer = null;
|
||||||
}
|
}
|
||||||
this.timers = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private initStateCache(): void {
|
private initStateCache(): void {
|
||||||
@@ -108,44 +105,6 @@ export class ProbeEngine {
|
|||||||
this.lastMatched.set(result.targetId, current);
|
this.lastMatched.set(result.targetId, current);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async probeGroup(targets: ResolvedTargetBase[]): Promise<void> {
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
targets.map(async (target) => {
|
|
||||||
await this.semaphore.acquire();
|
|
||||||
try {
|
|
||||||
return await this.runCheck(target);
|
|
||||||
} finally {
|
|
||||||
this.semaphore.release();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const [index, result] of results.entries()) {
|
|
||||||
if (result.status === "fulfilled") {
|
|
||||||
this.writeResult(result.value);
|
|
||||||
this.logStateChange(result.value);
|
|
||||||
this.logCheckDebug(result.value);
|
|
||||||
} else {
|
|
||||||
const target = targets[index];
|
|
||||||
if (target) {
|
|
||||||
this.logger.error(
|
|
||||||
{ reason: formatReason(result.reason), targetId: target.id, targetType: target.type },
|
|
||||||
`探针执行失败: ${formatReason(result.reason)}`,
|
|
||||||
);
|
|
||||||
this.writeResult({
|
|
||||||
detail: null,
|
|
||||||
durationMs: null,
|
|
||||||
failure: errorFailure("internal", "engine", formatReason(result.reason)),
|
|
||||||
matched: false,
|
|
||||||
observation: null,
|
|
||||||
targetId: target.id,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private refreshCache(): void {
|
private refreshCache(): void {
|
||||||
this.targetIds.clear();
|
this.targetIds.clear();
|
||||||
for (const target of this.store.getTargets()) {
|
for (const target of this.store.getTargets()) {
|
||||||
@@ -165,6 +124,62 @@ export class ProbeEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async runLoop(target: ResolvedTargetBase, signal: AbortSignal): Promise<void> {
|
||||||
|
while (!signal.aborted) {
|
||||||
|
const start = performance.now();
|
||||||
|
try {
|
||||||
|
await this.runOnce(target, signal);
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = performance.now() - start;
|
||||||
|
if (elapsed > target.intervalMs) {
|
||||||
|
this.logger.warn(
|
||||||
|
{ elapsed, intervalMs: target.intervalMs, targetId: target.id },
|
||||||
|
`拨测超时: ${target.id} 耗时 ${Math.round(elapsed)}ms > 间隔 ${target.intervalMs}ms`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const delay = Math.max(0, target.intervalMs - elapsed);
|
||||||
|
try {
|
||||||
|
await sleep(delay, signal);
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runOnce(target: ResolvedTargetBase, signal?: AbortSignal): Promise<CheckResult> {
|
||||||
|
await this.semaphore.acquire();
|
||||||
|
if (signal?.aborted) {
|
||||||
|
this.semaphore.release();
|
||||||
|
throw new DOMException("Aborted", "AbortError");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await this.runCheck(target);
|
||||||
|
this.writeResult(result);
|
||||||
|
this.logStateChange(result);
|
||||||
|
this.logCheckDebug(result);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const reason = formatReason(error);
|
||||||
|
this.logger.error({ reason, targetId: target.id, targetType: target.type }, `探针执行失败: ${reason}`);
|
||||||
|
const errorResult: CheckResult = {
|
||||||
|
detail: null,
|
||||||
|
durationMs: null,
|
||||||
|
failure: errorFailure("internal", "engine", reason),
|
||||||
|
matched: false,
|
||||||
|
observation: null,
|
||||||
|
targetId: target.id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.writeResult(errorResult);
|
||||||
|
return errorResult;
|
||||||
|
} finally {
|
||||||
|
this.semaphore.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private writeResult(result: CheckResult): void {
|
private writeResult(result: CheckResult): void {
|
||||||
if (!this.targetIds.has(result.targetId)) return;
|
if (!this.targetIds.has(result.targetId)) return;
|
||||||
|
|
||||||
@@ -182,3 +197,24 @@ export class ProbeEngine {
|
|||||||
function formatReason(reason: unknown): string {
|
function formatReason(reason: unknown): string {
|
||||||
return isError(reason) ? reason.message : String(reason);
|
return isError(reason) ? reason.message : String(reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
if (signal.aborted) {
|
||||||
|
reject(new DOMException("Aborted", "AbortError"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
resolve();
|
||||||
|
}, ms);
|
||||||
|
|
||||||
|
function onAbort() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new DOMException("Aborted", "AbortError"));
|
||||||
|
}
|
||||||
|
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
50
src/server/checker/expect/normalize.ts
Normal file
50
src/server/checker/expect/normalize.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { isPlainObject } from "es-toolkit";
|
||||||
|
|
||||||
|
import { resolveContentExpectations } from "./content";
|
||||||
|
import { CONTENT_EXTRACTOR_KEY_SET, MATCHER_KEY_SET } from "./keys";
|
||||||
|
import { isValueMatcherObject, isValueMatcherPrimitive, resolveValueExpectation } from "./value";
|
||||||
|
|
||||||
|
type ExpectRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
export function compactExpect(original: ExpectRecord, overrides: ExpectRecord): ExpectRecord {
|
||||||
|
const result: ExpectRecord = {};
|
||||||
|
for (const [key, value] of Object.entries(original)) {
|
||||||
|
if (value !== undefined) result[key] = value;
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(overrides)) {
|
||||||
|
if (value !== undefined) result[key] = value;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeContent(value: unknown): unknown {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (!Array.isArray(value)) return value;
|
||||||
|
return (value as unknown[]).map((entry): unknown => {
|
||||||
|
if (!canNormalizeContentEntry(entry)) return entry;
|
||||||
|
const resolved = resolveContentExpectations([entry] as never);
|
||||||
|
return resolved?.[0];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeKeyed(value: unknown): unknown {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (!isPlainObject(value)) return value;
|
||||||
|
return Object.entries(value as ExpectRecord).map(([key, item]) => ({ key, matcher: normalizeValue(item) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeValue(value: unknown): unknown {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (isValueMatcherPrimitive(value) || isValueMatcherObject(value)) return resolveValueExpectation(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canNormalizeContentEntry(value: unknown): boolean {
|
||||||
|
if (!isPlainObject(value)) return false;
|
||||||
|
const keys = Object.keys(value);
|
||||||
|
const extractorKeys = keys.filter((key) => CONTENT_EXTRACTOR_KEY_SET.has(key));
|
||||||
|
const matcherKeys = keys.filter((key) => MATCHER_KEY_SET.has(key));
|
||||||
|
if (extractorKeys.length === 0) return matcherKeys.length > 0 && matcherKeys.length === keys.length;
|
||||||
|
if (extractorKeys.length !== 1 || matcherKeys.length > 0 || keys.length !== 1) return false;
|
||||||
|
return isPlainObject((value as ExpectRecord)[extractorKeys[0]!]);
|
||||||
|
}
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import { isPlainObject } from "es-toolkit";
|
import { isPlainObject } from "es-toolkit";
|
||||||
|
|
||||||
|
import type { CheckerRegistry } from "./runner/registry";
|
||||||
import type { ConfigValidationIssue } from "./schema/issues";
|
import type { ConfigValidationIssue } from "./schema/issues";
|
||||||
import type { AuthoringProbeConfig, NormalizedProbeConfig } from "./schema/types";
|
import type { AuthoringProbeConfig, NormalizedProbeConfig } from "./schema/types";
|
||||||
import type { RawTargetConfig } from "./types";
|
import type { RawTargetConfig } from "./types";
|
||||||
|
|
||||||
import { resolveContentExpectations } from "./expect/content";
|
import { checkerRegistry } from "./runner";
|
||||||
import { CONTENT_EXTRACTOR_KEY_SET, MATCHER_KEY_SET } from "./expect/keys";
|
|
||||||
import { isValueMatcherObject, isValueMatcherPrimitive, resolveValueExpectation } from "./expect/value";
|
|
||||||
import { resolveVariables } from "./variables";
|
import { resolveVariables } from "./variables";
|
||||||
|
|
||||||
type ExpectRecord = Record<string, unknown>;
|
export function normalizeAuthoringConfig(
|
||||||
|
config: unknown,
|
||||||
export function normalizeAuthoringConfig(config: unknown): {
|
registry: CheckerRegistry = checkerRegistry,
|
||||||
|
): {
|
||||||
config: unknown;
|
config: unknown;
|
||||||
issues: ConfigValidationIssue[];
|
issues: ConfigValidationIssue[];
|
||||||
} {
|
} {
|
||||||
@@ -23,165 +23,20 @@ export function normalizeAuthoringConfig(config: unknown): {
|
|||||||
const normalized = { ...(variableResult.config as Record<string, unknown>) };
|
const normalized = { ...(variableResult.config as Record<string, unknown>) };
|
||||||
delete normalized["variables"];
|
delete normalized["variables"];
|
||||||
if (Array.isArray(normalized["targets"])) {
|
if (Array.isArray(normalized["targets"])) {
|
||||||
normalized["targets"] = normalized["targets"].map((target) => normalizeTarget(target));
|
normalized["targets"] = normalized["targets"].map((target) => normalizeTarget(target, registry));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { config: normalized, issues: variableResult.issues };
|
return { config: normalized, issues: variableResult.issues };
|
||||||
}
|
}
|
||||||
|
|
||||||
function canNormalizeContentEntry(value: unknown): boolean {
|
function normalizeTarget(target: unknown, registry: CheckerRegistry): unknown {
|
||||||
if (!isPlainObject(value)) return false;
|
|
||||||
const keys = Object.keys(value);
|
|
||||||
const extractorKeys = keys.filter((key) => CONTENT_EXTRACTOR_KEY_SET.has(key));
|
|
||||||
const matcherKeys = keys.filter((key) => MATCHER_KEY_SET.has(key));
|
|
||||||
if (extractorKeys.length === 0) return matcherKeys.length > 0 && matcherKeys.length === keys.length;
|
|
||||||
if (extractorKeys.length !== 1 || matcherKeys.length > 0 || keys.length !== 1) return false;
|
|
||||||
return isPlainObject((value as ExpectRecord)[extractorKeys[0]!]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function compact(original: ExpectRecord, overrides: ExpectRecord): ExpectRecord {
|
|
||||||
const result: ExpectRecord = {};
|
|
||||||
for (const [key, value] of Object.entries(original)) {
|
|
||||||
if (value !== undefined) result[key] = value;
|
|
||||||
}
|
|
||||||
for (const [key, value] of Object.entries(overrides)) {
|
|
||||||
if (value !== undefined) result[key] = value;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCommandExpect(raw: ExpectRecord): ExpectRecord {
|
|
||||||
return compact(raw, {
|
|
||||||
durationMs: normalizeValue(raw["durationMs"]),
|
|
||||||
exitCode: raw["exitCode"],
|
|
||||||
stderr: normalizeContent(raw["stderr"]),
|
|
||||||
stdout: normalizeContent(raw["stdout"]),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeContent(value: unknown): unknown {
|
|
||||||
if (value === undefined) return undefined;
|
|
||||||
if (!Array.isArray(value)) return value;
|
|
||||||
return (value as unknown[]).map((entry): unknown => {
|
|
||||||
if (!canNormalizeContentEntry(entry)) return entry;
|
|
||||||
const resolved = resolveContentExpectations([entry] as never);
|
|
||||||
return resolved?.[0];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDbExpect(raw: ExpectRecord): ExpectRecord {
|
|
||||||
return compact(raw, {
|
|
||||||
durationMs: normalizeValue(raw["durationMs"]),
|
|
||||||
result: normalizeContent(raw["result"]),
|
|
||||||
rowCount: normalizeValue(raw["rowCount"]),
|
|
||||||
rows: Array.isArray(raw["rows"]) ? raw["rows"].map((row) => normalizeKeyed(row)) : raw["rows"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeExpect(type: string, expect: unknown): unknown {
|
|
||||||
if (!isPlainObject(expect)) return expect;
|
|
||||||
const raw = expect as ExpectRecord;
|
|
||||||
switch (type) {
|
|
||||||
case "cmd":
|
|
||||||
return normalizeCommandExpect(raw);
|
|
||||||
case "db":
|
|
||||||
return normalizeDbExpect(raw);
|
|
||||||
case "http":
|
|
||||||
return normalizeHttpExpect(raw);
|
|
||||||
case "icmp":
|
|
||||||
return normalizeIcmpExpect(raw);
|
|
||||||
case "llm":
|
|
||||||
return normalizeLlmExpect(raw);
|
|
||||||
case "tcp":
|
|
||||||
return normalizeTcpExpect(raw);
|
|
||||||
case "udp":
|
|
||||||
return normalizeUdpExpect(raw);
|
|
||||||
default:
|
|
||||||
return expect;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeHttpExpect(raw: ExpectRecord): ExpectRecord {
|
|
||||||
return compact(raw, {
|
|
||||||
body: normalizeContent(raw["body"]),
|
|
||||||
durationMs: normalizeValue(raw["durationMs"]),
|
|
||||||
headers: normalizeKeyed(raw["headers"]),
|
|
||||||
status: raw["status"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeIcmpExpect(raw: ExpectRecord): ExpectRecord {
|
|
||||||
return compact(raw, {
|
|
||||||
alive: raw["alive"],
|
|
||||||
avgLatencyMs: normalizeValue(raw["avgLatencyMs"]),
|
|
||||||
durationMs: normalizeValue(raw["durationMs"]),
|
|
||||||
maxLatencyMs: normalizeValue(raw["maxLatencyMs"]),
|
|
||||||
packetLossPercent: normalizeValue(raw["packetLossPercent"]),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeKeyed(value: unknown): unknown {
|
|
||||||
if (value === undefined) return undefined;
|
|
||||||
if (!isPlainObject(value)) return value;
|
|
||||||
return Object.entries(value as ExpectRecord).map(([key, item]) => ({ key, matcher: normalizeValue(item) }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeLlmExpect(raw: ExpectRecord): ExpectRecord {
|
|
||||||
return compact(raw, {
|
|
||||||
durationMs: normalizeValue(raw["durationMs"]),
|
|
||||||
finishReason: normalizeValue(raw["finishReason"]),
|
|
||||||
headers: normalizeKeyed(raw["headers"]),
|
|
||||||
output: normalizeContent(raw["output"]),
|
|
||||||
rawFinishReason: normalizeValue(raw["rawFinishReason"]),
|
|
||||||
status: raw["status"],
|
|
||||||
stream: isPlainObject(raw["stream"])
|
|
||||||
? compact(raw["stream"] as ExpectRecord, {
|
|
||||||
completed: (raw["stream"] as ExpectRecord)["completed"],
|
|
||||||
firstTokenMs: normalizeValue((raw["stream"] as ExpectRecord)["firstTokenMs"]),
|
|
||||||
})
|
|
||||||
: raw["stream"],
|
|
||||||
usage: isPlainObject(raw["usage"])
|
|
||||||
? compact(raw["usage"] as ExpectRecord, {
|
|
||||||
inputTokens: normalizeValue((raw["usage"] as ExpectRecord)["inputTokens"]),
|
|
||||||
outputTokens: normalizeValue((raw["usage"] as ExpectRecord)["outputTokens"]),
|
|
||||||
totalTokens: normalizeValue((raw["usage"] as ExpectRecord)["totalTokens"]),
|
|
||||||
})
|
|
||||||
: raw["usage"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTarget(target: unknown): unknown {
|
|
||||||
if (!isPlainObject(target)) return target;
|
if (!isPlainObject(target)) return target;
|
||||||
const result = { ...(target as RawTargetConfig) };
|
const result = { ...(target as RawTargetConfig) };
|
||||||
if (result.expect !== undefined) {
|
const type = result.type;
|
||||||
result.expect = normalizeExpect(result.type, result.expect);
|
if (typeof type !== "string") return result;
|
||||||
}
|
const checker = registry?.tryGet(type);
|
||||||
return result;
|
if (!checker) return result;
|
||||||
}
|
return checker.normalize(result);
|
||||||
|
|
||||||
function normalizeTcpExpect(raw: ExpectRecord): ExpectRecord {
|
|
||||||
return compact(raw, {
|
|
||||||
banner: normalizeContent(raw["banner"]),
|
|
||||||
connected: raw["connected"],
|
|
||||||
durationMs: normalizeValue(raw["durationMs"]),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeUdpExpect(raw: ExpectRecord): ExpectRecord {
|
|
||||||
return compact(raw, {
|
|
||||||
durationMs: normalizeValue(raw["durationMs"]),
|
|
||||||
responded: raw["responded"],
|
|
||||||
response: normalizeContent(raw["response"]),
|
|
||||||
responseSize: normalizeValue(raw["responseSize"]),
|
|
||||||
sourceHost: normalizeValue(raw["sourceHost"]),
|
|
||||||
sourcePort: normalizeValue(raw["sourcePort"]),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeValue(value: unknown): unknown {
|
|
||||||
if (value === undefined) return undefined;
|
|
||||||
if (isValueMatcherPrimitive(value) || isValueMatcherObject(value)) return resolveValueExpectation(value);
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { AuthoringProbeConfig, NormalizedProbeConfig };
|
export type { AuthoringProbeConfig, NormalizedProbeConfig };
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { errorFailure } from "../../expect/failure";
|
|||||||
import { checkValueExpectation } from "../../expect/value";
|
import { checkValueExpectation } from "../../expect/value";
|
||||||
import { parseSize } from "../../utils";
|
import { parseSize } from "../../utils";
|
||||||
import { checkExitCode } from "./expect";
|
import { checkExitCode } from "./expect";
|
||||||
|
import { normalizeTargetExpect } from "./normalize";
|
||||||
import { commandCheckerSchemas } from "./schema";
|
import { commandCheckerSchemas } from "./schema";
|
||||||
import { validateCommandConfig } from "./validate";
|
import { validateCommandConfig } from "./validate";
|
||||||
|
|
||||||
@@ -202,6 +203,10 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
normalize(target: RawTargetConfig): RawTargetConfig {
|
||||||
|
return normalizeTargetExpect(target);
|
||||||
|
}
|
||||||
|
|
||||||
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedCommandTarget {
|
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedCommandTarget {
|
||||||
const t = target as RawTargetConfig & { cmd: CommandTargetConfig; type: "cmd" };
|
const t = target as RawTargetConfig & { cmd: CommandTargetConfig; type: "cmd" };
|
||||||
|
|
||||||
|
|||||||
19
src/server/checker/runner/cmd/normalize.ts
Normal file
19
src/server/checker/runner/cmd/normalize.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { isPlainObject } from "es-toolkit";
|
||||||
|
|
||||||
|
import type { RawTargetConfig } from "../../types";
|
||||||
|
|
||||||
|
import { compactExpect, normalizeContent, 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"]),
|
||||||
|
exitCode: raw["exitCode"],
|
||||||
|
stderr: normalizeContent(raw["stderr"]),
|
||||||
|
stdout: normalizeContent(raw["stdout"]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
111
src/server/checker/runner/cpu/calculate.ts
Normal file
111
src/server/checker/runner/cpu/calculate.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCpuSnapshots(before: CpuCoreSnapshot[], after: CpuCoreSnapshot[]): null | string {
|
||||||
|
if (before.length === 0 || after.length === 0) {
|
||||||
|
return "CPU 快照为空";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (before.length !== after.length) {
|
||||||
|
return `CPU 快照核心数不一致: before=${before.length}, after=${after.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < before.length; i++) {
|
||||||
|
const bTimes = before[i]!.times;
|
||||||
|
const aTimes = after[i]!.times;
|
||||||
|
|
||||||
|
for (const [name, value] of Object.entries(bTimes)) {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return `CPU 快照包含非有限值: before[${i}].times.${name}=${value}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [name, value] of Object.entries(aTimes)) {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return `CPU 快照包含非有限值: after[${i}].times.${name}=${value}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const idleDelta = aTimes.idle - bTimes.idle;
|
||||||
|
const userDelta = aTimes.user - bTimes.user;
|
||||||
|
const niceDelta = aTimes.nice - bTimes.nice;
|
||||||
|
const sysDelta = aTimes.sys - bTimes.sys;
|
||||||
|
const irqDelta = aTimes.irq - bTimes.irq;
|
||||||
|
const coreTotalDelta = userDelta + niceDelta + sysDelta + idleDelta + irqDelta;
|
||||||
|
|
||||||
|
if (coreTotalDelta < 0) {
|
||||||
|
return `CPU 快照包含负数 delta: core[${i}] totalDelta=${coreTotalDelta}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function round1(value: number): number {
|
||||||
|
return Math.round(value * 10) / 10;
|
||||||
|
}
|
||||||
219
src/server/checker/runner/cpu/execute.ts
Normal file
219
src/server/checker/runner/cpu/execute.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
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, validateCpuSnapshots } 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);
|
||||||
|
|
||||||
|
let after: CpuCoreSnapshot[];
|
||||||
|
if (aborted) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: errorFailure("cpu", "timeout", `CPU 采样超时 (${t.timeoutMs}ms)`),
|
||||||
|
matched: false,
|
||||||
|
observation: null,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
after = 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationError = validateCpuSnapshots(before, after);
|
||||||
|
if (validationError !== null) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: errorFailure("cpu", "snapshot", validationError),
|
||||||
|
matched: false,
|
||||||
|
observation: null,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = calculateCpuStats(before, after);
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
const result = checkStats(stats, t.expect, durationMs);
|
||||||
|
|
||||||
|
const observation: Record<string, unknown> = {
|
||||||
|
error: null,
|
||||||
|
idlePercent: stats.idlePercent,
|
||||||
|
logicalCoreCount: stats.logicalCoreCount,
|
||||||
|
maxCoreUsagePercent: stats.maxCoreUsagePercent,
|
||||||
|
minCoreUsagePercent: stats.minCoreUsagePercent,
|
||||||
|
usagePercent: stats.usagePercent,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (t.cpu.includePerCore) {
|
||||||
|
observation["perCoreUsagePercent"] = stats.perCoreUsagePercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: result.failure,
|
||||||
|
matched: result.matched,
|
||||||
|
observation,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize(target: RawTargetConfig): RawTargetConfig {
|
||||||
|
return normalizeTargetExpect(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedCpuTarget {
|
||||||
|
const t = target as RawTargetConfig & { cpu: CpuTargetConfig; type: "cpu" };
|
||||||
|
|
||||||
|
const rawSampleDuration = t.cpu.sampleDuration;
|
||||||
|
const sampleDurationMs = rawSampleDuration ? parseDuration(rawSampleDuration) : DEFAULT_SAMPLE_DURATION_MS;
|
||||||
|
const includePerCore = t.cpu.includePerCore ?? false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cpu: { includePerCore, sampleDurationMs },
|
||||||
|
description: null,
|
||||||
|
expect: target.expect as ResolvedCpuExpectConfig | undefined,
|
||||||
|
group: target.group ?? "default",
|
||||||
|
id: t.id,
|
||||||
|
intervalMs: context.defaultIntervalMs,
|
||||||
|
name: t.name ?? null,
|
||||||
|
timeoutMs: context.defaultTimeoutMs,
|
||||||
|
type: "cpu",
|
||||||
|
} satisfies ResolvedCpuTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
serialize(t: ResolvedCpuTarget): { config: string; target: string } {
|
||||||
|
return {
|
||||||
|
config: JSON.stringify(t.cpu),
|
||||||
|
target: `cpu sample ${t.cpu.sampleDurationMs}ms`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(input: CheckerValidationInput) {
|
||||||
|
return validateCpuConfig(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkStats(stats: CpuStats, expect: ResolvedCpuExpectConfig | undefined, durationMs: number) {
|
||||||
|
const usageResult = checkUsagePercent(stats.usagePercent, expect?.usagePercent);
|
||||||
|
if (!usageResult.matched) return usageResult;
|
||||||
|
const idleResult = checkIdlePercent(stats.idlePercent, expect?.idlePercent);
|
||||||
|
if (!idleResult.matched) return idleResult;
|
||||||
|
const maxCoreResult = checkMaxCoreUsage(stats.maxCoreUsagePercent, expect?.maxCoreUsagePercent);
|
||||||
|
if (!maxCoreResult.matched) return maxCoreResult;
|
||||||
|
const minCoreResult = checkMinCoreUsage(stats.minCoreUsagePercent, expect?.minCoreUsagePercent);
|
||||||
|
if (!minCoreResult.matched) return minCoreResult;
|
||||||
|
return checkValueExpectation(durationMs, expect?.durationMs, {
|
||||||
|
message: "durationMs mismatch",
|
||||||
|
path: "durationMs",
|
||||||
|
phase: "duration",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value: number): string {
|
||||||
|
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等待指定毫秒,支持 AbortSignal 取消。
|
||||||
|
* 返回 true 表示被中断(aborted),false 表示正常完成。
|
||||||
|
*/
|
||||||
|
async function waitForDuration(ms: number, signal: AbortSignal): Promise<boolean> {
|
||||||
|
if (signal.aborted) return true;
|
||||||
|
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
resolve(false);
|
||||||
|
}, ms);
|
||||||
|
|
||||||
|
function onAbort() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
35
src/server/checker/runner/cpu/expect.ts
Normal file
35
src/server/checker/runner/cpu/expect.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ExpectationResult, ValueExpectation } from "../../expect/types";
|
||||||
|
|
||||||
|
import { checkValueExpectation } from "../../expect/value";
|
||||||
|
|
||||||
|
export function checkIdlePercent(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
|
||||||
|
return checkValueExpectation(actual, matcher, {
|
||||||
|
message: "CPU 空闲率不满足条件",
|
||||||
|
path: "idlePercent",
|
||||||
|
phase: "idle",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkMaxCoreUsage(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
|
||||||
|
return checkValueExpectation(actual, matcher, {
|
||||||
|
message: "单核心最大使用率不满足条件",
|
||||||
|
path: "maxCoreUsagePercent",
|
||||||
|
phase: "maxCoreUsage",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkMinCoreUsage(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
|
||||||
|
return checkValueExpectation(actual, matcher, {
|
||||||
|
message: "单核心最小使用率不满足条件",
|
||||||
|
path: "minCoreUsagePercent",
|
||||||
|
phase: "minCoreUsage",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkUsagePercent(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
|
||||||
|
return checkValueExpectation(actual, matcher, {
|
||||||
|
message: "CPU 使用率不满足条件",
|
||||||
|
path: "usagePercent",
|
||||||
|
phase: "usage",
|
||||||
|
});
|
||||||
|
}
|
||||||
1
src/server/checker/runner/cpu/index.ts
Normal file
1
src/server/checker/runner/cpu/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { CpuChecker } from "./execute";
|
||||||
20
src/server/checker/runner/cpu/normalize.ts
Normal file
20
src/server/checker/runner/cpu/normalize.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { isPlainObject } from "es-toolkit";
|
||||||
|
|
||||||
|
import type { RawTargetConfig } from "../../types";
|
||||||
|
|
||||||
|
import { compactExpect, normalizeValue } from "../../expect/normalize";
|
||||||
|
|
||||||
|
export function normalizeTargetExpect(target: RawTargetConfig): RawTargetConfig {
|
||||||
|
if (target.expect === undefined || !isPlainObject(target.expect)) return target;
|
||||||
|
const raw = target.expect as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
...target,
|
||||||
|
expect: compactExpect(raw, {
|
||||||
|
durationMs: normalizeValue(raw["durationMs"]),
|
||||||
|
idlePercent: normalizeValue(raw["idlePercent"]),
|
||||||
|
maxCoreUsagePercent: normalizeValue(raw["maxCoreUsagePercent"]),
|
||||||
|
minCoreUsagePercent: normalizeValue(raw["minCoreUsagePercent"]),
|
||||||
|
usagePercent: normalizeValue(raw["usagePercent"]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
46
src/server/checker/runner/cpu/schema.ts
Normal file
46
src/server/checker/runner/cpu/schema.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
import type { CheckerSchemas } from "../types";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createAuthoringFieldSchema,
|
||||||
|
createAuthoringValueExpectationSchema,
|
||||||
|
createNormalizedValueExpectationSchema,
|
||||||
|
durationSchema,
|
||||||
|
} from "../../schema/fragments";
|
||||||
|
|
||||||
|
export const cpuCheckerSchemas: CheckerSchemas = {
|
||||||
|
authoring: {
|
||||||
|
config: createCpuConfigSchema("authoring"),
|
||||||
|
expect: createCpuExpectSchema("authoring"),
|
||||||
|
},
|
||||||
|
normalized: {
|
||||||
|
config: createCpuConfigSchema("normalized"),
|
||||||
|
expect: createCpuExpectSchema("normalized"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function createCpuConfigSchema(kind: "authoring" | "normalized") {
|
||||||
|
return Type.Object(
|
||||||
|
{
|
||||||
|
includePerCore: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(Type.Boolean()) : Type.Boolean()),
|
||||||
|
sampleDuration: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(durationSchema) : durationSchema),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCpuExpectSchema(kind: "authoring" | "normalized") {
|
||||||
|
const valueSchema =
|
||||||
|
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema();
|
||||||
|
return Type.Object(
|
||||||
|
{
|
||||||
|
durationMs: Type.Optional(valueSchema),
|
||||||
|
idlePercent: Type.Optional(valueSchema),
|
||||||
|
maxCoreUsagePercent: Type.Optional(valueSchema),
|
||||||
|
minCoreUsagePercent: Type.Optional(valueSchema),
|
||||||
|
usagePercent: Type.Optional(valueSchema),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
59
src/server/checker/runner/cpu/types.ts
Normal file
59
src/server/checker/runner/cpu/types.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import type { RawValueExpectation, ValueExpectation } from "../../expect/types";
|
||||||
|
import type { ResolvedTargetBase } from "../../types";
|
||||||
|
|
||||||
|
export interface CpuCoreSnapshot {
|
||||||
|
times: CpuTimesSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CpuStats {
|
||||||
|
idlePercent: number;
|
||||||
|
logicalCoreCount: number;
|
||||||
|
maxCoreUsagePercent: number;
|
||||||
|
minCoreUsagePercent: number;
|
||||||
|
perCoreUsagePercent: number[];
|
||||||
|
usagePercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CpuTargetConfig {
|
||||||
|
includePerCore?: boolean;
|
||||||
|
sampleDuration?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CpuTimesSnapshot {
|
||||||
|
idle: number;
|
||||||
|
irq: number;
|
||||||
|
nice: number;
|
||||||
|
sys: number;
|
||||||
|
user: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawCpuExpectConfig {
|
||||||
|
durationMs?: RawValueExpectation;
|
||||||
|
idlePercent?: RawValueExpectation;
|
||||||
|
maxCoreUsagePercent?: RawValueExpectation;
|
||||||
|
minCoreUsagePercent?: RawValueExpectation;
|
||||||
|
usagePercent?: RawValueExpectation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedCpuConfig {
|
||||||
|
includePerCore: boolean;
|
||||||
|
sampleDurationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedCpuExpectConfig {
|
||||||
|
durationMs?: ValueExpectation;
|
||||||
|
idlePercent?: ValueExpectation;
|
||||||
|
maxCoreUsagePercent?: ValueExpectation;
|
||||||
|
minCoreUsagePercent?: ValueExpectation;
|
||||||
|
usagePercent?: ValueExpectation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedCpuTarget extends ResolvedTargetBase {
|
||||||
|
cpu: ResolvedCpuConfig;
|
||||||
|
expect?: ResolvedCpuExpectConfig;
|
||||||
|
group: string;
|
||||||
|
intervalMs: number;
|
||||||
|
name: null | string;
|
||||||
|
timeoutMs: number;
|
||||||
|
type: "cpu";
|
||||||
|
}
|
||||||
136
src/server/checker/runner/cpu/validate.ts
Normal file
136
src/server/checker/runner/cpu/validate.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { isString } from "es-toolkit";
|
||||||
|
|
||||||
|
import type { ConfigValidationIssue } from "../../schema/issues";
|
||||||
|
import type { CheckerValidationInput } from "../types";
|
||||||
|
|
||||||
|
import { isPlainRecord, validateRawValueExpectation } from "../../expect/validate";
|
||||||
|
import { issue, joinPath } from "../../schema/issues";
|
||||||
|
import { parseDuration } from "../../utils";
|
||||||
|
|
||||||
|
export function validateCpuConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < input.targets.length; i++) {
|
||||||
|
const target = input.targets[i] as unknown;
|
||||||
|
if (!isPlainRecord(target)) continue;
|
||||||
|
if (target["type"] !== "cpu") continue;
|
||||||
|
issues.push(...validateCpuTarget(target, `targets[${i}]`));
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTargetName(target: Record<string, unknown>): string | undefined {
|
||||||
|
if (isString(target["name"])) return target["name"];
|
||||||
|
return isString(target["id"]) ? target["id"] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCpuExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||||
|
const rawExpect = target["expect"];
|
||||||
|
if (rawExpect === undefined || rawExpect === null || !isPlainRecord(rawExpect)) return [];
|
||||||
|
const expect = rawExpect;
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
const targetName = getTargetName(target);
|
||||||
|
const expectPath = joinPath(path, "expect");
|
||||||
|
|
||||||
|
const valueFields = ["durationMs", "idlePercent", "maxCoreUsagePercent", "minCoreUsagePercent", "usagePercent"];
|
||||||
|
for (const key of valueFields) {
|
||||||
|
if (expect[key] !== undefined) {
|
||||||
|
issues.push(...validateRawValueExpectation(expect[key], joinPath(expectPath, key), targetName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedKeys = new Set(valueFields);
|
||||||
|
for (const key of Object.keys(expect)) {
|
||||||
|
if (!allowedKeys.has(key)) {
|
||||||
|
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCpuTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
const targetName = getTargetName(target);
|
||||||
|
|
||||||
|
// 校验 cpu 配置段
|
||||||
|
const rawCpu = target["cpu"];
|
||||||
|
if (!isPlainRecord(rawCpu)) {
|
||||||
|
issues.push(issue("required", joinPath(path, "cpu"), "缺少 cpu 配置分组", targetName));
|
||||||
|
} else {
|
||||||
|
// 校验 sampleDuration 格式
|
||||||
|
if (rawCpu["sampleDuration"] !== undefined) {
|
||||||
|
const sd = rawCpu["sampleDuration"];
|
||||||
|
if (isString(sd)) {
|
||||||
|
try {
|
||||||
|
parseDuration(sd);
|
||||||
|
} catch {
|
||||||
|
issues.push(
|
||||||
|
issue(
|
||||||
|
"invalid-value",
|
||||||
|
joinPath(joinPath(path, "cpu"), "sampleDuration"),
|
||||||
|
"sampleDuration 不是有效的时长格式",
|
||||||
|
targetName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 变量引用时跳过格式校验(authoring 形态允许 "${...}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验 sampleDuration < timeout(仅当两者都可解析为数值时)
|
||||||
|
if (isString(rawCpu["sampleDuration"])) {
|
||||||
|
try {
|
||||||
|
const sampleMs = parseDuration(rawCpu["sampleDuration"]);
|
||||||
|
const timeout = target["timeout"];
|
||||||
|
if (isString(timeout)) {
|
||||||
|
try {
|
||||||
|
const timeoutMs = parseDuration(timeout);
|
||||||
|
if (sampleMs >= timeoutMs) {
|
||||||
|
issues.push(
|
||||||
|
issue(
|
||||||
|
"invalid-value",
|
||||||
|
joinPath(joinPath(path, "cpu"), "sampleDuration"),
|
||||||
|
"sampleDuration 必须小于 timeout",
|
||||||
|
targetName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// timeout 无法解析,由通用校验处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// timeout 为 undefined 时使用默认值 10s
|
||||||
|
if (timeout === undefined && sampleMs >= 10000) {
|
||||||
|
issues.push(
|
||||||
|
issue(
|
||||||
|
"invalid-value",
|
||||||
|
joinPath(joinPath(path, "cpu"), "sampleDuration"),
|
||||||
|
"sampleDuration 必须小于 timeout(默认 10s)",
|
||||||
|
targetName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// sampleDuration 无法解析,已由上方格式校验处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawCpu["includePerCore"] !== undefined && typeof rawCpu["includePerCore"] !== "boolean") {
|
||||||
|
issues.push(issue("invalid-type", joinPath(joinPath(path, "cpu"), "includePerCore"), "必须为布尔值", targetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedCpuKeys = new Set(["includePerCore", "sampleDuration"]);
|
||||||
|
for (const key of Object.keys(rawCpu)) {
|
||||||
|
if (!allowedCpuKeys.has(key)) {
|
||||||
|
issues.push(issue("unknown-field", joinPath(joinPath(path, "cpu"), key), "是未知字段", targetName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验 expect 字段
|
||||||
|
issues.push(...validateCpuExpect(target, path));
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { checkContentExpectations } from "../../expect/content";
|
|||||||
import { errorFailure } from "../../expect/failure";
|
import { errorFailure } from "../../expect/failure";
|
||||||
import { checkValueExpectation } from "../../expect/value";
|
import { checkValueExpectation } from "../../expect/value";
|
||||||
import { checkRowCount, checkRows } from "./expect";
|
import { checkRowCount, checkRows } from "./expect";
|
||||||
|
import { normalizeTargetExpect } from "./normalize";
|
||||||
import { dbCheckerSchemas } from "./schema";
|
import { dbCheckerSchemas } from "./schema";
|
||||||
import { validateDbConfig } from "./validate";
|
import { validateDbConfig } from "./validate";
|
||||||
|
|
||||||
@@ -223,6 +224,10 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
normalize(target: RawTargetConfig): RawTargetConfig {
|
||||||
|
return normalizeTargetExpect(target);
|
||||||
|
}
|
||||||
|
|
||||||
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedDbTarget {
|
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedDbTarget {
|
||||||
const t = target as RawTargetConfig & { db: DbTargetConfig; type: "db" };
|
const t = target as RawTargetConfig & { db: DbTargetConfig; type: "db" };
|
||||||
|
|
||||||
|
|||||||
19
src/server/checker/runner/db/normalize.ts
Normal file
19
src/server/checker/runner/db/normalize.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { isPlainObject } from "es-toolkit";
|
||||||
|
|
||||||
|
import type { RawTargetConfig } from "../../types";
|
||||||
|
|
||||||
|
import { compactExpect, normalizeContent, normalizeKeyed, 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"]),
|
||||||
|
result: normalizeContent(raw["result"]),
|
||||||
|
rowCount: normalizeValue(raw["rowCount"]),
|
||||||
|
rows: Array.isArray(raw["rows"]) ? raw["rows"].map((row) => normalizeKeyed(row)) : raw["rows"],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
checkTtlMin,
|
checkTtlMin,
|
||||||
checkValueCount,
|
checkValueCount,
|
||||||
} from "./expect";
|
} from "./expect";
|
||||||
|
import { normalizeTargetExpect } from "./normalize";
|
||||||
import { dnsCheckerSchemas } from "./schema";
|
import { dnsCheckerSchemas } from "./schema";
|
||||||
import { queryDns } from "./transport";
|
import { queryDns } from "./transport";
|
||||||
import { validateDnsConfig } from "./validate";
|
import { validateDnsConfig } from "./validate";
|
||||||
@@ -83,6 +84,10 @@ export class DnsChecker implements CheckerDefinition<ResolvedDnsTarget> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
normalize(target: RawTargetConfig): RawTargetConfig {
|
||||||
|
return normalizeTargetExpect(target);
|
||||||
|
}
|
||||||
|
|
||||||
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedDnsTarget {
|
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedDnsTarget {
|
||||||
const dns = target["dns"] as DnsServerConfig | DnsSystemConfig;
|
const dns = target["dns"] as DnsServerConfig | DnsSystemConfig;
|
||||||
|
|
||||||
|
|||||||
28
src/server/checker/runner/dns/normalize.ts
Normal file
28
src/server/checker/runner/dns/normalize.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { isPlainObject } from "es-toolkit";
|
||||||
|
|
||||||
|
import type { RawTargetConfig } from "../../types";
|
||||||
|
|
||||||
|
import { compactExpect, normalizeContent, 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, {
|
||||||
|
answerCount: normalizeValue(raw["answerCount"]),
|
||||||
|
authenticatedData: raw["authenticatedData"],
|
||||||
|
authoritative: raw["authoritative"],
|
||||||
|
durationMs: normalizeValue(raw["durationMs"]),
|
||||||
|
rcode: raw["rcode"],
|
||||||
|
recursionAvailable: raw["recursionAvailable"],
|
||||||
|
responded: raw["responded"],
|
||||||
|
result: normalizeContent(raw["result"]),
|
||||||
|
truncated: raw["truncated"],
|
||||||
|
ttlMax: normalizeValue(raw["ttlMax"]),
|
||||||
|
ttlMin: normalizeValue(raw["ttlMin"]),
|
||||||
|
valueCount: normalizeValue(raw["valueCount"]),
|
||||||
|
values: raw["values"],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { checkHeaderExpectations } from "../../expect/headers";
|
|||||||
import { checkStatusCode } from "../../expect/status";
|
import { checkStatusCode } from "../../expect/status";
|
||||||
import { checkValueExpectation, displayValueExpectation } from "../../expect/value";
|
import { checkValueExpectation, displayValueExpectation } from "../../expect/value";
|
||||||
import { parseSize } from "../../utils";
|
import { parseSize } from "../../utils";
|
||||||
|
import { normalizeTargetExpect } from "./normalize";
|
||||||
import { httpCheckerSchemas } from "./schema";
|
import { httpCheckerSchemas } from "./schema";
|
||||||
import { validateHttpConfig } from "./validate";
|
import { validateHttpConfig } from "./validate";
|
||||||
|
|
||||||
@@ -172,6 +173,10 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
normalize(target: RawTargetConfig): RawTargetConfig {
|
||||||
|
return normalizeTargetExpect(target);
|
||||||
|
}
|
||||||
|
|
||||||
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedHttpTarget {
|
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedHttpTarget {
|
||||||
const t = target as RawTargetConfig & { http: HttpTargetConfig; type: "http" };
|
const t = target as RawTargetConfig & { http: HttpTargetConfig; type: "http" };
|
||||||
|
|
||||||
|
|||||||
19
src/server/checker/runner/http/normalize.ts
Normal file
19
src/server/checker/runner/http/normalize.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { isPlainObject } from "es-toolkit";
|
||||||
|
|
||||||
|
import type { RawTargetConfig } from "../../types";
|
||||||
|
|
||||||
|
import { compactExpect, normalizeContent, normalizeKeyed, 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, {
|
||||||
|
body: normalizeContent(raw["body"]),
|
||||||
|
durationMs: normalizeValue(raw["durationMs"]),
|
||||||
|
headers: normalizeKeyed(raw["headers"]),
|
||||||
|
status: raw["status"],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { errorFailure } from "../../expect/failure";
|
|||||||
import { checkValueExpectation } from "../../expect/value";
|
import { checkValueExpectation } from "../../expect/value";
|
||||||
import { buildPingCommand } from "./command";
|
import { buildPingCommand } from "./command";
|
||||||
import { checkAlive, checkAvgLatency, checkMaxLatency, checkPacketLoss } from "./expect";
|
import { checkAlive, checkAvgLatency, checkMaxLatency, checkPacketLoss } from "./expect";
|
||||||
|
import { normalizeTargetExpect } from "./normalize";
|
||||||
import { parsePingOutput } from "./parse";
|
import { parsePingOutput } from "./parse";
|
||||||
import { icmpCheckerSchemas } from "./schema";
|
import { icmpCheckerSchemas } from "./schema";
|
||||||
import { validatePingConfig } from "./validate";
|
import { validatePingConfig } from "./validate";
|
||||||
@@ -153,6 +154,10 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
normalize(target: RawTargetConfig): RawTargetConfig {
|
||||||
|
return normalizeTargetExpect(target);
|
||||||
|
}
|
||||||
|
|
||||||
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedPingTarget {
|
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedPingTarget {
|
||||||
const t = target as RawTargetConfig & { icmp: PingTargetConfig; type: "icmp" };
|
const t = target as RawTargetConfig & { icmp: PingTargetConfig; type: "icmp" };
|
||||||
|
|
||||||
|
|||||||
20
src/server/checker/runner/icmp/normalize.ts
Normal file
20
src/server/checker/runner/icmp/normalize.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { isPlainObject } from "es-toolkit";
|
||||||
|
|
||||||
|
import type { RawTargetConfig } from "../../types";
|
||||||
|
|
||||||
|
import { compactExpect, normalizeValue } from "../../expect/normalize";
|
||||||
|
|
||||||
|
export function normalizeTargetExpect(target: RawTargetConfig): RawTargetConfig {
|
||||||
|
if (target.expect === undefined || !isPlainObject(target.expect)) return target;
|
||||||
|
const raw = target.expect as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
...target,
|
||||||
|
expect: compactExpect(raw, {
|
||||||
|
alive: raw["alive"],
|
||||||
|
avgLatencyMs: normalizeValue(raw["avgLatencyMs"]),
|
||||||
|
durationMs: normalizeValue(raw["durationMs"]),
|
||||||
|
maxLatencyMs: normalizeValue(raw["maxLatencyMs"]),
|
||||||
|
packetLossPercent: normalizeValue(raw["packetLossPercent"]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import { CommandChecker } from "./cmd";
|
import { CommandChecker } from "./cmd";
|
||||||
|
import { CpuChecker } from "./cpu";
|
||||||
import { DbChecker } from "./db";
|
import { DbChecker } from "./db";
|
||||||
import { DnsChecker } from "./dns";
|
import { DnsChecker } from "./dns";
|
||||||
import { HttpChecker } from "./http";
|
import { HttpChecker } from "./http";
|
||||||
import { IcmpChecker } from "./icmp";
|
import { IcmpChecker } from "./icmp";
|
||||||
import { LlmChecker } from "./llm";
|
import { LlmChecker } from "./llm";
|
||||||
|
import { MemChecker } from "./mem";
|
||||||
import { CheckerRegistry } from "./registry";
|
import { CheckerRegistry } from "./registry";
|
||||||
import { TcpChecker } from "./tcp";
|
import { TcpChecker } from "./tcp";
|
||||||
import { UdpChecker } from "./udp";
|
import { UdpChecker } from "./udp";
|
||||||
|
import { WsChecker } from "./ws";
|
||||||
|
|
||||||
const checkers = [
|
const checkers = [
|
||||||
new HttpChecker(),
|
new HttpChecker(),
|
||||||
@@ -17,6 +20,9 @@ const checkers = [
|
|||||||
new UdpChecker(),
|
new UdpChecker(),
|
||||||
new LlmChecker(),
|
new LlmChecker(),
|
||||||
new DnsChecker(),
|
new DnsChecker(),
|
||||||
|
new WsChecker(),
|
||||||
|
new CpuChecker(),
|
||||||
|
new MemChecker(),
|
||||||
];
|
];
|
||||||
|
|
||||||
export function createDefaultCheckerRegistry(): CheckerRegistry {
|
export function createDefaultCheckerRegistry(): CheckerRegistry {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { LlmTargetConfig, ResolvedLlmExpectConfig, ResolvedLlmTarget } from
|
|||||||
import { errorFailure } from "../../expect/failure";
|
import { errorFailure } from "../../expect/failure";
|
||||||
import { checkValueExpectation } from "../../expect/value";
|
import { checkValueExpectation } from "../../expect/value";
|
||||||
import { runExpects } from "./expect";
|
import { runExpects } from "./expect";
|
||||||
|
import { normalizeTargetExpect } from "./normalize";
|
||||||
import {
|
import {
|
||||||
buildObservationFromApiCallError,
|
buildObservationFromApiCallError,
|
||||||
buildObservationFromGenerateText,
|
buildObservationFromGenerateText,
|
||||||
@@ -127,6 +128,10 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
normalize(target: RawTargetConfig): RawTargetConfig {
|
||||||
|
return normalizeTargetExpect(target);
|
||||||
|
}
|
||||||
|
|
||||||
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedLlmTarget {
|
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedLlmTarget {
|
||||||
const t = target as RawTargetConfig & { llm: LlmTargetConfig; type: "llm" };
|
const t = target as RawTargetConfig & { llm: LlmTargetConfig; type: "llm" };
|
||||||
|
|
||||||
|
|||||||
34
src/server/checker/runner/llm/normalize.ts
Normal file
34
src/server/checker/runner/llm/normalize.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { isPlainObject } from "es-toolkit";
|
||||||
|
|
||||||
|
import type { RawTargetConfig } from "../../types";
|
||||||
|
|
||||||
|
import { compactExpect, normalizeContent, normalizeKeyed, 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"]),
|
||||||
|
finishReason: normalizeValue(raw["finishReason"]),
|
||||||
|
headers: normalizeKeyed(raw["headers"]),
|
||||||
|
output: normalizeContent(raw["output"]),
|
||||||
|
rawFinishReason: normalizeValue(raw["rawFinishReason"]),
|
||||||
|
status: raw["status"],
|
||||||
|
stream: isPlainObject(raw["stream"])
|
||||||
|
? compactExpect(raw["stream"] as Record<string, unknown>, {
|
||||||
|
completed: (raw["stream"] as Record<string, unknown>)["completed"],
|
||||||
|
firstTokenMs: normalizeValue((raw["stream"] as Record<string, unknown>)["firstTokenMs"]),
|
||||||
|
})
|
||||||
|
: raw["stream"],
|
||||||
|
usage: isPlainObject(raw["usage"])
|
||||||
|
? compactExpect(raw["usage"] as Record<string, unknown>, {
|
||||||
|
inputTokens: normalizeValue((raw["usage"] as Record<string, unknown>)["inputTokens"]),
|
||||||
|
outputTokens: normalizeValue((raw["usage"] as Record<string, unknown>)["outputTokens"]),
|
||||||
|
totalTokens: normalizeValue((raw["usage"] as Record<string, unknown>)["totalTokens"]),
|
||||||
|
})
|
||||||
|
: raw["usage"],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
60
src/server/checker/runner/mem/calculate.ts
Normal file
60
src/server/checker/runner/mem/calculate.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import type { Systeminformation } from "systeminformation";
|
||||||
|
|
||||||
|
import type { MemStats } from "./types";
|
||||||
|
|
||||||
|
export function calculateMemStats(data: Systeminformation.MemData): MemStats {
|
||||||
|
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 readMemData(): 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;
|
||||||
|
}
|
||||||
232
src/server/checker/runner/mem/execute.ts
Normal file
232
src/server/checker/runner/mem/execute.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import type { Systeminformation } from "systeminformation";
|
||||||
|
|
||||||
|
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||||
|
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||||
|
import type { MemStats, ResolvedMemExpectConfig, ResolvedMemTarget } from "./types";
|
||||||
|
|
||||||
|
import { errorFailure } from "../../expect/failure";
|
||||||
|
import { checkValueExpectation } from "../../expect/value";
|
||||||
|
import { calculateMemStats, readMemData } 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 { memCheckerSchemas } from "./schema";
|
||||||
|
import { validateMemConfig } from "./validate";
|
||||||
|
|
||||||
|
export class MemChecker implements CheckerDefinition<ResolvedMemTarget> {
|
||||||
|
readonly configKey = "mem";
|
||||||
|
|
||||||
|
readonly schemas = memCheckerSchemas;
|
||||||
|
|
||||||
|
readonly type = "mem";
|
||||||
|
|
||||||
|
constructor(private readonly reader: () => Promise<Systeminformation.MemData> = readMemData) {}
|
||||||
|
|
||||||
|
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: ResolvedMemTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const start = performance.now();
|
||||||
|
|
||||||
|
if (ctx.signal.aborted) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: errorFailure("mem", "timeout", "内存读取超时:signal 已取消"),
|
||||||
|
matched: false,
|
||||||
|
observation: null,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: Systeminformation.MemData;
|
||||||
|
try {
|
||||||
|
data = await raceWithSignal(this.reader(), ctx.signal);
|
||||||
|
} catch (error) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
const isTimeout =
|
||||||
|
error instanceof AbortError || (error instanceof Error && error.message === MEM_TIMEOUT_MESSAGE);
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: isTimeout
|
||||||
|
? errorFailure("mem", "timeout", "内存读取超时")
|
||||||
|
: errorFailure(
|
||||||
|
"mem",
|
||||||
|
"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 = calculateMemStats(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): ResolvedMemTarget {
|
||||||
|
return {
|
||||||
|
description: null,
|
||||||
|
expect: target.expect as ResolvedMemExpectConfig | undefined,
|
||||||
|
group: target.group ?? "default",
|
||||||
|
id: target.id,
|
||||||
|
intervalMs: context.defaultIntervalMs,
|
||||||
|
mem: {},
|
||||||
|
name: target.name ?? null,
|
||||||
|
timeoutMs: context.defaultTimeoutMs,
|
||||||
|
type: "mem",
|
||||||
|
} satisfies ResolvedMemTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
serialize(t: ResolvedMemTarget): { config: string; target: string } {
|
||||||
|
return {
|
||||||
|
config: JSON.stringify(t.mem),
|
||||||
|
target: `mem`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(input: CheckerValidationInput) {
|
||||||
|
return validateMemConfig(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkStats(stats: MemStats, expect: ResolvedMemExpectConfig | 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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const MEM_TIMEOUT_MESSAGE = "Memory read aborted by signal";
|
||||||
|
|
||||||
|
class AbortError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super(MEM_TIMEOUT_MESSAGE);
|
||||||
|
this.name = "AbortError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function raceWithSignal<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||||
|
if (signal.aborted) return Promise.reject(new AbortError());
|
||||||
|
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
function onAbort() {
|
||||||
|
reject(new AbortError());
|
||||||
|
}
|
||||||
|
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
|
||||||
|
promise.then(
|
||||||
|
(value) => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
resolve(value);
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
reject(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
123
src/server/checker/runner/mem/expect.ts
Normal file
123
src/server/checker/runner/mem/expect.ts
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
1
src/server/checker/runner/mem/index.ts
Normal file
1
src/server/checker/runner/mem/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { MemChecker } from "./execute";
|
||||||
72
src/server/checker/runner/mem/normalize.ts
Normal file
72
src/server/checker/runner/mem/normalize.ts
Normal 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;
|
||||||
|
}
|
||||||
55
src/server/checker/runner/mem/schema.ts
Normal file
55
src/server/checker/runner/mem/schema.ts
Normal 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 memCheckerSchemas: CheckerSchemas = {
|
||||||
|
authoring: {
|
||||||
|
config: createMemConfigSchema("authoring"),
|
||||||
|
expect: createMemExpectSchema("authoring"),
|
||||||
|
},
|
||||||
|
normalized: {
|
||||||
|
config: createMemConfigSchema("normalized"),
|
||||||
|
expect: createMemExpectSchema("normalized"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function createMemConfigSchema(_kind: "authoring" | "normalized") {
|
||||||
|
return Type.Object({}, { additionalProperties: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMemExpectSchema(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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
75
src/server/checker/runner/mem/types.ts
Normal file
75
src/server/checker/runner/mem/types.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { Systeminformation } from "systeminformation";
|
||||||
|
|
||||||
|
import type { RawValueExpectation, ValueExpectation } from "../../expect/types";
|
||||||
|
import type { ResolvedTargetBase } from "../../types";
|
||||||
|
|
||||||
|
export type MemDataReader = () => Promise<Systeminformation.MemData>;
|
||||||
|
|
||||||
|
export interface MemStats {
|
||||||
|
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 RawMemExpectConfig {
|
||||||
|
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 ResolvedMemConfig {}
|
||||||
|
|
||||||
|
export interface ResolvedMemExpectConfig {
|
||||||
|
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 ResolvedMemTarget extends ResolvedTargetBase {
|
||||||
|
expect?: ResolvedMemExpectConfig;
|
||||||
|
group: string;
|
||||||
|
intervalMs: number;
|
||||||
|
mem: ResolvedMemConfig;
|
||||||
|
name: null | string;
|
||||||
|
timeoutMs: number;
|
||||||
|
type: "mem";
|
||||||
|
}
|
||||||
111
src/server/checker/runner/mem/validate.ts
Normal file
111
src/server/checker/runner/mem/validate.ts
Normal 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 MEM_CONFIG_KEYS = new Set<string>([]);
|
||||||
|
|
||||||
|
const MEM_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 MEM_EXPECT_KEYS = new Set<string>(MEM_EXPECT_FIELDS);
|
||||||
|
|
||||||
|
export function validateMemConfig(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"] !== "mem") continue;
|
||||||
|
issues.push(...validateMemTarget(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 validateMemExpect(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 MEM_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 (!MEM_EXPECT_KEYS.has(key)) {
|
||||||
|
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateMemTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
const targetName = getTargetName(target);
|
||||||
|
|
||||||
|
const rawMem = target["mem"];
|
||||||
|
if (!isPlainRecord(rawMem)) {
|
||||||
|
issues.push(issue("required", joinPath(path, "mem"), "缺少 mem 配置分组", targetName));
|
||||||
|
} else {
|
||||||
|
for (const key of Object.keys(rawMem)) {
|
||||||
|
if (!MEM_CONFIG_KEYS.has(key)) {
|
||||||
|
issues.push(issue("unknown-field", joinPath(joinPath(path, "mem"), key), "是未知字段", targetName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
issues.push(...validateMemExpect(target, path));
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { errorFailure } from "../../expect/failure";
|
|||||||
import { checkValueExpectation } from "../../expect/value";
|
import { checkValueExpectation } from "../../expect/value";
|
||||||
import { parseSize } from "../../utils";
|
import { parseSize } from "../../utils";
|
||||||
import { checkBanner, checkConnected } from "./expect";
|
import { checkBanner, checkConnected } from "./expect";
|
||||||
|
import { normalizeTargetExpect } from "./normalize";
|
||||||
import { tcpCheckerSchemas } from "./schema";
|
import { tcpCheckerSchemas } from "./schema";
|
||||||
import { validateTcpConfig } from "./validate";
|
import { validateTcpConfig } from "./validate";
|
||||||
|
|
||||||
@@ -203,6 +204,10 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
normalize(target: RawTargetConfig): RawTargetConfig {
|
||||||
|
return normalizeTargetExpect(target);
|
||||||
|
}
|
||||||
|
|
||||||
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTcpTarget {
|
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTcpTarget {
|
||||||
const t = target as RawTargetConfig & { tcp: TcpTargetConfig; type: "tcp" };
|
const t = target as RawTargetConfig & { tcp: TcpTargetConfig; type: "tcp" };
|
||||||
|
|
||||||
|
|||||||
18
src/server/checker/runner/tcp/normalize.ts
Normal file
18
src/server/checker/runner/tcp/normalize.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { isPlainObject } from "es-toolkit";
|
||||||
|
|
||||||
|
import type { RawTargetConfig } from "../../types";
|
||||||
|
|
||||||
|
import { compactExpect, normalizeContent, 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, {
|
||||||
|
banner: normalizeContent(raw["banner"]),
|
||||||
|
connected: raw["connected"],
|
||||||
|
durationMs: normalizeValue(raw["durationMs"]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ export interface CheckerDefinition<TResolved extends ResolvedTargetBase = Resolv
|
|||||||
buildDetail(observation: Record<string, unknown>): null | string;
|
buildDetail(observation: Record<string, unknown>): null | string;
|
||||||
readonly configKey: string;
|
readonly configKey: string;
|
||||||
execute(target: TResolved, ctx: CheckerContext): Promise<CheckResult>;
|
execute(target: TResolved, ctx: CheckerContext): Promise<CheckResult>;
|
||||||
|
normalize(target: RawTargetConfig): RawTargetConfig;
|
||||||
resolve(target: RawTargetConfig, context: ResolveContext): TResolved;
|
resolve(target: RawTargetConfig, context: ResolveContext): TResolved;
|
||||||
readonly schemas: CheckerSchemas;
|
readonly schemas: CheckerSchemas;
|
||||||
serialize(target: TResolved): { config: string; target: string };
|
serialize(target: TResolved): { config: string; target: string };
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { checkValueExpectation } from "../../expect/value";
|
|||||||
import { parseSize } from "../../utils";
|
import { parseSize } from "../../utils";
|
||||||
import { decodePayload, encodeResponse } from "./encoding";
|
import { decodePayload, encodeResponse } from "./encoding";
|
||||||
import { checkResponded, checkResponseSize, checkResponseText, checkSourceHost, checkSourcePort } from "./expect";
|
import { checkResponded, checkResponseSize, checkResponseText, checkSourceHost, checkSourcePort } from "./expect";
|
||||||
|
import { normalizeTargetExpect } from "./normalize";
|
||||||
import { udpCheckerSchemas } from "./schema";
|
import { udpCheckerSchemas } from "./schema";
|
||||||
import { validateUdpConfig } from "./validate";
|
import { validateUdpConfig } from "./validate";
|
||||||
|
|
||||||
@@ -295,6 +296,10 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
normalize(target: RawTargetConfig): RawTargetConfig {
|
||||||
|
return normalizeTargetExpect(target);
|
||||||
|
}
|
||||||
|
|
||||||
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedUdpTarget {
|
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedUdpTarget {
|
||||||
const t = target as RawTargetConfig & { type: "udp"; udp: UdpTargetConfig };
|
const t = target as RawTargetConfig & { type: "udp"; udp: UdpTargetConfig };
|
||||||
|
|
||||||
|
|||||||
21
src/server/checker/runner/udp/normalize.ts
Normal file
21
src/server/checker/runner/udp/normalize.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { isPlainObject } from "es-toolkit";
|
||||||
|
|
||||||
|
import type { RawTargetConfig } from "../../types";
|
||||||
|
|
||||||
|
import { compactExpect, normalizeContent, 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"]),
|
||||||
|
responded: raw["responded"],
|
||||||
|
response: normalizeContent(raw["response"]),
|
||||||
|
responseSize: normalizeValue(raw["responseSize"]),
|
||||||
|
sourceHost: normalizeValue(raw["sourceHost"]),
|
||||||
|
sourcePort: normalizeValue(raw["sourcePort"]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
533
src/server/checker/runner/ws/execute.ts
Normal file
533
src/server/checker/runner/ws/execute.ts
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
import { isError } from "es-toolkit";
|
||||||
|
|
||||||
|
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||||
|
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||||
|
import type { ResolvedWsExpectConfig, ResolvedWsTarget, WsTargetConfig } from "./types";
|
||||||
|
|
||||||
|
import { errorFailure } from "../../expect/failure";
|
||||||
|
import { checkValueExpectation } from "../../expect/value";
|
||||||
|
import { parseSize } from "../../utils";
|
||||||
|
import { checkConnected, checkHandshakeHeaders, checkMessage } from "./expect";
|
||||||
|
import { normalizeTargetExpect } from "./normalize";
|
||||||
|
import { wsCheckerSchemas } from "./schema";
|
||||||
|
import { validateWsConfig } from "./validate";
|
||||||
|
|
||||||
|
const DEFAULT_MAX_MESSAGE_BYTES = 4096;
|
||||||
|
const DEFAULT_RECEIVE_TIMEOUT = 5000;
|
||||||
|
|
||||||
|
type MessageReceiveResult = { data: string; ok: true; size: number } | { error: string; ok: false };
|
||||||
|
|
||||||
|
type WsConnectResult = { error: string; ok: false } | { headers: Record<string, string>; ok: true; ws: WebSocket };
|
||||||
|
|
||||||
|
export class WsChecker implements CheckerDefinition<ResolvedWsTarget> {
|
||||||
|
readonly configKey = "ws";
|
||||||
|
|
||||||
|
readonly schemas = wsCheckerSchemas;
|
||||||
|
|
||||||
|
readonly type = "ws";
|
||||||
|
|
||||||
|
buildDetail(observation: Record<string, unknown>): null | string {
|
||||||
|
const connected = observation["connected"];
|
||||||
|
if (connected !== true) {
|
||||||
|
const error = observation["error"];
|
||||||
|
return typeof error === "string" ? `connection failed: ${error}` : "not connected";
|
||||||
|
}
|
||||||
|
const connectTimeMs = observation["connectTimeMs"];
|
||||||
|
const message = observation["message"];
|
||||||
|
const parts: string[] = [`connected in ${typeof connectTimeMs === "number" ? connectTimeMs : "?"}ms`];
|
||||||
|
if (typeof message === "string" && message.length > 0) {
|
||||||
|
parts.push(`message: ${truncateMessage(message)}`);
|
||||||
|
}
|
||||||
|
return parts.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(t: ResolvedWsTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const start = performance.now();
|
||||||
|
const expect = t.expect;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const connectResult = await wsConnect(t.ws, ctx.signal);
|
||||||
|
|
||||||
|
if (!connectResult.ok) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
const observation: Record<string, unknown> = {
|
||||||
|
connected: false,
|
||||||
|
connectTimeMs: null,
|
||||||
|
error: connectResult.error,
|
||||||
|
message: null,
|
||||||
|
messageSize: null,
|
||||||
|
};
|
||||||
|
if (expect?.connected === false) {
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: null,
|
||||||
|
matched: true,
|
||||||
|
observation,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: errorFailure("connect", "connect", connectResult.error),
|
||||||
|
matched: false,
|
||||||
|
observation,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ws = connectResult.ws;
|
||||||
|
const connectTimeMs = Math.round(performance.now() - start);
|
||||||
|
const handshakeHeaders = connectResult.headers;
|
||||||
|
|
||||||
|
if (ctx.signal.aborted) {
|
||||||
|
closeWs(ws);
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: errorFailure("connect", "connect", `连接超时 (${t.timeoutMs}ms)`),
|
||||||
|
matched: false,
|
||||||
|
observation: null,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedConnected = expect?.connected ?? true;
|
||||||
|
const connectedResult = checkConnected(true, expectedConnected);
|
||||||
|
if (!connectedResult.matched) {
|
||||||
|
closeWs(ws);
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: connectedResult.failure,
|
||||||
|
matched: false,
|
||||||
|
observation: {
|
||||||
|
connected: true,
|
||||||
|
connectTimeMs,
|
||||||
|
error: null,
|
||||||
|
handshakeHeaders,
|
||||||
|
message: null,
|
||||||
|
messageSize: null,
|
||||||
|
},
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expect?.handshakeHeaders) {
|
||||||
|
const headersResult = checkHandshakeHeaders(handshakeHeaders, expect.handshakeHeaders);
|
||||||
|
if (!headersResult.matched) {
|
||||||
|
closeWs(ws);
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: headersResult.failure,
|
||||||
|
matched: false,
|
||||||
|
observation: {
|
||||||
|
connected: true,
|
||||||
|
connectTimeMs,
|
||||||
|
error: null,
|
||||||
|
handshakeHeaders,
|
||||||
|
message: null,
|
||||||
|
messageSize: null,
|
||||||
|
},
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let messageText: null | string = null;
|
||||||
|
let messageSize: null | number = null;
|
||||||
|
|
||||||
|
if (t.ws.send) {
|
||||||
|
const messageResult: MessageReceiveResult = await wsSendAndReceive(
|
||||||
|
ws,
|
||||||
|
t.ws.send,
|
||||||
|
t.ws.receiveTimeout,
|
||||||
|
t.ws.maxMessageBytes,
|
||||||
|
ctx.signal,
|
||||||
|
);
|
||||||
|
if (!messageResult.ok) {
|
||||||
|
closeWs(ws);
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
const observation: Record<string, unknown> = {
|
||||||
|
connected: true,
|
||||||
|
connectTimeMs,
|
||||||
|
error: messageResult.error,
|
||||||
|
handshakeHeaders,
|
||||||
|
message: null,
|
||||||
|
messageSize: null,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: errorFailure("message", "message", messageResult.error),
|
||||||
|
matched: false,
|
||||||
|
observation,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
messageText = truncateMessageForObservation(messageResult.data);
|
||||||
|
messageSize = messageResult.size;
|
||||||
|
|
||||||
|
if (expect?.message) {
|
||||||
|
const msgCheck = checkMessage(messageResult.data, expect.message);
|
||||||
|
if (!msgCheck.matched) {
|
||||||
|
closeWs(ws);
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: msgCheck.failure,
|
||||||
|
matched: false,
|
||||||
|
observation: {
|
||||||
|
connected: true,
|
||||||
|
connectTimeMs,
|
||||||
|
error: null,
|
||||||
|
handshakeHeaders,
|
||||||
|
message: messageText,
|
||||||
|
messageSize,
|
||||||
|
},
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeWs(ws);
|
||||||
|
|
||||||
|
const observation: Record<string, unknown> = {
|
||||||
|
connected: true,
|
||||||
|
connectTimeMs,
|
||||||
|
error: null,
|
||||||
|
handshakeHeaders,
|
||||||
|
message: messageText,
|
||||||
|
messageSize,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (expect?.connectTimeMs) {
|
||||||
|
const ctResult = checkValueExpectation(connectTimeMs, expect.connectTimeMs, {
|
||||||
|
message: "connectTimeMs mismatch",
|
||||||
|
path: "connectTimeMs",
|
||||||
|
phase: "connect",
|
||||||
|
});
|
||||||
|
if (!ctResult.matched) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: ctResult.failure,
|
||||||
|
matched: false,
|
||||||
|
observation,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
const durationResult = checkValueExpectation(durationMs, expect?.durationMs, {
|
||||||
|
message: "durationMs mismatch",
|
||||||
|
path: "durationMs",
|
||||||
|
phase: "duration",
|
||||||
|
});
|
||||||
|
if (!durationResult.matched) {
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: durationResult.failure,
|
||||||
|
matched: false,
|
||||||
|
observation,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: null,
|
||||||
|
matched: true,
|
||||||
|
observation,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: errorFailure(
|
||||||
|
"connect",
|
||||||
|
"connect",
|
||||||
|
ctx.signal.aborted ? `连接超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
|
||||||
|
),
|
||||||
|
matched: false,
|
||||||
|
observation: null,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize(target: RawTargetConfig): RawTargetConfig {
|
||||||
|
return normalizeTargetExpect(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedWsTarget {
|
||||||
|
const t = target as RawTargetConfig & { type: "ws"; ws: WsTargetConfig };
|
||||||
|
|
||||||
|
const maxMessageBytes = parseSize(t.ws.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES);
|
||||||
|
const receiveTimeout = t.ws.receiveTimeout ?? DEFAULT_RECEIVE_TIMEOUT;
|
||||||
|
|
||||||
|
const expect = target.expect as ResolvedWsExpectConfig | undefined;
|
||||||
|
const resolvedExpect: ResolvedWsExpectConfig = expect
|
||||||
|
? { ...expect, connected: expect.connected ?? true }
|
||||||
|
: { connected: true };
|
||||||
|
|
||||||
|
return {
|
||||||
|
description: null,
|
||||||
|
expect: resolvedExpect,
|
||||||
|
group: target.group ?? "default",
|
||||||
|
id: t.id,
|
||||||
|
intervalMs: context.defaultIntervalMs,
|
||||||
|
name: t.name ?? null,
|
||||||
|
timeoutMs: context.defaultTimeoutMs,
|
||||||
|
type: "ws",
|
||||||
|
ws: {
|
||||||
|
headers: { ...(t.ws.headers ?? {}) },
|
||||||
|
ignoreSSL: t.ws.ignoreSSL ?? false,
|
||||||
|
maxMessageBytes,
|
||||||
|
receiveTimeout,
|
||||||
|
send: t.ws.send,
|
||||||
|
subprotocols: t.ws.subprotocols ?? [],
|
||||||
|
url: t.ws.url,
|
||||||
|
},
|
||||||
|
} satisfies ResolvedWsTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
serialize(t: ResolvedWsTarget): { config: string; target: string } {
|
||||||
|
return {
|
||||||
|
config: JSON.stringify({
|
||||||
|
headers: t.ws.headers,
|
||||||
|
ignoreSSL: t.ws.ignoreSSL,
|
||||||
|
maxMessageBytes: t.ws.maxMessageBytes,
|
||||||
|
receiveTimeout: t.ws.receiveTimeout,
|
||||||
|
send: t.ws.send,
|
||||||
|
subprotocols: t.ws.subprotocols,
|
||||||
|
url: t.ws.url,
|
||||||
|
}),
|
||||||
|
target: t.ws.url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(input: CheckerValidationInput) {
|
||||||
|
return validateWsConfig(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeWs(ws: WebSocket) {
|
||||||
|
try {
|
||||||
|
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* best-effort close */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function simplifyConnectError(message: string): string {
|
||||||
|
const lower = message.toLowerCase();
|
||||||
|
if (lower.includes("econnrefused") || lower.includes("connection refused")) return "connection refused";
|
||||||
|
if (lower.includes("enoent") || lower.includes("not found")) return "host not found";
|
||||||
|
if (lower.includes("etimedout") || lower.includes("timed out")) return "connection timed out";
|
||||||
|
if (lower.includes("econnreset") || lower.includes("reset")) return "connection reset";
|
||||||
|
if (lower.includes("enetwork") || lower.includes("network")) return "network error";
|
||||||
|
if (lower.includes("certificate") || lower.includes("cert") || lower.includes("ssl") || lower.includes("tls")) {
|
||||||
|
return "tls error: certificate verification failed";
|
||||||
|
}
|
||||||
|
if (lower.includes("401") || lower.includes("unauthorized")) return "handshake failed: unauthorized (401)";
|
||||||
|
if (lower.includes("403") || lower.includes("forbidden")) return "handshake failed: forbidden (403)";
|
||||||
|
if (lower.includes("404") || lower.includes("not found")) return "handshake failed: not found (404)";
|
||||||
|
if (lower.includes("handshake") || lower.includes("upgrade")) return `handshake failed: ${message}`;
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateMessage(message: string, maxLen = 80): string {
|
||||||
|
if (message.length <= maxLen) return message;
|
||||||
|
return `${message.slice(0, maxLen)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateMessageForObservation(message: string, maxLen = 256): string {
|
||||||
|
if (message.length <= maxLen) return message;
|
||||||
|
return message.slice(0, maxLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wsConnect(config: ResolvedWsTarget["ws"], signal: AbortSignal): Promise<WsConnectResult> {
|
||||||
|
if (signal.aborted) {
|
||||||
|
return { error: "连接已取消", ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
let settled = false;
|
||||||
|
let resolveFn: ((result: WsConnectResult) => void) | undefined;
|
||||||
|
const connectPromise = new Promise<WsConnectResult>((resolve) => {
|
||||||
|
resolveFn = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const settle = (result: WsConnectResult) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
resolveFn!(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const wsOptions: Bun.WebSocketOptions = {};
|
||||||
|
if (Object.keys(config.headers).length > 0) {
|
||||||
|
(wsOptions as Record<string, unknown>)["headers"] = config.headers;
|
||||||
|
}
|
||||||
|
if (config.ignoreSSL) {
|
||||||
|
(wsOptions as Record<string, unknown>)["tls"] = { rejectUnauthorized: false };
|
||||||
|
}
|
||||||
|
if (config.subprotocols.length > 0) {
|
||||||
|
(wsOptions as Record<string, unknown>)["protocols"] = config.subprotocols;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ws = new WebSocket(config.url, wsOptions as never);
|
||||||
|
|
||||||
|
const onAbort = () => {
|
||||||
|
settle({ error: "连接超时", ok: false });
|
||||||
|
closeWs(ws);
|
||||||
|
};
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
|
||||||
|
ws.addEventListener("open", () => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (ws.protocol) {
|
||||||
|
headers["sec-websocket-protocol"] = ws.protocol;
|
||||||
|
}
|
||||||
|
settle({ headers, ok: true, ws });
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener("error", () => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
settle({ error: "连接失败", ok: false });
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener("close", (event) => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
if (!settled) {
|
||||||
|
const code = event.code;
|
||||||
|
const reason = event.reason || "";
|
||||||
|
if (code >= 1000 && code < 2000) {
|
||||||
|
settle({
|
||||||
|
error: `handshake failed: server closed with code ${code}${reason ? `: ${reason}` : ""}`,
|
||||||
|
ok: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
settle({ error: "连接关闭", ok: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return await connectPromise;
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
return { error: "连接超时", ok: false };
|
||||||
|
}
|
||||||
|
const message = isError(error) ? error.message : String(error);
|
||||||
|
return { error: simplifyConnectError(message), ok: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wsSendAndReceive(
|
||||||
|
ws: WebSocket,
|
||||||
|
sendText: string,
|
||||||
|
receiveTimeout: number,
|
||||||
|
maxMessageBytes: number,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<MessageReceiveResult> {
|
||||||
|
let settled = false;
|
||||||
|
let resolveFn: ((result: MessageReceiveResult) => void) | undefined;
|
||||||
|
const messagePromise = new Promise<MessageReceiveResult>((resolve) => {
|
||||||
|
resolveFn = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const settle = (result: MessageReceiveResult) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
resolveFn!(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
settle({ error: `等待响应超时 (${receiveTimeout}ms)`, ok: false });
|
||||||
|
}, receiveTimeout);
|
||||||
|
|
||||||
|
const onAbort = () => {
|
||||||
|
settle({ error: "探测已取消", ok: false });
|
||||||
|
};
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
ws.removeEventListener("message", onMessage);
|
||||||
|
ws.removeEventListener("close", onClose);
|
||||||
|
ws.removeEventListener("error", onError);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMessage = (event: MessageEvent) => {
|
||||||
|
if (settled) return;
|
||||||
|
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data as Uint8Array);
|
||||||
|
const size = new TextEncoder().encode(data).byteLength;
|
||||||
|
if (size > maxMessageBytes) {
|
||||||
|
settle({ error: `消息超过 ${maxMessageBytes} 字节限制 (${size} bytes)`, ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settle({ data, ok: true, size });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClose = (event: CloseEvent) => {
|
||||||
|
if (settled) return;
|
||||||
|
const code = event.code;
|
||||||
|
const reason = event.reason || "";
|
||||||
|
settle({ error: `服务端关闭连接: code=${code}${reason ? ` reason=${reason}` : ""}`, ok: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settle({ error: "连接错误", ok: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.addEventListener("message", onMessage);
|
||||||
|
ws.addEventListener("close", onClose);
|
||||||
|
ws.addEventListener("error", onError);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ws.send(sendText);
|
||||||
|
} catch (error) {
|
||||||
|
cleanup();
|
||||||
|
return { error: isError(error) ? error.message : "发送消息失败", ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await messagePromise;
|
||||||
|
cleanup();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
30
src/server/checker/runner/ws/expect.ts
Normal file
30
src/server/checker/runner/ws/expect.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { ContentExpectations, ExpectationResult, KeyedExpectations } from "../../expect/types";
|
||||||
|
|
||||||
|
import { checkContentExpectations } from "../../expect/content";
|
||||||
|
import { mismatchFailure } from "../../expect/failure";
|
||||||
|
import { checkHeaderExpectations } from "../../expect/headers";
|
||||||
|
|
||||||
|
export function checkConnected(connected: boolean, expected: boolean): ExpectationResult {
|
||||||
|
if (connected === expected) return { failure: null, matched: true };
|
||||||
|
if (!connected && expected) {
|
||||||
|
return {
|
||||||
|
failure: mismatchFailure("connect", "connected", true, false, "期望 WebSocket 连接成功但连接失败"),
|
||||||
|
matched: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
failure: mismatchFailure("connect", "connected", false, true, "期望 WebSocket 连接失败但连接成功"),
|
||||||
|
matched: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkHandshakeHeaders(
|
||||||
|
headers: Record<string, unknown>,
|
||||||
|
expectations: KeyedExpectations | undefined,
|
||||||
|
): ExpectationResult {
|
||||||
|
return checkHeaderExpectations(headers, expectations);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkMessage(message: string, expectations: ContentExpectations): ExpectationResult {
|
||||||
|
return checkContentExpectations(message, expectations, { path: "message", phase: "message" });
|
||||||
|
}
|
||||||
1
src/server/checker/runner/ws/index.ts
Normal file
1
src/server/checker/runner/ws/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { WsChecker } from "./execute";
|
||||||
20
src/server/checker/runner/ws/normalize.ts
Normal file
20
src/server/checker/runner/ws/normalize.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { isPlainObject } from "es-toolkit";
|
||||||
|
|
||||||
|
import type { RawTargetConfig } from "../../types";
|
||||||
|
|
||||||
|
import { compactExpect, normalizeContent, normalizeKeyed, 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, {
|
||||||
|
connected: raw["connected"],
|
||||||
|
connectTimeMs: normalizeValue(raw["connectTimeMs"]),
|
||||||
|
durationMs: normalizeValue(raw["durationMs"]),
|
||||||
|
handshakeHeaders: normalizeKeyed(raw["handshakeHeaders"]),
|
||||||
|
message: normalizeContent(raw["message"]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
66
src/server/checker/runner/ws/schema.ts
Normal file
66
src/server/checker/runner/ws/schema.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
import type { CheckerSchemas } from "../types";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createAuthoringContentExpectationsSchema,
|
||||||
|
createAuthoringFieldSchema,
|
||||||
|
createAuthoringKeyedExpectationsSchema,
|
||||||
|
createAuthoringStringMapSchema,
|
||||||
|
createAuthoringValueExpectationSchema,
|
||||||
|
createNormalizedContentExpectationsSchema,
|
||||||
|
createNormalizedKeyedExpectationsSchema,
|
||||||
|
createNormalizedValueExpectationSchema,
|
||||||
|
sizeSchema,
|
||||||
|
stringMapSchema,
|
||||||
|
} from "../../schema/fragments";
|
||||||
|
|
||||||
|
export const wsCheckerSchemas: CheckerSchemas = {
|
||||||
|
authoring: {
|
||||||
|
config: createWsConfigSchema("authoring"),
|
||||||
|
expect: createWsExpectSchema("authoring"),
|
||||||
|
},
|
||||||
|
normalized: {
|
||||||
|
config: createWsConfigSchema("normalized"),
|
||||||
|
expect: createWsExpectSchema("normalized"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function createWsConfigSchema(kind: "authoring" | "normalized") {
|
||||||
|
const bool = Type.Boolean();
|
||||||
|
const timeout = Type.Number({ minimum: 0 });
|
||||||
|
return Type.Object(
|
||||||
|
{
|
||||||
|
headers: Type.Optional(kind === "authoring" ? createAuthoringStringMapSchema() : stringMapSchema),
|
||||||
|
ignoreSSL: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(bool) : bool),
|
||||||
|
maxMessageBytes: Type.Optional(sizeSchema),
|
||||||
|
receiveTimeout: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(timeout) : timeout),
|
||||||
|
send: Type.Optional(Type.String()),
|
||||||
|
subprotocols: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
|
||||||
|
url: Type.String({ minLength: 1 }),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWsExpectSchema(kind: "authoring" | "normalized") {
|
||||||
|
const connected = Type.Boolean();
|
||||||
|
return Type.Object(
|
||||||
|
{
|
||||||
|
connected: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(connected) : connected),
|
||||||
|
connectTimeMs: Type.Optional(
|
||||||
|
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
|
||||||
|
),
|
||||||
|
durationMs: Type.Optional(
|
||||||
|
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
|
||||||
|
),
|
||||||
|
handshakeHeaders: Type.Optional(
|
||||||
|
kind === "authoring" ? createAuthoringKeyedExpectationsSchema() : createNormalizedKeyedExpectationsSchema(),
|
||||||
|
),
|
||||||
|
message: Type.Optional(
|
||||||
|
kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/server/checker/runner/ws/types.ts
Normal file
55
src/server/checker/runner/ws/types.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type {
|
||||||
|
ContentExpectations,
|
||||||
|
KeyedExpectations,
|
||||||
|
RawContentExpectations,
|
||||||
|
RawKeyedExpectations,
|
||||||
|
RawValueExpectation,
|
||||||
|
ValueExpectation,
|
||||||
|
} from "../../expect/types";
|
||||||
|
import type { ResolvedTargetBase } from "../../types";
|
||||||
|
|
||||||
|
export interface RawWsExpectConfig {
|
||||||
|
connected?: boolean;
|
||||||
|
connectTimeMs?: RawValueExpectation;
|
||||||
|
durationMs?: RawValueExpectation;
|
||||||
|
handshakeHeaders?: RawKeyedExpectations;
|
||||||
|
message?: RawContentExpectations;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedWsConfig {
|
||||||
|
headers: Record<string, string>;
|
||||||
|
ignoreSSL: boolean;
|
||||||
|
maxMessageBytes: number;
|
||||||
|
receiveTimeout: number;
|
||||||
|
send?: string;
|
||||||
|
subprotocols: string[];
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedWsExpectConfig {
|
||||||
|
connected: boolean;
|
||||||
|
connectTimeMs?: ValueExpectation;
|
||||||
|
durationMs?: ValueExpectation;
|
||||||
|
handshakeHeaders?: KeyedExpectations;
|
||||||
|
message?: ContentExpectations;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedWsTarget extends ResolvedTargetBase {
|
||||||
|
expect?: ResolvedWsExpectConfig;
|
||||||
|
group: string;
|
||||||
|
intervalMs: number;
|
||||||
|
name: null | string;
|
||||||
|
timeoutMs: number;
|
||||||
|
type: "ws";
|
||||||
|
ws: ResolvedWsConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WsTargetConfig {
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
ignoreSSL?: boolean;
|
||||||
|
maxMessageBytes?: number | string;
|
||||||
|
receiveTimeout?: number;
|
||||||
|
send?: string;
|
||||||
|
subprotocols?: string[];
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
215
src/server/checker/runner/ws/validate.ts
Normal file
215
src/server/checker/runner/ws/validate.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { isNumber, isString } from "es-toolkit";
|
||||||
|
|
||||||
|
import type { ConfigValidationIssue } from "../../schema/issues";
|
||||||
|
import type { CheckerValidationInput } from "../types";
|
||||||
|
|
||||||
|
import {
|
||||||
|
isPlainRecord,
|
||||||
|
validateRawContentExpectations,
|
||||||
|
validateRawKeyedExpectations,
|
||||||
|
validateRawValueExpectation,
|
||||||
|
} from "../../expect/validate";
|
||||||
|
import { issue, joinPath } from "../../schema/issues";
|
||||||
|
|
||||||
|
const ALLOWED_PROTOCOLS = new Set(["ws:", "wss:"]);
|
||||||
|
|
||||||
|
export function validateWsConfig(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"] !== "ws") continue;
|
||||||
|
issues.push(...validateWsTarget(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 validateWsExpect(target: Record<string, unknown>, path: string, hasSend: boolean): ConfigValidationIssue[] {
|
||||||
|
const targetName = getTargetName(target);
|
||||||
|
const expect = target["expect"];
|
||||||
|
if (expect === undefined || expect === null || !isPlainRecord(expect)) return [];
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
const expectPath = joinPath(path, "expect");
|
||||||
|
|
||||||
|
if (expect["connected"] !== undefined && typeof expect["connected"] !== "boolean") {
|
||||||
|
issues.push(issue("invalid-type", joinPath(expectPath, "connected"), "必须为布尔值", targetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectedFalse = expect["connected"] === false;
|
||||||
|
|
||||||
|
if (expect["handshakeHeaders"] !== undefined) {
|
||||||
|
if (connectedFalse) {
|
||||||
|
issues.push(
|
||||||
|
issue(
|
||||||
|
"invalid-value",
|
||||||
|
joinPath(expectPath, "handshakeHeaders"),
|
||||||
|
"handshakeHeaders 断言需要 expect.connected 为 true",
|
||||||
|
targetName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
issues.push(
|
||||||
|
...validateRawKeyedExpectations(
|
||||||
|
expect["handshakeHeaders"],
|
||||||
|
joinPath(expectPath, "handshakeHeaders"),
|
||||||
|
targetName,
|
||||||
|
{
|
||||||
|
caseInsensitive: true,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expect["message"] !== undefined) {
|
||||||
|
if (!hasSend) {
|
||||||
|
issues.push(issue("invalid-value", joinPath(expectPath, "message"), "message 断言需要配置 ws.send", targetName));
|
||||||
|
} else if (connectedFalse) {
|
||||||
|
issues.push(
|
||||||
|
issue(
|
||||||
|
"invalid-value",
|
||||||
|
joinPath(expectPath, "message"),
|
||||||
|
"message 断言需要 expect.connected 为 true",
|
||||||
|
targetName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
issues.push(...validateRawContentExpectations(expect["message"], joinPath(expectPath, "message"), targetName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expect["connectTimeMs"] !== undefined) {
|
||||||
|
if (connectedFalse) {
|
||||||
|
issues.push(
|
||||||
|
issue(
|
||||||
|
"invalid-value",
|
||||||
|
joinPath(expectPath, "connectTimeMs"),
|
||||||
|
"connectTimeMs 断言需要 expect.connected 为 true",
|
||||||
|
targetName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
issues.push(
|
||||||
|
...validateRawValueExpectation(expect["connectTimeMs"], joinPath(expectPath, "connectTimeMs"), targetName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expect["durationMs"] !== undefined) {
|
||||||
|
issues.push(...validateRawValueExpectation(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedKeys = new Set(["connected", "connectTimeMs", "durationMs", "handshakeHeaders", "message"]);
|
||||||
|
for (const key of Object.keys(expect)) {
|
||||||
|
if (!allowedKeys.has(key)) {
|
||||||
|
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateWsTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
const targetName = getTargetName(target);
|
||||||
|
const ws = target["ws"];
|
||||||
|
|
||||||
|
if (!isPlainRecord(ws)) {
|
||||||
|
issues.push(issue("required", joinPath(path, "ws"), "缺少 ws.url 字段", targetName));
|
||||||
|
issues.push(...validateWsExpect(target, path, false));
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isString(ws["url"]) || ws["url"].trim() === "") {
|
||||||
|
issues.push(issue("required", joinPath(joinPath(path, "ws"), "url"), "缺少 ws.url 字段", targetName));
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const url = new URL(ws["url"]);
|
||||||
|
if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
|
||||||
|
issues.push(
|
||||||
|
issue(
|
||||||
|
"invalid-url",
|
||||||
|
joinPath(joinPath(path, "ws"), "url"),
|
||||||
|
"格式不合法,必须以 ws:// 或 wss:// 开头",
|
||||||
|
targetName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
issues.push(issue("invalid-url", joinPath(joinPath(path, "ws"), "url"), "格式不合法", targetName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ws["subprotocols"] !== undefined) {
|
||||||
|
if (!Array.isArray(ws["subprotocols"])) {
|
||||||
|
issues.push(
|
||||||
|
issue("invalid-type", joinPath(joinPath(path, "ws"), "subprotocols"), "必须为字符串数组", targetName),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < ws["subprotocols"].length; i++) {
|
||||||
|
const sp = ws["subprotocols"][i] as unknown;
|
||||||
|
if (!isString(sp) || sp.trim() === "") {
|
||||||
|
issues.push(
|
||||||
|
issue(
|
||||||
|
"invalid-value",
|
||||||
|
`${joinPath(joinPath(path, "ws"), "subprotocols")}[${i}]`,
|
||||||
|
"必须为非空字符串",
|
||||||
|
targetName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ws["ignoreSSL"] !== undefined && typeof ws["ignoreSSL"] !== "boolean") {
|
||||||
|
issues.push(issue("invalid-type", joinPath(joinPath(path, "ws"), "ignoreSSL"), "必须为布尔值", targetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
ws["receiveTimeout"] !== undefined &&
|
||||||
|
!(isNumber(ws["receiveTimeout"]) && Number.isFinite(ws["receiveTimeout"]) && ws["receiveTimeout"] >= 0)
|
||||||
|
) {
|
||||||
|
issues.push(
|
||||||
|
issue("invalid-type", joinPath(joinPath(path, "ws"), "receiveTimeout"), "必须为非负有限数字", targetName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ws["maxMessageBytes"] !== undefined) {
|
||||||
|
if (
|
||||||
|
!isString(ws["maxMessageBytes"]) &&
|
||||||
|
!(isNumber(ws["maxMessageBytes"]) && Number.isFinite(ws["maxMessageBytes"]) && ws["maxMessageBytes"] >= 0)
|
||||||
|
) {
|
||||||
|
issues.push(
|
||||||
|
issue("invalid-value", joinPath(joinPath(path, "ws"), "maxMessageBytes"), "必须为合法 size 值", targetName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedWsKeys = new Set([
|
||||||
|
"headers",
|
||||||
|
"ignoreSSL",
|
||||||
|
"maxMessageBytes",
|
||||||
|
"receiveTimeout",
|
||||||
|
"send",
|
||||||
|
"subprotocols",
|
||||||
|
"url",
|
||||||
|
]);
|
||||||
|
for (const key of Object.keys(ws)) {
|
||||||
|
if (!allowedWsKeys.has(key)) {
|
||||||
|
issues.push(issue("unknown-field", joinPath(joinPath(path, "ws"), key), "是未知字段", targetName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSend = isString(ws["send"]) && ws["send"].length > 0;
|
||||||
|
issues.push(...validateWsExpect(target, path, hasSend));
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import Ajv from "ajv";
|
import Ajv from "ajv";
|
||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { normalizeAuthoringConfig } from "../../../../src/server/checker/normalizer";
|
||||||
import { createDefaultCheckerRegistry } from "../../../../src/server/checker/runner";
|
import { createDefaultCheckerRegistry } from "../../../../src/server/checker/runner";
|
||||||
import { createProbeConfigJsonSchema } from "../../../../src/server/checker/schema/export";
|
import { createProbeConfigJsonSchema } from "../../../../src/server/checker/schema/export";
|
||||||
import { formatConfigIssues, issue } from "../../../../src/server/checker/schema/issues";
|
import { formatConfigIssues, issue } from "../../../../src/server/checker/schema/issues";
|
||||||
@@ -274,4 +275,115 @@ describe("config contract", () => {
|
|||||||
}),
|
}),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("所有 checker 的 authoring ValueMatcher 简写经 normalize 后通过 normalized contract 校验", () => {
|
||||||
|
const authoringShorthandExamples: Record<string, object> = {
|
||||||
|
cmd: {
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
cmd: { exec: "echo hello" },
|
||||||
|
expect: { durationMs: 1000 },
|
||||||
|
id: "cmd-test",
|
||||||
|
type: "cmd",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
db: {
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
db: { url: "sqlite://:memory:" },
|
||||||
|
expect: { durationMs: 2000 },
|
||||||
|
id: "db-test",
|
||||||
|
type: "db",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
dns: {
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
dns: { name: "example.com", resolver: "system" },
|
||||||
|
expect: { durationMs: 500 },
|
||||||
|
id: "dns-test",
|
||||||
|
type: "dns",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
http: {
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
expect: { durationMs: 5000 },
|
||||||
|
http: { url: "https://example.com" },
|
||||||
|
id: "http-test",
|
||||||
|
type: "http",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
icmp: {
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
expect: { packetLossPercent: 0 },
|
||||||
|
icmp: { host: "example.com" },
|
||||||
|
id: "icmp-test",
|
||||||
|
type: "icmp",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
llm: {
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
expect: { durationMs: 10000 },
|
||||||
|
id: "llm-test",
|
||||||
|
llm: {
|
||||||
|
model: "gpt-4o-mini",
|
||||||
|
prompt: "ping",
|
||||||
|
provider: "openai",
|
||||||
|
url: "https://example.com/v1/chat/completions",
|
||||||
|
},
|
||||||
|
type: "llm",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
tcp: {
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
expect: { durationMs: 3000 },
|
||||||
|
id: "tcp-test",
|
||||||
|
tcp: { host: "example.com", port: 80 },
|
||||||
|
type: "tcp",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
udp: {
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
expect: { durationMs: 1000 },
|
||||||
|
id: "udp-test",
|
||||||
|
type: "udp",
|
||||||
|
udp: { host: "example.com", port: 53 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ws: {
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
expect: { durationMs: 5000 },
|
||||||
|
id: "ws-test",
|
||||||
|
type: "ws",
|
||||||
|
ws: { url: "wss://example.com/ws" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [type, config] of Object.entries(authoringShorthandExamples)) {
|
||||||
|
const normalizeResult = normalizeAuthoringConfig(config, createDefaultCheckerRegistry());
|
||||||
|
expect(normalizeResult.issues).toHaveLength(0);
|
||||||
|
const contract = validateProbeConfigContract(normalizeResult.config, createDefaultCheckerRegistry());
|
||||||
|
expect(contract.config).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
contract.issues,
|
||||||
|
`Checker "${type}" authoring shorthand should pass normalized contract, got issues: ${JSON.stringify(contract.issues.map((i) => `${i.path}: ${i.message}`))}`,
|
||||||
|
).toHaveLength(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -886,6 +886,144 @@ targets:
|
|||||||
await expectConfigLoadError(configPath, "无效的时长格式");
|
await expectConfigLoadError(configPath, "无效的时长格式");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("interval 小于 10s 抛出错误", async () => {
|
||||||
|
const configPath = join(tempDir, "interval-too-small.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`targets:
|
||||||
|
- name: "t"
|
||||||
|
id: "t"
|
||||||
|
type: http
|
||||||
|
interval: "9s"
|
||||||
|
http:
|
||||||
|
url: "http://a.com"
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
await expectConfigLoadError(configPath, "interval 不能小于 10s");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("interval 9999ms 抛出错误", async () => {
|
||||||
|
const configPath = join(tempDir, "interval-9999ms.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`targets:
|
||||||
|
- name: "t"
|
||||||
|
id: "t"
|
||||||
|
type: http
|
||||||
|
interval: "9999ms"
|
||||||
|
http:
|
||||||
|
url: "http://a.com"
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
await expectConfigLoadError(configPath, "interval 不能小于 10s");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("interval 10s 通过", async () => {
|
||||||
|
const configPath = join(tempDir, "interval-10s.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`targets:
|
||||||
|
- name: "t"
|
||||||
|
id: "t"
|
||||||
|
type: http
|
||||||
|
interval: "10s"
|
||||||
|
http:
|
||||||
|
url: "http://a.com"
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
const config = await loadConfig(configPath);
|
||||||
|
expect(config.targets[0]!.intervalMs).toBe(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("interval 10000ms 通过", async () => {
|
||||||
|
const configPath = join(tempDir, "interval-10000ms.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`targets:
|
||||||
|
- name: "t"
|
||||||
|
id: "t"
|
||||||
|
type: http
|
||||||
|
interval: "10000ms"
|
||||||
|
http:
|
||||||
|
url: "http://a.com"
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
const config = await loadConfig(configPath);
|
||||||
|
expect(config.targets[0]!.intervalMs).toBe(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("timeout 大于 interval 抛出错误", async () => {
|
||||||
|
const configPath = join(tempDir, "timeout-gt-interval.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`targets:
|
||||||
|
- name: "t"
|
||||||
|
id: "t"
|
||||||
|
type: http
|
||||||
|
interval: "10s"
|
||||||
|
timeout: "30s"
|
||||||
|
http:
|
||||||
|
url: "http://a.com"
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
await expectConfigLoadError(configPath, "timeout 不能大于 interval");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("timeout 等于 interval 通过", async () => {
|
||||||
|
const configPath = join(tempDir, "timeout-eq-interval.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`targets:
|
||||||
|
- name: "t"
|
||||||
|
id: "t"
|
||||||
|
type: http
|
||||||
|
interval: "30s"
|
||||||
|
timeout: "30s"
|
||||||
|
http:
|
||||||
|
url: "http://a.com"
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
const config = await loadConfig(configPath);
|
||||||
|
expect(config.targets[0]!.intervalMs).toBe(30000);
|
||||||
|
expect(config.targets[0]!.timeoutMs).toBe(30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("变量解析后 interval 小于 10s 抛出错误", async () => {
|
||||||
|
const configPath = join(tempDir, "var-interval-too-small.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`variables:
|
||||||
|
check_interval: "5s"
|
||||||
|
targets:
|
||||||
|
- name: "t"
|
||||||
|
id: "t"
|
||||||
|
type: http
|
||||||
|
interval: "\${check_interval}"
|
||||||
|
http:
|
||||||
|
url: "http://a.com"
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
await expectConfigLoadError(configPath, "interval 不能小于 10s");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("变量解析后 timeout 大于 interval 抛出错误", async () => {
|
||||||
|
const configPath = join(tempDir, "var-timeout-gt-interval.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`variables:
|
||||||
|
check_timeout: "60s"
|
||||||
|
targets:
|
||||||
|
- name: "t"
|
||||||
|
id: "t"
|
||||||
|
type: http
|
||||||
|
timeout: "\${check_timeout}"
|
||||||
|
http:
|
||||||
|
url: "http://a.com"
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
await expectConfigLoadError(configPath, "timeout 不能大于 interval");
|
||||||
|
});
|
||||||
|
|
||||||
test("解析 expect 配置", async () => {
|
test("解析 expect 配置", async () => {
|
||||||
const configPath = join(tempDir, "expect.yaml");
|
const configPath = join(tempDir, "expect.yaml");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
@@ -2132,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 配置", () => {
|
describe("logging 配置", () => {
|
||||||
test("logging 全部缺省时使用默认值", async () => {
|
test("logging 全部缺省时使用默认值", async () => {
|
||||||
const configPath = join(tempDir, "logging-default.yaml");
|
const configPath = join(tempDir, "logging-default.yaml");
|
||||||
|
|||||||
@@ -50,6 +50,14 @@ function ensureRegistered() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRunOnce(engine: ProbeEngine) {
|
||||||
|
return (
|
||||||
|
engine as unknown as {
|
||||||
|
runOnce: (t: ResolvedTargetBase) => Promise<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
).runOnce.bind(engine);
|
||||||
|
}
|
||||||
|
|
||||||
function makeCommandTarget(name: string, overrides?: Partial<ResolvedCommandTarget>): ResolvedCommandTarget {
|
function makeCommandTarget(name: string, overrides?: Partial<ResolvedCommandTarget>): ResolvedCommandTarget {
|
||||||
return {
|
return {
|
||||||
cmd: {
|
cmd: {
|
||||||
@@ -70,6 +78,19 @@ function makeCommandTarget(name: string, overrides?: Partial<ResolvedCommandTarg
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeMockResult(targetId: string, overrides?: Partial<Record<string, unknown>>) {
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs: 1,
|
||||||
|
failure: null,
|
||||||
|
matched: true,
|
||||||
|
observation: null,
|
||||||
|
targetId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("ProbeEngine", () => {
|
describe("ProbeEngine", () => {
|
||||||
test("start/stop 不抛错", () => {
|
test("start/stop 不抛错", () => {
|
||||||
ensureRegistered();
|
ensureRegistered();
|
||||||
@@ -81,15 +102,12 @@ describe("ProbeEngine", () => {
|
|||||||
expect(true).toBe(true);
|
expect(true).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("单次 probeGroup 执行 cmd 检查", async () => {
|
test("单次 runOnce 执行 cmd 检查", async () => {
|
||||||
const target = makeCommandTarget("cmd-echo");
|
const target = makeCommandTarget("cmd-echo");
|
||||||
const mockStore = createMockStore(["cmd-echo"]) as unknown as ProbeStore;
|
const mockStore = createMockStore(["cmd-echo"]) as unknown as ProbeStore;
|
||||||
const engine = new ProbeEngine(mockStore, [target]);
|
const engine = new ProbeEngine(mockStore, [target]);
|
||||||
|
|
||||||
const probeGroup = (
|
await getRunOnce(engine)(target);
|
||||||
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
|
|
||||||
).probeGroup.bind(engine);
|
|
||||||
await probeGroup([target]);
|
|
||||||
|
|
||||||
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
||||||
expect(results.length).toBe(1);
|
expect(results.length).toBe(1);
|
||||||
@@ -119,11 +137,9 @@ describe("ProbeEngine", () => {
|
|||||||
|
|
||||||
const mockStore = createMockStore(["echo-a", "echo-b"]) as unknown as ProbeStore;
|
const mockStore = createMockStore(["echo-a", "echo-b"]) as unknown as ProbeStore;
|
||||||
const engine = new ProbeEngine(mockStore, [targetA, targetB]);
|
const engine = new ProbeEngine(mockStore, [targetA, targetB]);
|
||||||
|
const runOnce = getRunOnce(engine);
|
||||||
|
|
||||||
const probeGroup = (
|
await Promise.all([runOnce(targetA), runOnce(targetB)]);
|
||||||
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
|
|
||||||
).probeGroup.bind(engine);
|
|
||||||
await probeGroup([targetA, targetB]);
|
|
||||||
|
|
||||||
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
||||||
expect(results.length).toBe(2);
|
expect(results.length).toBe(2);
|
||||||
@@ -143,11 +159,9 @@ describe("ProbeEngine", () => {
|
|||||||
|
|
||||||
const mockStore = createMockStore(["bad-cmd", "good-cmd"]) as unknown as ProbeStore;
|
const mockStore = createMockStore(["bad-cmd", "good-cmd"]) as unknown as ProbeStore;
|
||||||
const engine = new ProbeEngine(mockStore, [badTarget, goodTarget]);
|
const engine = new ProbeEngine(mockStore, [badTarget, goodTarget]);
|
||||||
|
const runOnce = getRunOnce(engine);
|
||||||
|
|
||||||
const probeGroup = (
|
await Promise.all([runOnce(badTarget), runOnce(goodTarget)]);
|
||||||
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
|
|
||||||
).probeGroup.bind(engine);
|
|
||||||
await probeGroup([badTarget, goodTarget]);
|
|
||||||
|
|
||||||
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
||||||
expect(results.length).toBe(2);
|
expect(results.length).toBe(2);
|
||||||
@@ -173,27 +187,28 @@ describe("ProbeEngine", () => {
|
|||||||
const goodTarget = makeCommandTarget("good-cmd");
|
const goodTarget = makeCommandTarget("good-cmd");
|
||||||
const mockStore = createMockStore(["reject-cmd", "good-cmd"]) as unknown as ProbeStore;
|
const mockStore = createMockStore(["reject-cmd", "good-cmd"]) as unknown as ProbeStore;
|
||||||
const engine = new ProbeEngine(mockStore, [rejectTarget, goodTarget]);
|
const engine = new ProbeEngine(mockStore, [rejectTarget, goodTarget]);
|
||||||
|
const runOnce = getRunOnce(engine);
|
||||||
|
|
||||||
const probeGroup = (
|
await Promise.all([runOnce(rejectTarget), runOnce(goodTarget)]);
|
||||||
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
|
|
||||||
).probeGroup.bind(engine);
|
|
||||||
await probeGroup([rejectTarget, goodTarget]);
|
|
||||||
|
|
||||||
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
||||||
expect(results.length).toBe(2);
|
expect(results.length).toBe(2);
|
||||||
expect(results[0]!["targetId"]).toBe("reject-cmd");
|
|
||||||
expect(results[0]!["matched"]).toBe(false);
|
const rejectResult = results.find((r) => r["targetId"] === "reject-cmd");
|
||||||
expect(results[0]!["durationMs"]).toBeNull();
|
const goodResult = results.find((r) => r["targetId"] === "good-cmd");
|
||||||
expect(results[0]!["observation"]).toBeNull();
|
expect(rejectResult).toBeDefined();
|
||||||
expect(results[0]!["failure"]).toEqual({
|
expect(rejectResult!["matched"]).toBe(false);
|
||||||
|
expect(rejectResult!["durationMs"]).toBeNull();
|
||||||
|
expect(rejectResult!["observation"]).toBeNull();
|
||||||
|
expect(rejectResult!["failure"]).toEqual({
|
||||||
kind: "error",
|
kind: "error",
|
||||||
message: "boom",
|
message: "boom",
|
||||||
path: "engine",
|
path: "engine",
|
||||||
phase: "internal",
|
phase: "internal",
|
||||||
});
|
});
|
||||||
expect(typeof results[0]!["timestamp"]).toBe("string");
|
expect(typeof rejectResult!["timestamp"]).toBe("string");
|
||||||
expect(results[1]!["targetId"]).toBe("good-cmd");
|
expect(goodResult).toBeDefined();
|
||||||
expect(results[1]!["matched"]).toBe(true);
|
expect(goodResult!["matched"]).toBe(true);
|
||||||
checker.execute = originalExecute;
|
checker.execute = originalExecute;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -212,11 +227,9 @@ describe("ProbeEngine", () => {
|
|||||||
|
|
||||||
const mockStore = createMockStore(targets.map((t) => t.name ?? t.id)) as unknown as ProbeStore;
|
const mockStore = createMockStore(targets.map((t) => t.name ?? t.id)) as unknown as ProbeStore;
|
||||||
const engine = new ProbeEngine(mockStore, targets, 2);
|
const engine = new ProbeEngine(mockStore, targets, 2);
|
||||||
|
const runOnce = getRunOnce(engine);
|
||||||
|
|
||||||
const probeGroup = (
|
await Promise.all(targets.map((t) => runOnce(t)));
|
||||||
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
|
|
||||||
).probeGroup.bind(engine);
|
|
||||||
await probeGroup(targets);
|
|
||||||
|
|
||||||
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
||||||
expect(results.length).toBe(5);
|
expect(results.length).toBe(5);
|
||||||
@@ -225,7 +238,7 @@ describe("ProbeEngine", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("groupByInterval 按间隔分组", () => {
|
test("不同 interval 的 target 独立调度", () => {
|
||||||
const targetA = makeCommandTarget("a", { intervalMs: 30000 });
|
const targetA = makeCommandTarget("a", { intervalMs: 30000 });
|
||||||
const targetB = makeCommandTarget("b", { intervalMs: 30000 });
|
const targetB = makeCommandTarget("b", { intervalMs: 30000 });
|
||||||
const targetC = makeCommandTarget("c", { intervalMs: 60000 });
|
const targetC = makeCommandTarget("c", { intervalMs: 60000 });
|
||||||
@@ -242,10 +255,7 @@ describe("ProbeEngine", () => {
|
|||||||
const mockStore = createMockStore(["other-name"]) as unknown as ProbeStore;
|
const mockStore = createMockStore(["other-name"]) as unknown as ProbeStore;
|
||||||
const engine = new ProbeEngine(mockStore, [target]);
|
const engine = new ProbeEngine(mockStore, [target]);
|
||||||
|
|
||||||
const probeGroup = (
|
await getRunOnce(engine)(target);
|
||||||
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
|
|
||||||
).probeGroup.bind(engine);
|
|
||||||
await probeGroup([target]);
|
|
||||||
|
|
||||||
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
||||||
expect(results.length).toBe(0);
|
expect(results.length).toBe(0);
|
||||||
@@ -281,10 +291,7 @@ describe("ProbeEngine", () => {
|
|||||||
const mockStore = createMockStore(["http-test"]) as unknown as ProbeStore;
|
const mockStore = createMockStore(["http-test"]) as unknown as ProbeStore;
|
||||||
const engine = new ProbeEngine(mockStore, [httpTarget]);
|
const engine = new ProbeEngine(mockStore, [httpTarget]);
|
||||||
|
|
||||||
const probeGroup = (
|
await getRunOnce(engine)(httpTarget);
|
||||||
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
|
|
||||||
).probeGroup.bind(engine);
|
|
||||||
await probeGroup([httpTarget]);
|
|
||||||
|
|
||||||
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
||||||
expect(results.length).toBe(1);
|
expect(results.length).toBe(1);
|
||||||
@@ -365,11 +372,9 @@ describe("ProbeEngine", () => {
|
|||||||
0,
|
0,
|
||||||
logger,
|
logger,
|
||||||
);
|
);
|
||||||
|
const runOnce = getRunOnce(engine);
|
||||||
|
|
||||||
const probeGroup = (
|
await Promise.all([runOnce(makeCommandTarget("fail-target")), runOnce(makeCommandTarget("ok-target"))]);
|
||||||
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
|
|
||||||
).probeGroup.bind(engine);
|
|
||||||
await probeGroup([makeCommandTarget("fail-target"), makeCommandTarget("ok-target")]);
|
|
||||||
|
|
||||||
const errorLogs = logger.entries.filter((e) => e.level === "error");
|
const errorLogs = logger.entries.filter((e) => e.level === "error");
|
||||||
expect(errorLogs.length).toBeGreaterThanOrEqual(1);
|
expect(errorLogs.length).toBeGreaterThanOrEqual(1);
|
||||||
@@ -393,25 +398,17 @@ describe("ProbeEngine", () => {
|
|||||||
const originalExecute = checker.execute.bind(checker);
|
const originalExecute = checker.execute.bind(checker);
|
||||||
checker.execute = async (target) => {
|
checker.execute = async (target) => {
|
||||||
if (target.id === targetId) {
|
if (target.id === targetId) {
|
||||||
return {
|
return makeMockResult(targetId, {
|
||||||
detail: null,
|
|
||||||
durationMs: 10,
|
durationMs: 10,
|
||||||
failure: { kind: "error", message: "fail", path: "exitCode", phase: "body" },
|
failure: { kind: "error", message: "fail", path: "exitCode", phase: "body" },
|
||||||
matched: false,
|
matched: false,
|
||||||
observation: null,
|
});
|
||||||
targetId,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return originalExecute(target, { signal: new AbortController().signal });
|
return originalExecute(target, { signal: new AbortController().signal });
|
||||||
};
|
};
|
||||||
|
|
||||||
const engine = new ProbeEngine(mockStore, [makeCommandTarget(targetId)], 20, 0, logger);
|
const engine = new ProbeEngine(mockStore, [makeCommandTarget(targetId)], 20, 0, logger);
|
||||||
|
await getRunOnce(engine)(makeCommandTarget(targetId));
|
||||||
const probeGroup = (
|
|
||||||
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
|
|
||||||
).probeGroup.bind(engine);
|
|
||||||
await probeGroup([makeCommandTarget(targetId)]);
|
|
||||||
|
|
||||||
const stateLogs = logger.entries.filter((e) => e.level === "warn" && e.msg.includes("UP → DOWN"));
|
const stateLogs = logger.entries.filter((e) => e.level === "warn" && e.msg.includes("UP → DOWN"));
|
||||||
expect(stateLogs.length).toBe(1);
|
expect(stateLogs.length).toBe(1);
|
||||||
@@ -432,11 +429,7 @@ describe("ProbeEngine", () => {
|
|||||||
} as unknown as ProbeStore;
|
} as unknown as ProbeStore;
|
||||||
|
|
||||||
const engine = new ProbeEngine(mockStore, [makeCommandTarget(targetId)], 20, 0, logger);
|
const engine = new ProbeEngine(mockStore, [makeCommandTarget(targetId)], 20, 0, logger);
|
||||||
|
await getRunOnce(engine)(makeCommandTarget(targetId));
|
||||||
const probeGroup = (
|
|
||||||
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
|
|
||||||
).probeGroup.bind(engine);
|
|
||||||
await probeGroup([makeCommandTarget(targetId)]);
|
|
||||||
|
|
||||||
const recoverLogs = logger.entries.filter((e) => e.level === "info" && e.msg.includes("DOWN → UP"));
|
const recoverLogs = logger.entries.filter((e) => e.level === "info" && e.msg.includes("DOWN → UP"));
|
||||||
expect(recoverLogs.length).toBe(1);
|
expect(recoverLogs.length).toBe(1);
|
||||||
@@ -455,11 +448,7 @@ describe("ProbeEngine", () => {
|
|||||||
} as unknown as ProbeStore;
|
} as unknown as ProbeStore;
|
||||||
|
|
||||||
const engine = new ProbeEngine(mockStore, [makeCommandTarget(targetId)], 20, 0, logger);
|
const engine = new ProbeEngine(mockStore, [makeCommandTarget(targetId)], 20, 0, logger);
|
||||||
|
await getRunOnce(engine)(makeCommandTarget(targetId));
|
||||||
const probeGroup = (
|
|
||||||
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
|
|
||||||
).probeGroup.bind(engine);
|
|
||||||
await probeGroup([makeCommandTarget(targetId)]);
|
|
||||||
|
|
||||||
const stateChangeLogs = logger.entries.filter(
|
const stateChangeLogs = logger.entries.filter(
|
||||||
(e) => e.msg.includes("UP → DOWN") || e.msg.includes("DOWN → UP") || e.msg.includes("首次 DOWN"),
|
(e) => e.msg.includes("UP → DOWN") || e.msg.includes("DOWN → UP") || e.msg.includes("首次 DOWN"),
|
||||||
@@ -477,25 +466,17 @@ describe("ProbeEngine", () => {
|
|||||||
const originalExecute = checker.execute.bind(checker);
|
const originalExecute = checker.execute.bind(checker);
|
||||||
checker.execute = async (target) => {
|
checker.execute = async (target) => {
|
||||||
if (target.id === targetId) {
|
if (target.id === targetId) {
|
||||||
return {
|
return makeMockResult(targetId, {
|
||||||
detail: null,
|
|
||||||
durationMs: 10,
|
durationMs: 10,
|
||||||
failure: { kind: "error", message: "fail", path: "exitCode", phase: "body" },
|
failure: { kind: "error", message: "fail", path: "exitCode", phase: "body" },
|
||||||
matched: false,
|
matched: false,
|
||||||
observation: null,
|
});
|
||||||
targetId,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return originalExecute(target, { signal: new AbortController().signal });
|
return originalExecute(target, { signal: new AbortController().signal });
|
||||||
};
|
};
|
||||||
|
|
||||||
const engine = new ProbeEngine(mockStore, [makeCommandTarget(targetId)], 20, 0, logger);
|
const engine = new ProbeEngine(mockStore, [makeCommandTarget(targetId)], 20, 0, logger);
|
||||||
|
await getRunOnce(engine)(makeCommandTarget(targetId));
|
||||||
const probeGroup = (
|
|
||||||
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
|
|
||||||
).probeGroup.bind(engine);
|
|
||||||
await probeGroup([makeCommandTarget(targetId)]);
|
|
||||||
|
|
||||||
const firstDownLogs = logger.entries.filter((e) => e.level === "warn" && e.msg.includes("首次 DOWN"));
|
const firstDownLogs = logger.entries.filter((e) => e.level === "warn" && e.msg.includes("首次 DOWN"));
|
||||||
expect(firstDownLogs.length).toBe(1);
|
expect(firstDownLogs.length).toBe(1);
|
||||||
@@ -510,11 +491,7 @@ describe("ProbeEngine", () => {
|
|||||||
const mockStore = createMockStore([targetId]) as unknown as ProbeStore;
|
const mockStore = createMockStore([targetId]) as unknown as ProbeStore;
|
||||||
|
|
||||||
const engine = new ProbeEngine(mockStore, [makeCommandTarget(targetId)], 20, 0, logger);
|
const engine = new ProbeEngine(mockStore, [makeCommandTarget(targetId)], 20, 0, logger);
|
||||||
|
await getRunOnce(engine)(makeCommandTarget(targetId));
|
||||||
const probeGroup = (
|
|
||||||
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
|
|
||||||
).probeGroup.bind(engine);
|
|
||||||
await probeGroup([makeCommandTarget(targetId)]);
|
|
||||||
|
|
||||||
const debugLogs = logger.entries.filter((e) => e.level === "debug");
|
const debugLogs = logger.entries.filter((e) => e.level === "debug");
|
||||||
expect(debugLogs.length).toBeGreaterThanOrEqual(1);
|
expect(debugLogs.length).toBeGreaterThanOrEqual(1);
|
||||||
@@ -527,11 +504,186 @@ describe("ProbeEngine", () => {
|
|||||||
ensureRegistered();
|
ensureRegistered();
|
||||||
const mockStore = createMockStore(["no-log"]) as unknown as ProbeStore;
|
const mockStore = createMockStore(["no-log"]) as unknown as ProbeStore;
|
||||||
const engine = new ProbeEngine(mockStore, [makeCommandTarget("no-log")]);
|
const engine = new ProbeEngine(mockStore, [makeCommandTarget("no-log")]);
|
||||||
|
await getRunOnce(engine)(makeCommandTarget("no-log"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const probeGroup = (
|
describe("runLoop 调度行为", () => {
|
||||||
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
|
test("首次立即执行", async () => {
|
||||||
).probeGroup.bind(engine);
|
ensureRegistered();
|
||||||
await probeGroup([makeCommandTarget("no-log")]);
|
let callCount = 0;
|
||||||
|
const checker = checkerRegistry.get("cmd");
|
||||||
|
const originalExecute = checker.execute.bind(checker);
|
||||||
|
checker.execute = (target) => {
|
||||||
|
callCount++;
|
||||||
|
return Promise.resolve(makeMockResult(target.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const target = makeCommandTarget("immediate", { intervalMs: 60000 });
|
||||||
|
const mockStore = createMockStore(["immediate"]) as unknown as ProbeStore;
|
||||||
|
const engine = new ProbeEngine(mockStore, [target]);
|
||||||
|
engine.start();
|
||||||
|
|
||||||
|
await Bun.sleep(20);
|
||||||
|
expect(callCount).toBeGreaterThanOrEqual(1);
|
||||||
|
engine.stop();
|
||||||
|
|
||||||
|
checker.execute = originalExecute;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("正常调度间隔", async () => {
|
||||||
|
ensureRegistered();
|
||||||
|
const callTimes: number[] = [];
|
||||||
|
const checker = checkerRegistry.get("cmd");
|
||||||
|
const originalExecute = checker.execute.bind(checker);
|
||||||
|
checker.execute = (target) => {
|
||||||
|
callTimes.push(performance.now());
|
||||||
|
return Promise.resolve(makeMockResult(target.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const target = makeCommandTarget("interval", { intervalMs: 100 });
|
||||||
|
const mockStore = createMockStore(["interval"]) as unknown as ProbeStore;
|
||||||
|
const engine = new ProbeEngine(mockStore, [target]);
|
||||||
|
engine.start();
|
||||||
|
|
||||||
|
await Bun.sleep(280);
|
||||||
|
engine.stop();
|
||||||
|
|
||||||
|
expect(callTimes.length).toBeGreaterThanOrEqual(2);
|
||||||
|
const gap = callTimes[1]! - callTimes[0]!;
|
||||||
|
expect(gap).toBeGreaterThanOrEqual(80);
|
||||||
|
expect(gap).toBeLessThan(200);
|
||||||
|
|
||||||
|
checker.execute = originalExecute;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("catch-up 语义:超时后立即补执行", async () => {
|
||||||
|
ensureRegistered();
|
||||||
|
const callTimes: number[] = [];
|
||||||
|
const checker = checkerRegistry.get("cmd");
|
||||||
|
const originalExecute = checker.execute.bind(checker);
|
||||||
|
checker.execute = async (target) => {
|
||||||
|
callTimes.push(performance.now());
|
||||||
|
if (callTimes.length === 1) {
|
||||||
|
await Bun.sleep(150);
|
||||||
|
}
|
||||||
|
return makeMockResult(target.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const target = makeCommandTarget("catchup", { intervalMs: 100 });
|
||||||
|
const mockStore = createMockStore(["catchup"]) as unknown as ProbeStore;
|
||||||
|
const engine = new ProbeEngine(mockStore, [target]);
|
||||||
|
engine.start();
|
||||||
|
|
||||||
|
await Bun.sleep(350);
|
||||||
|
engine.stop();
|
||||||
|
|
||||||
|
expect(callTimes.length).toBeGreaterThanOrEqual(2);
|
||||||
|
const gap = callTimes[1]! - callTimes[0]!;
|
||||||
|
expect(gap).toBeGreaterThanOrEqual(140);
|
||||||
|
expect(gap).toBeLessThan(220);
|
||||||
|
|
||||||
|
checker.execute = originalExecute;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("overrun warn 日志", async () => {
|
||||||
|
ensureRegistered();
|
||||||
|
const logger = createMemoryLogger();
|
||||||
|
const checker = checkerRegistry.get("cmd");
|
||||||
|
const originalExecute = checker.execute.bind(checker);
|
||||||
|
checker.execute = async (target) => {
|
||||||
|
await Bun.sleep(150);
|
||||||
|
return makeMockResult(target.id, { durationMs: 150 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const target = makeCommandTarget("overrun", { intervalMs: 100 });
|
||||||
|
const mockStore = createMockStore(["overrun"]) as unknown as ProbeStore;
|
||||||
|
const engine = new ProbeEngine(mockStore, [target], 20, 0, logger);
|
||||||
|
engine.start();
|
||||||
|
|
||||||
|
await Bun.sleep(250);
|
||||||
|
engine.stop();
|
||||||
|
|
||||||
|
const warnLogs = logger.entries.filter((e) => e.level === "warn" && e.msg.includes("拨测超时"));
|
||||||
|
expect(warnLogs.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(warnLogs[0]!.obj).toBeDefined();
|
||||||
|
expect(warnLogs[0]!.obj!["targetId"]).toBe("overrun");
|
||||||
|
|
||||||
|
checker.execute = originalExecute;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("无并发重叠:同一 target 不会并发执行", async () => {
|
||||||
|
ensureRegistered();
|
||||||
|
let running = 0;
|
||||||
|
let maxConcurrent = 0;
|
||||||
|
const checker = checkerRegistry.get("cmd");
|
||||||
|
const originalExecute = checker.execute.bind(checker);
|
||||||
|
checker.execute = async (target) => {
|
||||||
|
running++;
|
||||||
|
maxConcurrent = Math.max(maxConcurrent, running);
|
||||||
|
await Bun.sleep(60);
|
||||||
|
running--;
|
||||||
|
return makeMockResult(target.id, { durationMs: 60 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const target = makeCommandTarget("no-overlap", { intervalMs: 70 });
|
||||||
|
const mockStore = createMockStore(["no-overlap"]) as unknown as ProbeStore;
|
||||||
|
const engine = new ProbeEngine(mockStore, [target]);
|
||||||
|
engine.start();
|
||||||
|
|
||||||
|
await Bun.sleep(350);
|
||||||
|
engine.stop();
|
||||||
|
|
||||||
|
expect(maxConcurrent).toBeLessThanOrEqual(1);
|
||||||
|
|
||||||
|
checker.execute = originalExecute;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("优雅停止:stop() 后循环快速退出", async () => {
|
||||||
|
ensureRegistered();
|
||||||
|
const checker = checkerRegistry.get("cmd");
|
||||||
|
const originalExecute = checker.execute.bind(checker);
|
||||||
|
checker.execute = (target) => Promise.resolve(makeMockResult(target.id));
|
||||||
|
|
||||||
|
const target = makeCommandTarget("graceful", { intervalMs: 60000 });
|
||||||
|
const mockStore = createMockStore(["graceful"]) as unknown as ProbeStore;
|
||||||
|
const engine = new ProbeEngine(mockStore, [target]);
|
||||||
|
engine.start();
|
||||||
|
|
||||||
|
await Bun.sleep(20);
|
||||||
|
const stopStart = performance.now();
|
||||||
|
engine.stop();
|
||||||
|
const stopDuration = performance.now() - stopStart;
|
||||||
|
|
||||||
|
expect(stopDuration).toBeLessThan(1000);
|
||||||
|
|
||||||
|
checker.execute = originalExecute;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("错误隔离:runCheck 抛异常后循环继续", async () => {
|
||||||
|
ensureRegistered();
|
||||||
|
let callCount = 0;
|
||||||
|
const checker = checkerRegistry.get("cmd");
|
||||||
|
const originalExecute = checker.execute.bind(checker);
|
||||||
|
checker.execute = (target) => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount === 1) {
|
||||||
|
return Promise.reject(new Error("first fail"));
|
||||||
|
}
|
||||||
|
return Promise.resolve(makeMockResult(target.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const target = makeCommandTarget("error-isolation", { intervalMs: 50 });
|
||||||
|
const mockStore = createMockStore(["error-isolation"]) as unknown as ProbeStore;
|
||||||
|
const engine = new ProbeEngine(mockStore, [target]);
|
||||||
|
engine.start();
|
||||||
|
|
||||||
|
await Bun.sleep(180);
|
||||||
|
engine.stop();
|
||||||
|
|
||||||
|
expect(callCount).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
checker.execute = originalExecute;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
177
tests/server/checker/runner/cpu/calculate.test.ts
Normal file
177
tests/server/checker/runner/cpu/calculate.test.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import type { CpuCoreSnapshot } from "../../../../../src/server/checker/runner/cpu/types";
|
||||||
|
|
||||||
|
import { calculateCpuStats, validateCpuSnapshots } 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateCpuSnapshots", () => {
|
||||||
|
test("合法 snapshot 返回 null", () => {
|
||||||
|
const before = [makeCore(100, 0, 0, 900, 0)];
|
||||||
|
const after = [makeCore(200, 0, 0, 800, 0)];
|
||||||
|
expect(validateCpuSnapshots(before, after)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("空 before snapshot", () => {
|
||||||
|
const after = [makeCore(0, 0, 0, 0, 0)];
|
||||||
|
expect(validateCpuSnapshots([], after)).toBe("CPU 快照为空");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("空 after snapshot", () => {
|
||||||
|
const before = [makeCore(0, 0, 0, 0, 0)];
|
||||||
|
expect(validateCpuSnapshots(before, [])).toBe("CPU 快照为空");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("核心数不一致", () => {
|
||||||
|
const before = [makeCore(0, 0, 0, 0, 0)];
|
||||||
|
const after = [makeCore(0, 0, 0, 0, 0), makeCore(0, 0, 0, 0, 0)];
|
||||||
|
expect(validateCpuSnapshots(before, after)).toBe("CPU 快照核心数不一致: before=1, after=2");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("before 包含 NaN time 值", () => {
|
||||||
|
const before = [{ times: { idle: NaN, irq: 0, nice: 0, sys: 0, user: 0 } }];
|
||||||
|
const after = [makeCore(0, 0, 0, 0, 0)];
|
||||||
|
const error = validateCpuSnapshots(before, after);
|
||||||
|
expect(error).toContain("非有限值");
|
||||||
|
expect(error).toContain("before[0]");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("after 包含 Infinity time 值", () => {
|
||||||
|
const before = [makeCore(0, 0, 0, 0, 0)];
|
||||||
|
const after = [{ times: { idle: Infinity, irq: 0, nice: 0, sys: 0, user: 0 } }];
|
||||||
|
const error = validateCpuSnapshots(before, after);
|
||||||
|
expect(error).toContain("非有限值");
|
||||||
|
expect(error).toContain("after[0]");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("负数 total delta", () => {
|
||||||
|
const before = [makeCore(1000, 0, 0, 0, 0)];
|
||||||
|
const after = [makeCore(100, 0, 0, 0, 0)];
|
||||||
|
const error = validateCpuSnapshots(before, after);
|
||||||
|
expect(error).toContain("负数 delta");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("零 delta 合法", () => {
|
||||||
|
const before = [makeCore(100, 0, 0, 100, 0)];
|
||||||
|
const after = [makeCore(100, 0, 0, 100, 0)];
|
||||||
|
expect(validateCpuSnapshots(before, after)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("零 delta 不产生除零错误", () => {
|
||||||
|
const before = [makeCore(100, 0, 0, 100, 0)];
|
||||||
|
const after = [makeCore(100, 0, 0, 100, 0)];
|
||||||
|
const stats = calculateCpuStats(before, after);
|
||||||
|
expect(Number.isFinite(stats.usagePercent)).toBe(true);
|
||||||
|
expect(Number.isFinite(stats.idlePercent)).toBe(true);
|
||||||
|
expect(Number.isFinite(stats.maxCoreUsagePercent)).toBe(true);
|
||||||
|
expect(Number.isFinite(stats.minCoreUsagePercent)).toBe(true);
|
||||||
|
expect(stats.usagePercent).toBe(0);
|
||||||
|
expect(stats.idlePercent).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
379
tests/server/checker/runner/cpu/execute.test.ts
Normal file
379
tests/server/checker/runner/cpu/execute.test.ts
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
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("第二次 snapshot 抛错返回 cpu/snapshot failure", async () => {
|
||||||
|
const before = [makeCore(0, 0, 0, 10000, 0)];
|
||||||
|
let callCount = 0;
|
||||||
|
const reader: SnapshotReader = () => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount === 1) return before;
|
||||||
|
throw new Error("second snapshot failed");
|
||||||
|
};
|
||||||
|
const checker = new CpuChecker(reader);
|
||||||
|
|
||||||
|
const target: RawTargetConfig = { cpu: {}, 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.matched).toBe(false);
|
||||||
|
expect(result.failure?.phase).toBe("cpu");
|
||||||
|
expect(result.failure?.path).toBe("snapshot");
|
||||||
|
expect(result.observation).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("空 snapshot pair 返回 cpu/snapshot failure", async () => {
|
||||||
|
const reader: SnapshotReader = () => [];
|
||||||
|
const checker = new CpuChecker(reader);
|
||||||
|
|
||||||
|
const target: RawTargetConfig = { cpu: {}, 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.matched).toBe(false);
|
||||||
|
expect(result.failure?.phase).toBe("cpu");
|
||||||
|
expect(result.failure?.path).toBe("snapshot");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("核心数不一致返回 cpu/snapshot failure", async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
const snapshots = [[makeCore(0, 0, 0, 100, 0)], [makeCore(0, 0, 0, 100, 0), makeCore(0, 0, 0, 100, 0)]];
|
||||||
|
const reader: SnapshotReader = () => {
|
||||||
|
const result = snapshots[Math.min(callCount, snapshots.length - 1)]!;
|
||||||
|
callCount++;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
const checker = new CpuChecker(reader);
|
||||||
|
|
||||||
|
const target: RawTargetConfig = { cpu: {}, 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.matched).toBe(false);
|
||||||
|
expect(result.failure?.phase).toBe("cpu");
|
||||||
|
expect(result.failure?.path).toBe("snapshot");
|
||||||
|
expect(result.failure?.message).toContain("核心数不一致");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("非有限 CPU time 值返回 cpu/snapshot failure", async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
const snapshots: CpuCoreSnapshot[][] = [
|
||||||
|
[makeCore(0, 0, 0, 100, 0)],
|
||||||
|
[{ times: { idle: NaN, irq: 0, nice: 0, sys: 0, user: 100 } }],
|
||||||
|
];
|
||||||
|
const reader: SnapshotReader = () => {
|
||||||
|
const result = snapshots[Math.min(callCount, snapshots.length - 1)]!;
|
||||||
|
callCount++;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
const checker = new CpuChecker(reader);
|
||||||
|
|
||||||
|
const target: RawTargetConfig = { cpu: {}, 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.matched).toBe(false);
|
||||||
|
expect(result.failure?.phase).toBe("cpu");
|
||||||
|
expect(result.failure?.path).toBe("snapshot");
|
||||||
|
expect(result.failure?.message).toContain("非有限值");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("负数 CPU time delta 返回 cpu/snapshot failure", async () => {
|
||||||
|
const before = [makeCore(1000, 0, 0, 0, 0)];
|
||||||
|
const after = [makeCore(100, 0, 0, 0, 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 }));
|
||||||
|
|
||||||
|
const ctx = { signal: new AbortController().signal };
|
||||||
|
const result = await checker.execute(resolved, ctx);
|
||||||
|
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.failure?.phase).toBe("cpu");
|
||||||
|
expect(result.failure?.path).toBe("snapshot");
|
||||||
|
expect(result.failure?.message).toContain("负数 delta");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("零 delta snapshot 返回稳定安全值", async () => {
|
||||||
|
const before = [makeCore(100, 0, 0, 100, 0)];
|
||||||
|
const after = [makeCore(100, 0, 0, 100, 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 }));
|
||||||
|
|
||||||
|
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: 0,
|
||||||
|
maxCoreUsagePercent: 0,
|
||||||
|
minCoreUsagePercent: 0,
|
||||||
|
usagePercent: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includePerCore=true 时输出 perCoreUsagePercent", async () => {
|
||||||
|
const before = [makeCore(0, 0, 0, 0, 0), makeCore(0, 0, 0, 0, 0)];
|
||||||
|
const after = [makeCore(8000, 0, 0, 2000, 0), makeCore(2000, 0, 0, 8000, 0)];
|
||||||
|
const reader = makeSnapshotReader(before, after);
|
||||||
|
const checker = new CpuChecker(reader);
|
||||||
|
|
||||||
|
const target: RawTargetConfig = { cpu: { includePerCore: true }, id: "cpu-test", type: "cpu" };
|
||||||
|
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
|
||||||
|
|
||||||
|
const ctx = { signal: new AbortController().signal };
|
||||||
|
const result = await checker.execute(resolved, ctx);
|
||||||
|
|
||||||
|
expect(result.observation).toMatchObject({
|
||||||
|
perCoreUsagePercent: [80, 20],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CpuChecker buildDetail", () => {
|
||||||
|
test("正常输出格式", () => {
|
||||||
|
const checker = new CpuChecker();
|
||||||
|
const detail = checker.buildDetail({
|
||||||
|
idlePercent: 40,
|
||||||
|
logicalCoreCount: 8,
|
||||||
|
maxCoreUsagePercent: 91.5,
|
||||||
|
minCoreUsagePercent: 8.2,
|
||||||
|
usagePercent: 60,
|
||||||
|
});
|
||||||
|
expect(detail).toBe("usage 60%, max core 91.5%, 8 cores");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CpuChecker serialize", () => {
|
||||||
|
test("序列化输出", () => {
|
||||||
|
const checker = new CpuChecker();
|
||||||
|
const target: RawTargetConfig = { cpu: { sampleDuration: "1s" }, id: "cpu-test", type: "cpu" };
|
||||||
|
const resolved = checker.resolve(target, makeResolveContext());
|
||||||
|
const result = checker.serialize(resolved);
|
||||||
|
expect(result.target).toBe("cpu sample 1000ms");
|
||||||
|
const config = JSON.parse(result.config) as { sampleDurationMs: number };
|
||||||
|
expect(config.sampleDurationMs).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
55
tests/server/checker/runner/cpu/expect.test.ts
Normal file
55
tests/server/checker/runner/cpu/expect.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
checkIdlePercent,
|
||||||
|
checkMaxCoreUsage,
|
||||||
|
checkMinCoreUsage,
|
||||||
|
checkUsagePercent,
|
||||||
|
} from "../../../../../src/server/checker/runner/cpu/expect";
|
||||||
|
|
||||||
|
describe("CPU expect checks", () => {
|
||||||
|
test("checkUsagePercent 匹配", () => {
|
||||||
|
expect(checkUsagePercent(50, { lte: 85 }).matched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkUsagePercent 不匹配", () => {
|
||||||
|
const result = checkUsagePercent(90, { lte: 85 });
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.failure?.phase).toBe("usage");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkIdlePercent 匹配", () => {
|
||||||
|
expect(checkIdlePercent(50, { gte: 15 }).matched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkIdlePercent 不匹配", () => {
|
||||||
|
const result = checkIdlePercent(10, { gte: 15 });
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.failure?.phase).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkMaxCoreUsage 匹配", () => {
|
||||||
|
expect(checkMaxCoreUsage(80, { lte: 95 }).matched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkMaxCoreUsage 不匹配", () => {
|
||||||
|
const result = checkMaxCoreUsage(96, { lte: 95 });
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.failure?.phase).toBe("maxCoreUsage");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkMinCoreUsage 匹配", () => {
|
||||||
|
expect(checkMinCoreUsage(10, { gte: 5 }).matched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkMinCoreUsage 不匹配", () => {
|
||||||
|
const result = checkMinCoreUsage(3, { gte: 5 });
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.failure?.phase).toBe("minCoreUsage");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("undefined matcher 直接通过", () => {
|
||||||
|
expect(checkUsagePercent(99.9, undefined).matched).toBe(true);
|
||||||
|
expect(checkIdlePercent(0, undefined).matched).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
41
tests/server/checker/runner/cpu/normalize.test.ts
Normal file
41
tests/server/checker/runner/cpu/normalize.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { normalizeTargetExpect } from "../../../../../src/server/checker/runner/cpu/normalize";
|
||||||
|
|
||||||
|
describe("normalizeTargetExpect (cpu)", () => {
|
||||||
|
test("无 expect 直接返回", () => {
|
||||||
|
const target = { cpu: {}, id: "test", type: "cpu" };
|
||||||
|
expect(normalizeTargetExpect(target)).toEqual(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect 为非对象直接返回", () => {
|
||||||
|
const target = { cpu: {}, expect: "not-an-object", id: "test", type: "cpu" };
|
||||||
|
expect(normalizeTargetExpect(target)).toEqual(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ValueMatcher 简写展开", () => {
|
||||||
|
const target = { cpu: {}, expect: { usagePercent: 85 }, id: "test", type: "cpu" };
|
||||||
|
const result = normalizeTargetExpect(target);
|
||||||
|
expect((result.expect as Record<string, unknown>)["usagePercent"]).toEqual({ equals: 85 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("已经是 matcher 对象的不变", () => {
|
||||||
|
const target = { cpu: {}, expect: { usagePercent: { lte: 85 } }, id: "test", type: "cpu" };
|
||||||
|
const result = normalizeTargetExpect(target);
|
||||||
|
expect((result.expect as Record<string, unknown>)["usagePercent"]).toEqual({ lte: 85 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("多个字段同时展开", () => {
|
||||||
|
const target = {
|
||||||
|
cpu: {},
|
||||||
|
expect: { idlePercent: 15, maxCoreUsagePercent: { lte: 95 }, usagePercent: 85 },
|
||||||
|
id: "test",
|
||||||
|
type: "cpu",
|
||||||
|
};
|
||||||
|
const result = normalizeTargetExpect(target);
|
||||||
|
const expectObj = result.expect as Record<string, unknown>;
|
||||||
|
expect(expectObj["idlePercent"]).toEqual({ equals: 15 });
|
||||||
|
expect(expectObj["maxCoreUsagePercent"]).toEqual({ lte: 95 });
|
||||||
|
expect(expectObj["usagePercent"]).toEqual({ equals: 85 });
|
||||||
|
});
|
||||||
|
});
|
||||||
77
tests/server/checker/runner/cpu/schema.test.ts
Normal file
77
tests/server/checker/runner/cpu/schema.test.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import Ajv from "ajv";
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { cpuCheckerSchemas } from "../../../../../src/server/checker/runner/cpu/schema";
|
||||||
|
|
||||||
|
const ajv = new Ajv({ strict: false });
|
||||||
|
|
||||||
|
describe("CPU checker schema", () => {
|
||||||
|
test("authoring config 允许变量引用", () => {
|
||||||
|
const validate = ajv.compile(cpuCheckerSchemas.authoring.config);
|
||||||
|
expect(validate({ includePerCore: "${per_core|false}", sampleDuration: "${sample_dur|1s}" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalized config 允许合法值", () => {
|
||||||
|
const validate = ajv.compile(cpuCheckerSchemas.normalized.config);
|
||||||
|
expect(validate({ includePerCore: true, sampleDuration: "1s" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalized config 空配置通过", () => {
|
||||||
|
const validate = ajv.compile(cpuCheckerSchemas.normalized.config);
|
||||||
|
expect(validate({})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("config 拒绝额外字段", () => {
|
||||||
|
const validate = ajv.compile(cpuCheckerSchemas.authoring.config);
|
||||||
|
expect(validate({ extraField: true })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("authoring expect 允许 ValueMatcher 简写", () => {
|
||||||
|
const validate = ajv.compile(cpuCheckerSchemas.authoring.expect);
|
||||||
|
expect(validate({ usagePercent: 85 })).toBe(true);
|
||||||
|
expect(validate({ usagePercent: { lte: 85 } })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalized expect 允许 matcher 对象", () => {
|
||||||
|
const validate = ajv.compile(cpuCheckerSchemas.normalized.expect);
|
||||||
|
expect(validate({ idlePercent: { gte: 15 }, usagePercent: { lte: 85 } })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect 拒绝 logicalCoreCount 字段", () => {
|
||||||
|
const validate = ajv.compile(cpuCheckerSchemas.authoring.expect);
|
||||||
|
expect(validate({ logicalCoreCount: { gte: 4 } })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect 拒绝 userPercent 字段", () => {
|
||||||
|
const validate = ajv.compile(cpuCheckerSchemas.authoring.expect);
|
||||||
|
expect(validate({ userPercent: { lte: 50 } })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect 拒绝 systemPercent 字段", () => {
|
||||||
|
const validate = ajv.compile(cpuCheckerSchemas.authoring.expect);
|
||||||
|
expect(validate({ systemPercent: { lte: 50 } })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect 允许所有合法字段", () => {
|
||||||
|
const validate = ajv.compile(cpuCheckerSchemas.normalized.expect);
|
||||||
|
expect(
|
||||||
|
validate({
|
||||||
|
durationMs: { lte: 2000 },
|
||||||
|
idlePercent: { gte: 15 },
|
||||||
|
maxCoreUsagePercent: { lte: 95 },
|
||||||
|
minCoreUsagePercent: { gte: 0 },
|
||||||
|
usagePercent: { lte: 85 },
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect 拒绝额外字段", () => {
|
||||||
|
const validate = ajv.compile(cpuCheckerSchemas.normalized.expect);
|
||||||
|
expect(validate({ unknownField: 1 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect 空对象通过", () => {
|
||||||
|
const validate = ajv.compile(cpuCheckerSchemas.normalized.expect);
|
||||||
|
expect(validate({})).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
84
tests/server/checker/runner/cpu/validate.test.ts
Normal file
84
tests/server/checker/runner/cpu/validate.test.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import type { RawTargetConfig } from "../../../../../src/server/checker/types";
|
||||||
|
|
||||||
|
import { validateCpuConfig } from "../../../../../src/server/checker/runner/cpu/validate";
|
||||||
|
|
||||||
|
function validate(target: RawTargetConfig) {
|
||||||
|
return validateCpuConfig({ targets: [target] });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("validateCpuConfig", () => {
|
||||||
|
test("有效配置无错误", () => {
|
||||||
|
expect(validate({ cpu: { sampleDuration: "1s" }, id: "cpu-test", type: "cpu" })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("空 cpu 配置无错误", () => {
|
||||||
|
expect(validate({ cpu: {}, id: "cpu-test", type: "cpu" })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("缺少 cpu 配置分组", () => {
|
||||||
|
const issues = validate({ id: "cpu-test", type: "cpu" });
|
||||||
|
expect(issues.some((i) => i.path.endsWith("cpu") && i.code === "required")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("无效 sampleDuration 格式", () => {
|
||||||
|
const issues = validate({ cpu: { sampleDuration: "abc" }, id: "cpu-test", type: "cpu" });
|
||||||
|
expect(issues.some((i) => i.path.endsWith("sampleDuration"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sampleDuration >= timeout 报错", () => {
|
||||||
|
const issues = validate({ cpu: { sampleDuration: "5s" }, id: "cpu-test", timeout: "5s", type: "cpu" });
|
||||||
|
expect(issues.some((i) => i.path.endsWith("sampleDuration") && i.message.includes("必须小于 timeout"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sampleDuration 大于默认 timeout (10s) 报错", () => {
|
||||||
|
const issues = validate({ cpu: { sampleDuration: "15s" }, id: "cpu-test", type: "cpu" });
|
||||||
|
expect(issues.some((i) => i.message.includes("默认 10s"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sampleDuration < timeout 通过", () => {
|
||||||
|
const issues = validate({ cpu: { sampleDuration: "1s" }, id: "cpu-test", timeout: "5s", type: "cpu" });
|
||||||
|
expect(issues.some((i) => i.path.includes("sampleDuration"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includePerCore 非布尔值报错", () => {
|
||||||
|
const issues = validate({ cpu: { includePerCore: "yes" }, id: "cpu-test", type: "cpu" });
|
||||||
|
expect(issues.some((i) => i.path.endsWith("includePerCore") && i.code === "invalid-type")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cpu 未知字段报错", () => {
|
||||||
|
const issues = validate({ cpu: { extra: true }, id: "cpu-test", type: "cpu" });
|
||||||
|
expect(issues.some((i) => i.path.endsWith("extra") && i.code === "unknown-field")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect 未知字段报错", () => {
|
||||||
|
const issues = validate({ cpu: {}, expect: { logicalCoreCount: { gte: 4 } }, id: "cpu-test", type: "cpu" });
|
||||||
|
expect(issues.some((i) => i.path.endsWith("logicalCoreCount") && i.code === "unknown-field")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect userPercent 未知字段报错", () => {
|
||||||
|
const issues = validate({ cpu: {}, expect: { userPercent: { lte: 50 } }, id: "cpu-test", type: "cpu" });
|
||||||
|
expect(issues.some((i) => i.path.endsWith("userPercent") && i.code === "unknown-field")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect systemPercent 未知字段报错", () => {
|
||||||
|
const issues = validate({ cpu: {}, expect: { systemPercent: { lte: 50 } }, id: "cpu-test", type: "cpu" });
|
||||||
|
expect(issues.some((i) => i.path.endsWith("systemPercent") && i.code === "unknown-field")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect 合法 ValueMatcher 通过", () => {
|
||||||
|
const issues = validate({
|
||||||
|
cpu: {},
|
||||||
|
expect: { maxCoreUsagePercent: { lte: 95 }, usagePercent: { lte: 85 } },
|
||||||
|
id: "cpu-test",
|
||||||
|
type: "cpu",
|
||||||
|
});
|
||||||
|
expect(issues.filter((i) => i.path.includes("expect"))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect 非法 ValueMatcher 报错", () => {
|
||||||
|
const issues = validate({ cpu: {}, expect: { usagePercent: [1, 2] }, id: "cpu-test", type: "cpu" });
|
||||||
|
expect(issues.some((i) => i.path.includes("usagePercent"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
87
tests/server/checker/runner/dns/normalize.test.ts
Normal file
87
tests/server/checker/runner/dns/normalize.test.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { normalizeAuthoringConfig } from "../../../../../src/server/checker/normalizer";
|
||||||
|
import { checkerRegistry } from "../../../../../src/server/checker/runner";
|
||||||
|
import { validateProbeConfigContract } from "../../../../../src/server/checker/schema/validate";
|
||||||
|
|
||||||
|
describe("DNS normalize", () => {
|
||||||
|
test("ValueMatcher 简写被展开", () => {
|
||||||
|
const result = normalizeAuthoringConfig(
|
||||||
|
{
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
dns: { name: "example.com", resolver: "server", server: "8.8.8.8" },
|
||||||
|
expect: { durationMs: 1000, valueCount: { gte: 1 } },
|
||||||
|
id: "dns-test",
|
||||||
|
type: "dns",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
checkerRegistry,
|
||||||
|
);
|
||||||
|
expect(result.issues).toHaveLength(0);
|
||||||
|
const target = (result.config as { targets: Array<{ expect: Record<string, unknown> }> }).targets[0]!;
|
||||||
|
expect(target.expect["durationMs"]).toEqual({ equals: 1000 });
|
||||||
|
expect(target.expect["valueCount"]).toEqual({ gte: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ContentExpectations 简写被展开", () => {
|
||||||
|
const result = normalizeAuthoringConfig(
|
||||||
|
{
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
dns: { name: "example.com", resolver: "server", server: "8.8.8.8" },
|
||||||
|
expect: { result: [{ contains: "NOERROR" }] },
|
||||||
|
id: "dns-test",
|
||||||
|
type: "dns",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
checkerRegistry,
|
||||||
|
);
|
||||||
|
expect(result.issues).toHaveLength(0);
|
||||||
|
const target = (result.config as { targets: Array<{ expect: Record<string, unknown> }> }).targets[0]!;
|
||||||
|
const resultExpect = target.expect["result"] as Array<Record<string, unknown>>;
|
||||||
|
expect(resultExpect[0]!["kind"]).toBe("value");
|
||||||
|
expect((resultExpect[0]!["matcher"] as Record<string, unknown>)["contains"]).toBe("NOERROR");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DNS authoring 简写经 normalize 后通过 normalized contract 校验", () => {
|
||||||
|
const result = normalizeAuthoringConfig(
|
||||||
|
{
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
dns: { name: "example.com", resolver: "server", server: "8.8.8.8" },
|
||||||
|
expect: { durationMs: 1000, result: [{ contains: "NOERROR" }], valueCount: { gte: 1 } },
|
||||||
|
id: "dns-test",
|
||||||
|
type: "dns",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
checkerRegistry,
|
||||||
|
);
|
||||||
|
expect(result.issues).toHaveLength(0);
|
||||||
|
const contract = validateProbeConfigContract(result.config, checkerRegistry);
|
||||||
|
expect(contract.config).not.toBeNull();
|
||||||
|
expect(contract.issues).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DNS system 模式 ValueMatcher 简写被展开", () => {
|
||||||
|
const result = normalizeAuthoringConfig(
|
||||||
|
{
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
dns: { name: "example.com", resolver: "system" },
|
||||||
|
expect: { durationMs: 500, valueCount: { gte: 1 } },
|
||||||
|
id: "dns-system-test",
|
||||||
|
type: "dns",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
checkerRegistry,
|
||||||
|
);
|
||||||
|
expect(result.issues).toHaveLength(0);
|
||||||
|
const target = (result.config as { targets: Array<{ expect: Record<string, unknown> }> }).targets[0]!;
|
||||||
|
expect(target.expect["durationMs"]).toEqual({ equals: 500 });
|
||||||
|
});
|
||||||
|
});
|
||||||
139
tests/server/checker/runner/mem/calculate.test.ts
Normal file
139
tests/server/checker/runner/mem/calculate.test.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import type { Systeminformation } from "systeminformation";
|
||||||
|
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { calculateMemStats } from "../../../../../src/server/checker/runner/mem/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("calculateMemStats", () => {
|
||||||
|
test("usagePercent = activeBytes / totalBytes * 100", () => {
|
||||||
|
const stats = calculateMemStats(makeMemData({ active: 4294967296, total: 8589934592 }));
|
||||||
|
expect(stats.usagePercent).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("usedPercent = usedBytes / totalBytes * 100", () => {
|
||||||
|
const stats = calculateMemStats(makeMemData({ total: 8589934592, used: 6442450944 }));
|
||||||
|
expect(stats.usedPercent).toBe(75);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("freePercent = freeBytes / totalBytes * 100", () => {
|
||||||
|
const stats = calculateMemStats(makeMemData({ free: 2147483648, total: 8589934592 }));
|
||||||
|
expect(stats.freePercent).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("activePercent = activeBytes / totalBytes * 100", () => {
|
||||||
|
const stats = calculateMemStats(makeMemData({ active: 3221225472, total: 8589934592 }));
|
||||||
|
expect(stats.activePercent).toBe(37.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("availablePercent = availableBytes / totalBytes * 100", () => {
|
||||||
|
const stats = calculateMemStats(makeMemData({ available: 6442450944, total: 8589934592 }));
|
||||||
|
expect(stats.availablePercent).toBe(75);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("保留 1 位小数", () => {
|
||||||
|
const stats = calculateMemStats(makeMemData({ active: 3000000000, total: 8000000000 }));
|
||||||
|
expect(stats.usagePercent).toBe(37.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("round1 处理需要四舍五入的情况", () => {
|
||||||
|
const stats = calculateMemStats(makeMemData({ active: 3333333333, total: 10000000000 }));
|
||||||
|
expect(stats.usagePercent).toBe(33.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("total 为 0 时百分比为 0", () => {
|
||||||
|
const stats = calculateMemStats(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 = calculateMemStats(makeMemData({ buffcache: 0 }));
|
||||||
|
expect(stats.buffcacheBytes).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buffcacheBytes 为正数时保留", () => {
|
||||||
|
const stats = calculateMemStats(makeMemData({ buffcache: 1073741824 }));
|
||||||
|
expect(stats.buffcacheBytes).toBe(1073741824);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("所有字节字段正确映射", () => {
|
||||||
|
const data = makeMemData({
|
||||||
|
active: 1000,
|
||||||
|
available: 2000,
|
||||||
|
free: 3000,
|
||||||
|
total: 4000,
|
||||||
|
used: 3500,
|
||||||
|
});
|
||||||
|
const stats = calculateMemStats(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("calculateMemStats swap", () => {
|
||||||
|
test("swap 不可用:swaptotal=0 时 swapUsagePercent=null", () => {
|
||||||
|
const stats = calculateMemStats(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 总量为 0,swapUsagePercent 为 null(不是 0)", () => {
|
||||||
|
const stats = calculateMemStats(makeMemData({ swaptotal: 0 }));
|
||||||
|
expect(stats.swapUsagePercent).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("swap 已使用", () => {
|
||||||
|
const stats = calculateMemStats(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 = calculateMemStats(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 = calculateMemStats(
|
||||||
|
makeMemData({ swapfree: 3000000000, swaptotal: 10000000000, swapused: 7000000000 }),
|
||||||
|
);
|
||||||
|
expect(stats.swapUsagePercent).toBe(70);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("swap 合法 0 不被转换为 null", () => {
|
||||||
|
const stats = calculateMemStats(makeMemData({ swapfree: 4294967296, swaptotal: 4294967296, swapused: 0 }));
|
||||||
|
expect(stats.swapUsedBytes).toBe(0);
|
||||||
|
expect(stats.swapUsagePercent).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
244
tests/server/checker/runner/mem/execute.test.ts
Normal file
244
tests/server/checker/runner/mem/execute.test.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import type { Systeminformation } from "systeminformation";
|
||||||
|
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import type { RawTargetConfig } from "../../../../../src/server/checker/types";
|
||||||
|
|
||||||
|
import { MemChecker } from "../../../../../src/server/checker/runner/mem/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("MemChecker resolve", () => {
|
||||||
|
const checker = new MemChecker();
|
||||||
|
|
||||||
|
test("默认值:mem 为空对象", () => {
|
||||||
|
const target: RawTargetConfig = { id: "mem-test", mem: {}, type: "mem" };
|
||||||
|
const resolved = checker.resolve(target, makeResolveContext());
|
||||||
|
expect(resolved.mem).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("无 expect 时 expect 为 undefined", () => {
|
||||||
|
const target: RawTargetConfig = { id: "mem-test", mem: {}, type: "mem" };
|
||||||
|
const resolved = checker.resolve(target, makeResolveContext());
|
||||||
|
expect(resolved.expect).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("保留 expect 字段", () => {
|
||||||
|
const target: RawTargetConfig = {
|
||||||
|
expect: { usagePercent: { lte: 85 } },
|
||||||
|
id: "mem-test",
|
||||||
|
mem: {},
|
||||||
|
type: "mem",
|
||||||
|
};
|
||||||
|
const resolved = checker.resolve(target, makeResolveContext());
|
||||||
|
expect(resolved.expect).toEqual({ usagePercent: { lte: 85 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("type 为 mem", () => {
|
||||||
|
const target: RawTargetConfig = { id: "mem-test", mem: {}, type: "mem" };
|
||||||
|
const resolved = checker.resolve(target, makeResolveContext());
|
||||||
|
expect(resolved.type).toBe("mem");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MemChecker execute", () => {
|
||||||
|
test("成功匹配", async () => {
|
||||||
|
const data = makeMemData({ active: 4294967296, total: 8589934592 });
|
||||||
|
const reader = () => Promise.resolve(data);
|
||||||
|
const checker = new MemChecker(reader);
|
||||||
|
|
||||||
|
const target: RawTargetConfig = { id: "mem-test", mem: {}, type: "mem" };
|
||||||
|
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 MemChecker(reader);
|
||||||
|
|
||||||
|
const target: RawTargetConfig = { id: "mem-test", mem: {}, type: "mem" };
|
||||||
|
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 MemChecker(reader);
|
||||||
|
|
||||||
|
const target: RawTargetConfig = { id: "mem-test", mem: {}, type: "mem" };
|
||||||
|
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 MemChecker(reader);
|
||||||
|
|
||||||
|
const target: RawTargetConfig = { id: "mem-test", mem: {}, type: "mem" };
|
||||||
|
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("mem");
|
||||||
|
expect(result.failure?.path).toBe("snapshot");
|
||||||
|
expect(result.observation).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("signal 已 abort 时返回 timeout failure", async () => {
|
||||||
|
const reader = () => Promise.resolve(makeMemData());
|
||||||
|
const checker = new MemChecker(reader);
|
||||||
|
|
||||||
|
const target: RawTargetConfig = { id: "mem-test", mem: {}, type: "mem" };
|
||||||
|
const resolved = checker.resolve(target, makeResolveContext());
|
||||||
|
|
||||||
|
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("mem");
|
||||||
|
expect(result.failure?.path).toBe("timeout");
|
||||||
|
expect(result.observation).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pending reader 被 signal abort 后返回 timeout failure", async () => {
|
||||||
|
const reader = () =>
|
||||||
|
new Promise<Systeminformation.MemData>(() => {
|
||||||
|
// 故意永不 resolve,模拟悬挂的 reader
|
||||||
|
});
|
||||||
|
const checker = new MemChecker(reader);
|
||||||
|
|
||||||
|
const target: RawTargetConfig = { id: "mem-test", mem: {}, type: "mem" };
|
||||||
|
const resolved = checker.resolve(target, makeResolveContext());
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const executePromise = checker.execute(resolved, { signal: controller.signal });
|
||||||
|
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
const result = await executePromise;
|
||||||
|
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.failure?.phase).toBe("mem");
|
||||||
|
expect(result.failure?.path).toBe("timeout");
|
||||||
|
expect(result.observation).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reader 在 abort 前 resolve 时返回正常结果", async () => {
|
||||||
|
const data = makeMemData({ active: 4294967296, total: 8589934592 });
|
||||||
|
const reader = () => Promise.resolve(data);
|
||||||
|
const checker = new MemChecker(reader);
|
||||||
|
|
||||||
|
const target: RawTargetConfig = { id: "mem-test", mem: {}, type: "mem" };
|
||||||
|
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("detail 格式", async () => {
|
||||||
|
const data = makeMemData({ active: 4294967296, total: 8589934592 });
|
||||||
|
const reader = () => Promise.resolve(data);
|
||||||
|
const checker = new MemChecker(reader);
|
||||||
|
|
||||||
|
const target: RawTargetConfig = { id: "mem-test", mem: {}, type: "mem" };
|
||||||
|
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("MemChecker serialize", () => {
|
||||||
|
test("序列化输出", () => {
|
||||||
|
const checker = new MemChecker();
|
||||||
|
const target: RawTargetConfig = { id: "mem-test", mem: {}, type: "mem" };
|
||||||
|
const resolved = checker.resolve(target, makeResolveContext());
|
||||||
|
const result = checker.serialize(resolved);
|
||||||
|
expect(result.target).toBe("mem");
|
||||||
|
const config = JSON.parse(result.config) as Record<string, unknown>;
|
||||||
|
expect(config).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
149
tests/server/checker/runner/mem/expect.test.ts
Normal file
149
tests/server/checker/runner/mem/expect.test.ts
Normal 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/mem/expect";
|
||||||
|
|
||||||
|
describe("Mem 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("Mem 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("Mem 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("Mem 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
72
tests/server/checker/runner/mem/normalize.test.ts
Normal file
72
tests/server/checker/runner/mem/normalize.test.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { normalizeTargetExpect } from "../../../../../src/server/checker/runner/mem/normalize";
|
||||||
|
|
||||||
|
describe("normalizeTargetExpect (mem)", () => {
|
||||||
|
test("无 expect 直接返回", () => {
|
||||||
|
const target = { id: "test", mem: {}, type: "mem" };
|
||||||
|
expect(normalizeTargetExpect(target)).toEqual(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect 为非对象直接返回", () => {
|
||||||
|
const target = { expect: "not-an-object", id: "test", mem: {}, type: "mem" };
|
||||||
|
expect(normalizeTargetExpect(target)).toEqual(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("字节大小字符串 512MB 转换为数字", () => {
|
||||||
|
const target = { expect: { usedBytes: "512MB" }, id: "test", mem: {}, type: "mem" };
|
||||||
|
const result = normalizeTargetExpect(target);
|
||||||
|
expect((result.expect as Record<string, unknown>)["usedBytes"]).toEqual({ equals: 536870912 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("字节大小字符串 1GB 转换为数字", () => {
|
||||||
|
const target = { expect: { totalBytes: "1GB" }, id: "test", mem: {}, type: "mem" };
|
||||||
|
const result = normalizeTargetExpect(target);
|
||||||
|
expect((result.expect as Record<string, unknown>)["totalBytes"]).toEqual({ equals: 1073741824 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("数字字节 matcher 保持不变", () => {
|
||||||
|
const target = { expect: { usedBytes: 1073741824 }, id: "test", mem: {}, type: "mem" };
|
||||||
|
const result = normalizeTargetExpect(target);
|
||||||
|
expect((result.expect as Record<string, unknown>)["usedBytes"]).toEqual({ equals: 1073741824 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("百分比 matcher 正常展开", () => {
|
||||||
|
const target = { expect: { usagePercent: 85 }, id: "test", mem: {}, type: "mem" };
|
||||||
|
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", mem: {}, type: "mem" };
|
||||||
|
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", mem: {}, type: "mem" };
|
||||||
|
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",
|
||||||
|
mem: {},
|
||||||
|
type: "mem",
|
||||||
|
};
|
||||||
|
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 (mem) 错误", () => {
|
||||||
|
test("非法大小字符串抛出", () => {
|
||||||
|
const target = { expect: { usedBytes: "abc" }, id: "test", mem: {}, type: "mem" };
|
||||||
|
expect(() => normalizeTargetExpect(target)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
95
tests/server/checker/runner/mem/schema.test.ts
Normal file
95
tests/server/checker/runner/mem/schema.test.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import Ajv from "ajv";
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { memCheckerSchemas } from "../../../../../src/server/checker/runner/mem/schema";
|
||||||
|
|
||||||
|
const ajv = new Ajv({ strict: false });
|
||||||
|
|
||||||
|
describe("Mem checker schema", () => {
|
||||||
|
test("authoring config 空配置通过", () => {
|
||||||
|
const validate = ajv.compile(memCheckerSchemas.authoring.config);
|
||||||
|
expect(validate({})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalized config 空配置通过", () => {
|
||||||
|
const validate = ajv.compile(memCheckerSchemas.normalized.config);
|
||||||
|
expect(validate({})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("config 拒绝额外字段", () => {
|
||||||
|
const validate = ajv.compile(memCheckerSchemas.authoring.config);
|
||||||
|
expect(validate({ extraField: true })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("authoring expect 允许百分比 ValueMatcher 简写", () => {
|
||||||
|
const validate = ajv.compile(memCheckerSchemas.authoring.expect);
|
||||||
|
expect(validate({ usagePercent: 85 })).toBe(true);
|
||||||
|
expect(validate({ usagePercent: { lte: 85 } })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("authoring expect 允许字节字段字符串", () => {
|
||||||
|
const validate = ajv.compile(memCheckerSchemas.authoring.expect);
|
||||||
|
expect(validate({ usedBytes: "512MB" })).toBe(true);
|
||||||
|
expect(validate({ totalBytes: "1GB" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("authoring expect 允许字节字段数字", () => {
|
||||||
|
const validate = ajv.compile(memCheckerSchemas.authoring.expect);
|
||||||
|
expect(validate({ usedBytes: 536870912 })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalized expect 允许 matcher 对象", () => {
|
||||||
|
const validate = ajv.compile(memCheckerSchemas.normalized.expect);
|
||||||
|
expect(validate({ freePercent: { gte: 15 }, usagePercent: { lte: 85 } })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect 拒绝未知字段", () => {
|
||||||
|
const validate = ajv.compile(memCheckerSchemas.authoring.expect);
|
||||||
|
expect(validate({ unknownField: 1 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect 空对象通过", () => {
|
||||||
|
const validate = ajv.compile(memCheckerSchemas.normalized.expect);
|
||||||
|
expect(validate({})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect 允许所有合法百分比字段", () => {
|
||||||
|
const validate = ajv.compile(memCheckerSchemas.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(memCheckerSchemas.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(memCheckerSchemas.normalized.expect);
|
||||||
|
expect(validate({ durationMs: { lte: 5000 } })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect 允许 buffcacheBytes 字段", () => {
|
||||||
|
const validate = ajv.compile(memCheckerSchemas.normalized.expect);
|
||||||
|
expect(validate({ buffcacheBytes: { lte: 2147483648 } })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
87
tests/server/checker/runner/mem/validate.test.ts
Normal file
87
tests/server/checker/runner/mem/validate.test.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import type { RawTargetConfig } from "../../../../../src/server/checker/types";
|
||||||
|
|
||||||
|
import { validateMemConfig } from "../../../../../src/server/checker/runner/mem/validate";
|
||||||
|
|
||||||
|
function validate(target: RawTargetConfig) {
|
||||||
|
return validateMemConfig({ targets: [target] });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("validateMemConfig", () => {
|
||||||
|
test("有效配置无错误", () => {
|
||||||
|
expect(validate({ id: "mem-test", mem: {}, type: "mem" })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("缺少 mem 配置分组", () => {
|
||||||
|
const issues = validate({ id: "mem-test", type: "mem" });
|
||||||
|
expect(issues.some((i) => i.path.endsWith("mem") && i.code === "required")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mem 未知字段报错", () => {
|
||||||
|
const issues = validate({ id: "mem-test", mem: { extra: true }, type: "mem" });
|
||||||
|
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", mem: {}, type: "mem" });
|
||||||
|
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",
|
||||||
|
mem: {},
|
||||||
|
type: "mem",
|
||||||
|
});
|
||||||
|
expect(issues.filter((i) => i.path.includes("expect"))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect 非法 ValueMatcher 报错", () => {
|
||||||
|
const issues = validate({ expect: { usagePercent: [1, 2] }, id: "mem-test", mem: {}, type: "mem" });
|
||||||
|
expect(issues.some((i) => i.path.includes("usagePercent"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect 合法字节大小字符串通过", () => {
|
||||||
|
const issues = validate({ expect: { usedBytes: "512MB" }, id: "mem-test", mem: {}, type: "mem" });
|
||||||
|
expect(issues.filter((i) => i.path.includes("usedBytes"))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect 非法字节大小字符串报错", () => {
|
||||||
|
const issues = validate({ expect: { usedBytes: "abc" }, id: "mem-test", mem: {}, type: "mem" });
|
||||||
|
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",
|
||||||
|
mem: {},
|
||||||
|
type: "mem",
|
||||||
|
});
|
||||||
|
expect(issues).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("非 mem type 的 target 不校验", () => {
|
||||||
|
const issues = validate({ id: "other-test", type: "http" });
|
||||||
|
expect(issues).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@ import { Type } from "@sinclair/typebox";
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
import type { Checker } from "../../../../src/server/checker/runner/types";
|
import type { Checker } from "../../../../src/server/checker/runner/types";
|
||||||
import type { CheckResult, ResolvedTargetBase } from "../../../../src/server/checker/types";
|
import type { CheckResult, RawTargetConfig, ResolvedTargetBase } from "../../../../src/server/checker/types";
|
||||||
|
|
||||||
import { createDefaultCheckerRegistry } from "../../../../src/server/checker/runner";
|
import { createDefaultCheckerRegistry } from "../../../../src/server/checker/runner";
|
||||||
import { CheckerRegistry } from "../../../../src/server/checker/runner/registry";
|
import { CheckerRegistry } from "../../../../src/server/checker/runner/registry";
|
||||||
@@ -12,6 +12,7 @@ function createChecker(type: string): Checker {
|
|||||||
buildDetail: () => null,
|
buildDetail: () => null,
|
||||||
configKey: type,
|
configKey: type,
|
||||||
execute: () => Promise.resolve<CheckResult>({} as unknown as CheckResult),
|
execute: () => Promise.resolve<CheckResult>({} as unknown as CheckResult),
|
||||||
|
normalize: (t: RawTargetConfig) => t,
|
||||||
resolve: () => ({}) as unknown as ResolvedTargetBase,
|
resolve: () => ({}) as unknown as ResolvedTargetBase,
|
||||||
schemas: {
|
schemas: {
|
||||||
authoring: {
|
authoring: {
|
||||||
@@ -72,8 +73,33 @@ describe("CheckerRegistry", () => {
|
|||||||
const second = createDefaultCheckerRegistry();
|
const second = createDefaultCheckerRegistry();
|
||||||
first.register(createChecker("custom"));
|
first.register(createChecker("custom"));
|
||||||
|
|
||||||
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "dns", "custom"]);
|
expect(first.supportedTypes).toEqual([
|
||||||
expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "dns"]);
|
"http",
|
||||||
|
"cmd",
|
||||||
|
"db",
|
||||||
|
"tcp",
|
||||||
|
"icmp",
|
||||||
|
"udp",
|
||||||
|
"llm",
|
||||||
|
"dns",
|
||||||
|
"ws",
|
||||||
|
"cpu",
|
||||||
|
"mem",
|
||||||
|
"custom",
|
||||||
|
]);
|
||||||
|
expect(second.supportedTypes).toEqual([
|
||||||
|
"http",
|
||||||
|
"cmd",
|
||||||
|
"db",
|
||||||
|
"tcp",
|
||||||
|
"icmp",
|
||||||
|
"udp",
|
||||||
|
"llm",
|
||||||
|
"dns",
|
||||||
|
"ws",
|
||||||
|
"cpu",
|
||||||
|
"mem",
|
||||||
|
]);
|
||||||
expect(
|
expect(
|
||||||
first.definitions.every((checker) => checker.schemas.authoring.config && checker.schemas.normalized.expect),
|
first.definitions.every((checker) => checker.schemas.authoring.config && checker.schemas.normalized.expect),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
|||||||
155
tests/server/checker/runner/ws/config-loader.test.ts
Normal file
155
tests/server/checker/runner/ws/config-loader.test.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import type { ResolvedWsTarget } from "../../../../../src/server/checker/runner/ws/types";
|
||||||
|
|
||||||
|
import { loadConfig } from "../../../../../src/server/checker/config-loader";
|
||||||
|
|
||||||
|
describe("loadConfig with ws checker", () => {
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tempDir = join(tmpdir(), `ws-cfg-test-${Date.now()}`);
|
||||||
|
await mkdir(tempDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await rm(tempDir, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("解析最简 ws 配置", async () => {
|
||||||
|
const configPath = join(tempDir, "minimal-ws.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`targets:
|
||||||
|
- id: "ws-test"
|
||||||
|
type: ws
|
||||||
|
ws:
|
||||||
|
url: "ws://example.com/ws"
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = await loadConfig(configPath);
|
||||||
|
expect(config.targets).toHaveLength(1);
|
||||||
|
const t = config.targets[0]! as ResolvedWsTarget;
|
||||||
|
expect(t.type).toBe("ws");
|
||||||
|
expect(t.id).toBe("ws-test");
|
||||||
|
expect(t.ws.url).toBe("ws://example.com/ws");
|
||||||
|
expect(t.ws.headers).toEqual({});
|
||||||
|
expect(t.ws.ignoreSSL).toBe(false);
|
||||||
|
expect(t.ws.maxMessageBytes).toBe(4096);
|
||||||
|
expect(t.ws.receiveTimeout).toBe(5000);
|
||||||
|
expect(t.ws.send).toBeUndefined();
|
||||||
|
expect(t.ws.subprotocols).toEqual([]);
|
||||||
|
expect(t.expect).toEqual({ connected: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("解析带 send 的 ws 配置", async () => {
|
||||||
|
const configPath = join(tempDir, "ws-send.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`targets:
|
||||||
|
- id: "ws-echo"
|
||||||
|
name: "WS Echo 检查"
|
||||||
|
type: ws
|
||||||
|
ws:
|
||||||
|
url: "wss://api.example.com/ws"
|
||||||
|
headers:
|
||||||
|
Authorization: "Bearer token"
|
||||||
|
subprotocols:
|
||||||
|
- "json"
|
||||||
|
ignoreSSL: true
|
||||||
|
send: "ping"
|
||||||
|
receiveTimeout: 3000
|
||||||
|
maxMessageBytes: "8KB"
|
||||||
|
expect:
|
||||||
|
message:
|
||||||
|
- contains: "pong"
|
||||||
|
durationMs:
|
||||||
|
lte: 5000
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = await loadConfig(configPath);
|
||||||
|
expect(config.targets).toHaveLength(1);
|
||||||
|
const t = config.targets[0]! as ResolvedWsTarget;
|
||||||
|
expect(t.type).toBe("ws");
|
||||||
|
expect(t.ws.url).toBe("wss://api.example.com/ws");
|
||||||
|
expect(t.ws.headers).toEqual({ Authorization: "Bearer token" });
|
||||||
|
expect(t.ws.ignoreSSL).toBe(true);
|
||||||
|
expect(t.ws.maxMessageBytes).toBe(8192);
|
||||||
|
expect(t.ws.receiveTimeout).toBe(3000);
|
||||||
|
expect(t.ws.send).toBe("ping");
|
||||||
|
expect(t.ws.subprotocols).toEqual(["json"]);
|
||||||
|
expect(t.expect?.connected).toBe(true);
|
||||||
|
expect(t.expect?.message).toBeDefined();
|
||||||
|
expect(t.expect?.durationMs).toEqual({ lte: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ws 缺少 url 抛出错误", async () => {
|
||||||
|
const configPath = join(tempDir, "ws-no-url.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`targets:
|
||||||
|
- id: "t"
|
||||||
|
type: ws
|
||||||
|
ws: {}
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await loadConfig(configPath);
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect((error as Error).message).toContain("ws.url");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ws url 非 ws/wss 协议抛出错误", async () => {
|
||||||
|
const configPath = join(tempDir, "ws-bad-url.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`targets:
|
||||||
|
- id: "t"
|
||||||
|
type: ws
|
||||||
|
ws:
|
||||||
|
url: "http://example.com"
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await loadConfig(configPath);
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect((error as Error).message).toContain("ws:// 或 wss://");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ws expect.message 未配置 send 抛出错误", async () => {
|
||||||
|
const configPath = join(tempDir, "ws-no-send.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`targets:
|
||||||
|
- id: "t"
|
||||||
|
type: ws
|
||||||
|
ws:
|
||||||
|
url: "ws://example.com"
|
||||||
|
expect:
|
||||||
|
message:
|
||||||
|
- contains: "pong"
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await loadConfig(configPath);
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect((error as Error).message).toContain("send");
|
||||||
|
});
|
||||||
|
});
|
||||||
201
tests/server/checker/runner/ws/execute.test.ts
Normal file
201
tests/server/checker/runner/ws/execute.test.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import type { CheckerContext } from "../../../../../src/server/checker/runner/types";
|
||||||
|
import type { ResolvedWsTarget } from "../../../../../src/server/checker/runner/ws/types";
|
||||||
|
|
||||||
|
import { WsChecker } from "../../../../../src/server/checker/runner/ws/execute";
|
||||||
|
|
||||||
|
function createEchoServer() {
|
||||||
|
return Bun.serve({
|
||||||
|
fetch(req, server) {
|
||||||
|
const success = server.upgrade(req);
|
||||||
|
if (!success) return new Response("Upgrade failed", { status: 500 });
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
port: 0,
|
||||||
|
websocket: {
|
||||||
|
close() {
|
||||||
|
/* ws close */
|
||||||
|
},
|
||||||
|
message(ws, message) {
|
||||||
|
ws.send(message);
|
||||||
|
},
|
||||||
|
open() {
|
||||||
|
/* ws open */
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNoReplyServer() {
|
||||||
|
return Bun.serve({
|
||||||
|
fetch(req, server) {
|
||||||
|
const success = server.upgrade(req);
|
||||||
|
if (!success) return new Response("Upgrade failed", { status: 500 });
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
port: 0,
|
||||||
|
websocket: {
|
||||||
|
close() {
|
||||||
|
/* ws close */
|
||||||
|
},
|
||||||
|
message() {
|
||||||
|
/* no reply */
|
||||||
|
},
|
||||||
|
open() {
|
||||||
|
/* ws open */
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRejectServer() {
|
||||||
|
return Bun.serve({
|
||||||
|
fetch() {
|
||||||
|
return new Response("Forbidden", { status: 403 });
|
||||||
|
},
|
||||||
|
port: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeContext(overrides?: Partial<CheckerContext>): CheckerContext {
|
||||||
|
return {
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeWsTarget(overrides?: Partial<ResolvedWsTarget>): ResolvedWsTarget {
|
||||||
|
return {
|
||||||
|
description: null,
|
||||||
|
expect: { connected: true },
|
||||||
|
group: "default",
|
||||||
|
id: "test-ws",
|
||||||
|
intervalMs: 30000,
|
||||||
|
name: null,
|
||||||
|
timeoutMs: 10000,
|
||||||
|
type: "ws",
|
||||||
|
ws: {
|
||||||
|
headers: {},
|
||||||
|
ignoreSSL: false,
|
||||||
|
maxMessageBytes: 4096,
|
||||||
|
receiveTimeout: 5000,
|
||||||
|
subprotocols: [],
|
||||||
|
url: "ws://127.0.0.1:19999/ws",
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let echoServer: ReturnType<typeof createEchoServer>;
|
||||||
|
let noReplyServer: ReturnType<typeof createNoReplyServer>;
|
||||||
|
let rejectServer: ReturnType<typeof createRejectServer>;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
echoServer = createEchoServer();
|
||||||
|
noReplyServer = createNoReplyServer();
|
||||||
|
rejectServer = createRejectServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await echoServer.stop();
|
||||||
|
await noReplyServer.stop();
|
||||||
|
await rejectServer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WsChecker execute", () => {
|
||||||
|
const checker = new WsChecker();
|
||||||
|
|
||||||
|
test("可达性检查 - 连接成功", async () => {
|
||||||
|
const target = makeWsTarget({ ws: { ...makeWsTarget().ws, url: `ws://127.0.0.1:${echoServer.port}` } });
|
||||||
|
const result = await checker.execute(target, makeContext());
|
||||||
|
expect(result.matched).toBe(true);
|
||||||
|
expect(result.failure).toBeNull();
|
||||||
|
expect(result.observation!["connected"]).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("可达性检查 - 连接失败", async () => {
|
||||||
|
const target = makeWsTarget({ ws: { ...makeWsTarget().ws, url: "ws://127.0.0.1:1" } });
|
||||||
|
const result = await checker.execute(target, makeContext());
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.failure).not.toBeNull();
|
||||||
|
expect(result.observation!["connected"]).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("可达性检查 - 连接失败但 expect.connected=false", async () => {
|
||||||
|
const target = makeWsTarget({
|
||||||
|
expect: { connected: false },
|
||||||
|
ws: { ...makeWsTarget().ws, url: "ws://127.0.0.1:1" },
|
||||||
|
});
|
||||||
|
const result = await checker.execute(target, makeContext());
|
||||||
|
expect(result.matched).toBe(true);
|
||||||
|
expect(result.failure).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("交互模式 - 发送消息并收到响应", async () => {
|
||||||
|
const target = makeWsTarget({
|
||||||
|
expect: {
|
||||||
|
connected: true,
|
||||||
|
message: [{ kind: "value" as const, matcher: { equals: "ping" } }],
|
||||||
|
},
|
||||||
|
ws: {
|
||||||
|
...makeWsTarget().ws,
|
||||||
|
send: "ping",
|
||||||
|
url: `ws://127.0.0.1:${echoServer.port}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await checker.execute(target, makeContext());
|
||||||
|
expect(result.matched).toBe(true);
|
||||||
|
expect(result.observation!["message"]).toBe("ping");
|
||||||
|
expect(result.observation!["messageSize"]).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("交互模式 - 消息不匹配", async () => {
|
||||||
|
const target = makeWsTarget({
|
||||||
|
expect: {
|
||||||
|
connected: true,
|
||||||
|
message: [{ kind: "value" as const, matcher: { equals: "pong" } }],
|
||||||
|
},
|
||||||
|
ws: {
|
||||||
|
...makeWsTarget().ws,
|
||||||
|
send: "ping",
|
||||||
|
url: `ws://127.0.0.1:${echoServer.port}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await checker.execute(target, makeContext());
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.failure).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("交互模式 - receiveTimeout 超时", async () => {
|
||||||
|
const target = makeWsTarget({
|
||||||
|
ws: {
|
||||||
|
...makeWsTarget().ws,
|
||||||
|
receiveTimeout: 500,
|
||||||
|
send: "ping",
|
||||||
|
url: `ws://127.0.0.1:${noReplyServer.port}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await checker.execute(target, makeContext());
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.failure?.phase).toBe("message");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("HTTP 403 握手失败", async () => {
|
||||||
|
const target = makeWsTarget({ ws: { ...makeWsTarget().ws, url: `ws://127.0.0.1:${rejectServer.port}` } });
|
||||||
|
const result = await checker.execute(target, makeContext());
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.observation!["connected"]).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildDetail 连接成功", () => {
|
||||||
|
const detail = checker.buildDetail({ connected: true, connectTimeMs: 50, message: "hello" });
|
||||||
|
expect(detail).toContain("connected");
|
||||||
|
expect(detail).toContain("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildDetail 连接失败", () => {
|
||||||
|
const detail = checker.buildDetail({ connected: false, error: "connection refused" });
|
||||||
|
expect(detail).toContain("connection failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
95
tests/server/checker/runner/ws/resolve.test.ts
Normal file
95
tests/server/checker/runner/ws/resolve.test.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import type { ResolveContext } from "../../../../../src/server/checker/runner/types";
|
||||||
|
import type { ResolvedWsTarget } from "../../../../../src/server/checker/runner/ws/types";
|
||||||
|
import type { RawTargetConfig } from "../../../../../src/server/checker/types";
|
||||||
|
|
||||||
|
import { checkerRegistry } from "../../../../../src/server/checker/runner";
|
||||||
|
|
||||||
|
function asWs(resolved: ReturnType<ReturnType<typeof checkerRegistry.get>["resolve"]>): ResolvedWsTarget {
|
||||||
|
return resolved as ResolvedWsTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRawTarget(overrides?: Partial<RawTargetConfig>): RawTargetConfig {
|
||||||
|
return {
|
||||||
|
id: "test-ws",
|
||||||
|
type: "ws",
|
||||||
|
ws: { url: "ws://example.com/ws" },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeResolveContext(overrides?: Partial<ResolveContext>): ResolveContext {
|
||||||
|
return {
|
||||||
|
configDir: "/tmp",
|
||||||
|
defaultIntervalMs: 30000,
|
||||||
|
defaultTimeoutMs: 10000,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("WsChecker resolve", () => {
|
||||||
|
const checker = checkerRegistry.tryGet("ws")!;
|
||||||
|
|
||||||
|
test("最简 target 填充默认值", () => {
|
||||||
|
const resolved = asWs(checker.resolve(makeRawTarget(), makeResolveContext()));
|
||||||
|
expect(resolved.type).toBe("ws");
|
||||||
|
expect(resolved.ws.url).toBe("ws://example.com/ws");
|
||||||
|
expect(resolved.ws.headers).toEqual({});
|
||||||
|
expect(resolved.ws.ignoreSSL).toBe(false);
|
||||||
|
expect(resolved.ws.maxMessageBytes).toBe(4096);
|
||||||
|
expect(resolved.ws.receiveTimeout).toBe(5000);
|
||||||
|
expect(resolved.ws.send).toBeUndefined();
|
||||||
|
expect(resolved.ws.subprotocols).toEqual([]);
|
||||||
|
expect(resolved.expect).toEqual({ connected: true });
|
||||||
|
expect(resolved.group).toBe("default");
|
||||||
|
expect(resolved.intervalMs).toBe(30000);
|
||||||
|
expect(resolved.timeoutMs).toBe(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("完整配置正确 resolve", () => {
|
||||||
|
const raw = makeRawTarget({
|
||||||
|
expect: { connected: true, durationMs: { lte: 5000 } },
|
||||||
|
ws: {
|
||||||
|
headers: { Authorization: "Bearer token" },
|
||||||
|
ignoreSSL: true,
|
||||||
|
maxMessageBytes: "8KB",
|
||||||
|
receiveTimeout: 3000,
|
||||||
|
send: "ping",
|
||||||
|
subprotocols: ["json"],
|
||||||
|
url: "wss://api.example.com/ws",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const resolved = asWs(checker.resolve(raw, makeResolveContext()));
|
||||||
|
expect(resolved.ws.url).toBe("wss://api.example.com/ws");
|
||||||
|
expect(resolved.ws.headers).toEqual({ Authorization: "Bearer token" });
|
||||||
|
expect(resolved.ws.ignoreSSL).toBe(true);
|
||||||
|
expect(resolved.ws.maxMessageBytes).toBe(8192);
|
||||||
|
expect(resolved.ws.receiveTimeout).toBe(3000);
|
||||||
|
expect(resolved.ws.send).toBe("ping");
|
||||||
|
expect(resolved.ws.subprotocols).toEqual(["json"]);
|
||||||
|
expect(resolved.expect?.connected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect 默认 connected=true", () => {
|
||||||
|
const raw = makeRawTarget({ expect: { durationMs: { lte: 1000 } } });
|
||||||
|
const resolved = asWs(checker.resolve(raw, makeResolveContext()));
|
||||||
|
expect(resolved.expect?.connected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect.connected=false 保留", () => {
|
||||||
|
const raw = makeRawTarget({ expect: { connected: false } });
|
||||||
|
const resolved = asWs(checker.resolve(raw, makeResolveContext()));
|
||||||
|
expect(resolved.expect?.connected).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serialize 返回正确格式", () => {
|
||||||
|
const resolved = asWs(checker.resolve(makeRawTarget(), makeResolveContext()));
|
||||||
|
const serialized = checker.serialize(resolved);
|
||||||
|
expect(serialized.target).toBe("ws://example.com/ws");
|
||||||
|
const config = JSON.parse(serialized.config) as Record<string, unknown>;
|
||||||
|
expect(config["url"]).toBe("ws://example.com/ws");
|
||||||
|
expect(config["ignoreSSL"]).toBe(false);
|
||||||
|
expect(config["receiveTimeout"]).toBe(5000);
|
||||||
|
});
|
||||||
|
});
|
||||||
22
tests/server/checker/runner/ws/schema.test.ts
Normal file
22
tests/server/checker/runner/ws/schema.test.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { checkerRegistry } from "../../../../../src/server/checker/runner";
|
||||||
|
|
||||||
|
describe("WsChecker schema", () => {
|
||||||
|
const checker = checkerRegistry.tryGet("ws");
|
||||||
|
|
||||||
|
test("ws checker 注册到 registry", () => {
|
||||||
|
expect(checker).toBeDefined();
|
||||||
|
expect(checker?.type).toBe("ws");
|
||||||
|
expect(checker?.configKey).toBe("ws");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("schemas 包含 authoring 和 normalized config/expect", () => {
|
||||||
|
expect(checker).toBeDefined();
|
||||||
|
expect(Object.keys(checker!.schemas).sort()).toEqual(["authoring", "normalized"].sort());
|
||||||
|
expect(checker!.schemas.authoring.config).toBeDefined();
|
||||||
|
expect(checker!.schemas.authoring.expect).toBeDefined();
|
||||||
|
expect(checker!.schemas.normalized.config).toBeDefined();
|
||||||
|
expect(checker!.schemas.normalized.expect).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
215
tests/server/checker/runner/ws/validate.test.ts
Normal file
215
tests/server/checker/runner/ws/validate.test.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import type { CheckerValidationInput } from "../../../../../src/server/checker/runner/types";
|
||||||
|
|
||||||
|
import { validateWsConfig } from "../../../../../src/server/checker/runner/ws/validate";
|
||||||
|
|
||||||
|
function makeInput(targets: unknown[]): CheckerValidationInput {
|
||||||
|
return {
|
||||||
|
targets: targets as CheckerValidationInput["targets"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("validateWsConfig", () => {
|
||||||
|
test("合法 ws target 无错误", () => {
|
||||||
|
const issues = validateWsConfig(makeInput([{ id: "t1", type: "ws", ws: { url: "ws://example.com" } }]));
|
||||||
|
expect(issues).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("合法 wss target 无错误", () => {
|
||||||
|
const issues = validateWsConfig(makeInput([{ id: "t1", type: "ws", ws: { url: "wss://example.com/ws" } }]));
|
||||||
|
expect(issues).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("缺少 ws 分组", () => {
|
||||||
|
const issues = validateWsConfig(makeInput([{ id: "t1", type: "ws" }]));
|
||||||
|
expect(issues.length).toBeGreaterThan(0);
|
||||||
|
expect(issues.some((i) => i.message.includes("ws"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("缺少 url", () => {
|
||||||
|
const issues = validateWsConfig(makeInput([{ id: "t1", type: "ws", ws: {} }]));
|
||||||
|
expect(issues.some((i) => i.path.includes("url"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("url 非 ws/wss 协议报错", () => {
|
||||||
|
const issues = validateWsConfig(makeInput([{ id: "t1", type: "ws", ws: { url: "http://example.com" } }]));
|
||||||
|
expect(issues.some((i) => i.code === "invalid-url")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("url 格式非法报错", () => {
|
||||||
|
const issues = validateWsConfig(makeInput([{ id: "t1", type: "ws", ws: { url: "not-a-url" } }]));
|
||||||
|
expect(issues.some((i) => i.code === "invalid-url")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subprotocols 非数组报错", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([{ id: "t1", type: "ws", ws: { subprotocols: "json", url: "ws://example.com" } }]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.path.includes("subprotocols"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subprotocols 元素为空字符串报错", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([{ id: "t1", type: "ws", ws: { subprotocols: [""], url: "ws://example.com" } }]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.path.includes("subprotocols"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subprotocols 合法无错误", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([{ id: "t1", type: "ws", ws: { subprotocols: ["json", "binary"], url: "ws://example.com" } }]),
|
||||||
|
);
|
||||||
|
expect(issues).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignoreSSL 非布尔值报错", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([{ id: "t1", type: "ws", ws: { ignoreSSL: "yes", url: "ws://example.com" } }]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.path.includes("ignoreSSL"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("receiveTimeout 非数字报错", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([{ id: "t1", type: "ws", ws: { receiveTimeout: "slow", url: "ws://example.com" } }]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.path.includes("receiveTimeout"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("receiveTimeout 为负数报错", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([{ id: "t1", type: "ws", ws: { receiveTimeout: -1, url: "ws://example.com" } }]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.path.includes("receiveTimeout"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maxMessageBytes 非法值报错", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([{ id: "t1", type: "ws", ws: { maxMessageBytes: -1, url: "ws://example.com" } }]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.path.includes("maxMessageBytes"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ws 分组未知字段", () => {
|
||||||
|
const issues = validateWsConfig(makeInput([{ id: "t1", type: "ws", ws: { tls: true, url: "ws://example.com" } }]));
|
||||||
|
expect(issues.some((i) => i.message.includes("未知字段"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect.message 未配置 ws.send 报错", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([
|
||||||
|
{
|
||||||
|
expect: { message: [{ contains: "pong" }] },
|
||||||
|
id: "t1",
|
||||||
|
type: "ws",
|
||||||
|
ws: { url: "ws://example.com" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.message.includes("send"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect.message 配置 ws.send 无错误", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([
|
||||||
|
{
|
||||||
|
expect: { message: [{ contains: "pong" }] },
|
||||||
|
id: "t1",
|
||||||
|
type: "ws",
|
||||||
|
ws: { send: "ping", url: "ws://example.com" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(issues).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect.connected 非布尔值报错", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([
|
||||||
|
{
|
||||||
|
expect: { connected: "yes" },
|
||||||
|
id: "t1",
|
||||||
|
type: "ws",
|
||||||
|
ws: { url: "ws://example.com" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.path.includes("connected"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect.connected=false 时 expect.message 报错", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([
|
||||||
|
{
|
||||||
|
expect: { connected: false, message: [{ contains: "pong" }] },
|
||||||
|
id: "t1",
|
||||||
|
type: "ws",
|
||||||
|
ws: { send: "ping", url: "ws://example.com" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.message.includes("connected"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect.connected=false 时 expect.handshakeHeaders 报错", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([
|
||||||
|
{
|
||||||
|
expect: { connected: false, handshakeHeaders: { "Sec-WebSocket-Protocol": { equals: "json" } } },
|
||||||
|
id: "t1",
|
||||||
|
type: "ws",
|
||||||
|
ws: { url: "ws://example.com" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.message.includes("connected"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect.connected=false 时 expect.connectTimeMs 报错", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([
|
||||||
|
{
|
||||||
|
expect: { connected: false, connectTimeMs: { lte: 1000 } },
|
||||||
|
id: "t1",
|
||||||
|
type: "ws",
|
||||||
|
ws: { url: "ws://example.com" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.message.includes("connected"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect.connected=false 单独配置合法", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([
|
||||||
|
{
|
||||||
|
expect: { connected: false },
|
||||||
|
id: "t1",
|
||||||
|
type: "ws",
|
||||||
|
ws: { url: "ws://example.com" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(issues).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect 未知字段报错", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([
|
||||||
|
{
|
||||||
|
expect: { status: [200] },
|
||||||
|
id: "t1",
|
||||||
|
type: "ws",
|
||||||
|
ws: { url: "ws://example.com" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.message.includes("未知字段"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("非 ws 类型 target 跳过", () => {
|
||||||
|
const issues = validateWsConfig(makeInput([{ http: { url: "http://example.com" }, id: "t1", type: "http" }]));
|
||||||
|
expect(issues).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user