1
0

Compare commits

...

4 Commits

Author SHA1 Message Date
c120690cf1 feat: target 时间配置校验,interval 最小 10s,timeout 不大于 interval
在配置加载阶段新增通用 target 时间字段语义校验:
- interval 解析后不得小于 10s
- timeout 解析后不得大于同一 target 的 interval
- 默认值(30s / 10s)参与校验
- 变量引用先解析再校验
- 格式错误优先于关系错误,避免级联提示
2026-05-25 17:48:51 +08:00
77c6015b3a refactor: 将 checker normalize 职责下沉到各 runner 目录
- 新增 CheckerDefinition.normalize 必需方法,typecheck 兜底遗漏实现
- 新增 expect/normalize.ts 共享 helper(compactExpect、normalizeValue、
  normalizeContent、normalizeKeyed)
- 为 HTTP、Cmd、DB、TCP、UDP、ICMP、LLM、WS、DNS 各新增独立 normalize.ts
- 简化 normalizer.ts:删除所有 checker type switch,改为 registry 委托
- 修复 DNS authoring 简写 bug:durationMs、valueCount、result 等字段
  现可通过完整加载链路
- 新增 DNS 回归测试和 registry 级合同测试
- 更新 docs/development/checker.md:补充 normalize 规范、文件结构、
  测试要求和 checklist
2026-05-25 16:16:41 +08:00
c1db793073 feat: WS checker,支持可达性检测和单次请求-响应交互验证 2026-05-25 14:13:43 +08:00
714b635aef docs: 重构文档体系
- 合并 DEVELOPMENT.md 至 docs/development/README.md
- 合并 CONTRIBUTING.md 至 docs/development/checker.md
- 合并 build-release.md 至 release.md
- 合并 testing-quality.md 内容至各专题文档
- 合并 status-model.md 至 expectations.md
- 新增 docs/user/README.md 用户入口
- 简化 docs/README.md 文档路由
- 各专题文档新增适用场景和更新触发条件
- 更新 openspec/config.yaml 文档规则
2026-05-25 10:47:52 +08:00
59 changed files with 3608 additions and 865 deletions

View File

@@ -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 已通过或记录未执行原因
```

View File

@@ -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) |
## 已知限制
当前不做告警通知、拨测目标动态增删、认证鉴权和分布式部署。

View File

@@ -61,7 +61,7 @@ targets:
lte: 5000
```
完整配置、checkerexpect 规则参见 [配置文件](docs/user/configuration.md)、[Checker 参考](docs/user/checkers/README.md) 和 [校验规则](docs/user/expectations.md)。
完整配置、checkerexpect 和部署说明参见 [用户文档](docs/user/README.md)、[配置文件](docs/user/configuration.md)、[Checker 参考](docs/user/checkers/README.md) 和 [校验规则](docs/user/expectations.md)。
## 生产运行
@@ -74,15 +74,16 @@ Docker、跨平台发布包和运行时注意事项参见 [部署文档](docs/us
## 文档导航
| 入口 | 内容 |
| -------------------------------------------- | ---------------------------------------------------- |
| [文档总览](docs/README.md) | 全部文档入口和文档归属矩阵 |
| [配置文件](docs/user/configuration.md) | YAML 结构、变量、server、targets 通用字段 |
| [Checker 参考](docs/user/checkers/README.md) | 所有 checker 的配置、expect 和示例 |
| [校验规则](docs/user/expectations.md) | ValueMatcher、ContentExpectations、KeyedExpectations |
| [部署文档](docs/user/deployment.md) | 构建、Docker、发布包和容器运行边界 |
| [状态模型](docs/user/status-model.md) | UP/DOWN、failure、observation、detail |
| [故障排查](docs/user/troubleshooting.md) | 常见运行问题和排查入口 |
| 入口 | 内容 |
| -------------------------------------------- | ------------------------------------------- |
| [文档总览](docs/README.md) | 全部文档入口和文档归属矩阵 |
| [用户文档](docs/user/README.md) | 配置、部署、expect、排障和 checker 使用入口 |
| [配置文件](docs/user/configuration.md) | YAML 结构、变量、server、targets 通用字段 |
| [Checker 参考](docs/user/checkers/README.md) | 所有 checker 的配置、expect 和示例 |
| [校验规则](docs/user/expectations.md) | expect 规则、状态判定、failure、observation |
| [部署文档](docs/user/deployment.md) | 构建、Docker、发布包和容器运行边界 |
| [故障排查](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
```
开发入口参见 [DEVELOPMENT.md](DEVELOPMENT.md)。新增或修改 checker 前请先阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。
开发入口参见 [开发文档](docs/development/README.md)。新增或修改 checker 前请先阅读 [Checker 开发](docs/development/checker.md)。
## License

View File

@@ -2,40 +2,23 @@
本文档是 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
docs/
README.md
development/
README.md
architecture.md
backend.md
frontend.md
release.md
checker.md
user/
deployment.md
README.md
configuration.md
deployment.md
expectations.md
status-model.md
troubleshooting.md
checkers/
README.md
@@ -47,40 +30,75 @@ docs/
icmp.md
dns.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` |
| YAML 顶层结构、server、variables、targets 通用字段变化 | `docs/user/configuration.md` |
| checker 配置、expect 字段、示例变化 | `docs/user/checkers/` 对应文档 |
| ValueMatcher、ContentExpectations、KeyedExpectations 规则变化 | `docs/user/expectations.md` |
| 构建产物运行、Docker、发布包、运行时能力变化 | `docs/user/deployment.md` |
| UP/DOWN 判定、failure、observation、detail 行为变化 | `docs/user/status-model.md` |
| 常见运行问题、依赖命令、容器权限、配置校验问题变化 | `docs/user/troubleshooting.md` |
| 开发入口、常用命令、质量门禁、全局规则变化 | `DEVELOPMENT.md` |
| 架构边界、启动流程、运行时流程变化 | `docs/development/architecture.md` |
| 后端 API、store、engine、logger、expect 实现机制变化 | `docs/development/backend.md` |
| 前端技术栈、组件、样式、数据层规范变化 | `docs/development/frontend.md` |
| 新增或修改 checker 的开发机制变化 | `CONTRIBUTING.md``docs/development/checker-development.md` |
| 构建、发布、脚本、项目配置维护方式变化 | `docs/development/build-release.md` |
| 测试、lint、typecheck、hooks、格式化规范变化 | `docs/development/testing-quality.md` |
| 包管理、依赖、目录、提交、OpenSpec 约定变化 | `DEVELOPMENT.md` |
| 文档同步规则、文档影响分析规则变化 | `docs/README.md``openspec/config.yaml` |
| AI 提示词资产变化 | `docs/prompts/` |
| 变更类型 | 默认更新位置 |
| -------------------------------------------------------------- | -------------------------------------------------------------- |
| 项目定位、核心能力、快速开始、顶层文档导航 | `README.md` |
| 文档路由、文档更新规则、文档归属矩阵 | `docs/README.md``openspec/config.yaml` |
| 开发入口、常用命令、质量门禁、全局工程规则、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 约定 | `docs/development/checker.md` |
| 构建、发布、Dockerfile、脚本、前后端静态资源集成 | `docs/development/release.md` |
| YAML 顶层结构、server、variables、targets 通用字段 | `docs/user/configuration.md` |
| checker 配置、expect 字段、示例、用户可见 checker 行为 | `docs/user/checkers/<type>.md``docs/user/checkers/README.md` |
| ValueMatcher、ContentExpectations、KeyedExpectations、状态模型 | `docs/user/expectations.md` |
| 构建产物运行、Docker 参数、发布包、运行时依赖 | `docs/user/deployment.md` |
| 常见运行问题、依赖命令、容器权限、配置校验问题 | `docs/user/troubleshooting.md` |
## development 文档如何更新
开发文档解释“如何实现和维护”。代码变更影响开发者理解、开发流程、测试方式或架构边界时,必须更新 `docs/development/` 对应文档。
- 全局规则、常用命令、质量门禁、目录边界、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
代码或配置变更
├─ 用户能感知吗?
│ ├─ 配置 / checker / expect -> docs/user/
│ ├─ 部署 / 运行 / release -> docs/user/deployment.md
│ ├─ 状态 / 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
└─ 都不是
└─ 收尾说明无需更新文档及原因
-> 用户能感知吗?更新 docs/user/ 或 README.md
-> 开发者需要知道吗?更新 docs/development/
-> 文档规则变化吗?更新 docs/README.md 和 openspec/config.yaml
-> 都不是?收尾说明写明无需更新文档及原因
```
## 维护原则
- 根目录入口文档保持轻量,不承载完整配置参考和实现教程。
- 用户文档解释“如何使用”,开发文档解释“如何实现和维护”。
- 配置事实来源是 TypeBox schema、`probe-config.schema.json`、语义校验器和测试;文档负责解释和示例。
- 每次代码变更都必须做文档影响分析;有影响时更新对应专题文档,无影响时在收尾说明中写明原因。
- 同一字段表只在最贴近读者的文档中完整展开,其他文档用链接引用。
- README 不承载完整配置表和 checker 表DEVELOPMENT 不承载完整架构百科和 checker 教程。
同一事实只在最贴近读者的文档中完整展开,其他文档使用链接引用。根目录 README 保持轻量不承载完整配置参考、checker 表或实现教程。
## 收尾说明示例

View File

@@ -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 请求流程、前后端边界 |
| [backend.md](backend.md) | 后端库优先级、API 路由、共享 helpers、类型规范、配置契约、store、engine、logger、expect、错误模型 |
| [frontend.md](frontend.md) | React、TDesign、TanStack Query、组件、样式和前端测试规范 |
| [checker-development.md](checker-development.md) | 新增或修改 checker 的实现机制和完整 checklist |
| [build-release.md](build-release.md) | 开发服务、前后端集成、构建、Docker、release、脚本、环境变量 |
| [testing-quality.md](testing-quality.md) | lint、format、typecheck、test、hooks 和测试编写规范 |
| [../../DEVELOPMENT.md](../../DEVELOPMENT.md) | 包管理、依赖、目录、提交、OpenSpec 和项目级约定 |
| [../README.md](../README.md) | 文档影响分析、文档归属矩阵和按任务阅读路径 |
| 文档 | 内容 |
| ---------------------------------- | ------------------------------------------------------------------------------------------------- |
| [architecture.md](architecture.md) | 项目结构、启动流程、运行时流程、HTTP 请求流程、前后端边界 |
| [backend.md](backend.md) | 后端库优先级、API 路由、共享 helpers、类型规范、配置契约、store、engine、logger、expect、错误模型 |
| [frontend.md](frontend.md) | React、TDesign、TanStack Query、组件、样式和前端测试规范 |
| [checker.md](checker.md) | 新增或修改 checker 的实现机制、测试要求、文档同步和 checklist |
| [release.md](release.md) | 开发服务、前后端集成、构建、Docker、release、脚本、环境变量 |
| [../README.md](../README.md) | 文档路由、文档归属矩阵、development/user 文档更新规则 |
## 常用命令
| 命令 | 说明 |
| -------------------------------- | ---------------------------------------- |
| `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/` |
| 配置 schema | TypeBox fragments、`probe-config.schema.json`、schema 测试 |
| 项目全局规则 | `openspec/config.yaml``DEVELOPMENT.md`、本目录专题文档 |
| checker 贡献流程 | `CONTRIBUTING.md``checker-development.md` |
| 主题 | 事实来源 |
| -------------- | ---------------------------------------------------------- |
| 代码结构和实现 | `src/``scripts/``tests/` |
| 配置 schema | TypeBox fragments、`probe-config.schema.json`、schema 测试 |
| 项目全局规则 | `openspec/config.yaml`本文档、本目录专题文档 |
| checker 流程 | [checker.md](checker.md) |
## 更新触发条件
修改常用命令、质量门禁、全局工程规则、目录边界、OpenSpec 协作方式或开发文档索引时,必须更新本文档。

View File

@@ -1,5 +1,9 @@
# 架构与边界
本文档说明 DiAL 的项目结构、启动链路、运行时流程、HTTP 请求流程和前后端边界。
适用场景修改目录边界、启动流程、运行时调度、HTTP server、前后端集成方式或主要模块职责。
## 项目结构
```text
@@ -104,3 +108,7 @@ Request
| `src/server/checker/expect/` | 跨 checker 复用的断言基础设施 |
| `src/web/` | React Dashboard |
| `src/shared/api.ts` | 前后端共享 API 类型 |
## 更新触发条件
修改项目结构、启动流程、运行时流程、HTTP 请求流程、前后端边界或主要模块职责时,必须更新本文档。

View File

@@ -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 |
| `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"`
## 后端测试与验证
| 变更类型 | 测试重点 |
| ---------------------- | ---------------------------------------- |
| 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 基础设施、错误模型或后端测试规范时,必须更新本文档。

View File

@@ -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 可写 DSLNormalized 描述 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
View 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 可写 DSLNormalized 描述 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 时,必须更新本文档。

View File

@@ -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`
- 颜色统一使用 TDesign CSS tokens不使用硬编码色值。
## 前端测试
## 前端测试与验证
- 测试目录为 `tests/web/`,结构对应 `src/web/`
- 重点测试 `constants/` 中的纯函数
- 单元测试重点覆盖 `constants/``utils/` 和 hooks 中的纯逻辑
- 组件测试使用 jsdom 和 `@testing-library/react`
- 测试用户行为而非实现细节。
- 只 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`
## 更新触发条件
修改前端技术栈、组件边界、数据流、样式规则、测试环境或前端验证方式时,必须更新本文档。

View File

@@ -1,5 +1,9 @@
# 构建与发布
本文档说明开发服务、前后端集成、生产构建、Docker 镜像、跨平台 release 和相关脚本维护方式。
适用场景:修改 `scripts/`、构建流程、Dockerfile、静态资源集成、release 打包、运行时环境变量或部署产物。
## 开发期运行
```bash
@@ -105,3 +109,19 @@ release 流程:
- `scripts/build-common.ts` 中的 import specifier 输出必须使用 `/` 分隔符。
- 跨平台路径测试不得用当前平台 `path.sep` 伪装其他平台,应使用 `node:path.win32` 或等价注入方式模拟。
- 如本地 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、脚本参数或发布验证方式时必须更新本文档。

View File

@@ -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
View 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)。

View File

@@ -2,6 +2,8 @@
Checker 是 DiAL 的拨测执行单元。每个 target 通过 `type` 选择一个 checker并配置对应的专属字段和 `expect` 规则。
适用场景:查询 checker 类型选择、专属配置、expect 字段、示例和各 checker 文档入口。
## 支持的类型
| 类型 | 用途 | 文档 |
@@ -14,6 +16,7 @@ Checker 是 DiAL 的拨测执行单元。每个 target 通过 `type` 选择一
| `icmp` | 基于系统 `ping` 的存活、延迟、丢包检查 | [ICMP](icmp.md) |
| `dns` | 本机解析或指定 DNS server 协议级检查 | [DNS](dns.md) |
| `llm` | 大模型服务应用层健康检查 | [LLM](llm.md) |
| `ws` | WebSocket 可达性和消息交互检查 | [WS](ws.md) |
## 选择建议
@@ -27,6 +30,7 @@ Checker 是 DiAL 的拨测执行单元。每个 target 通过 `type` 选择一
| 主机可达性、延迟、丢包率 | `icmp` |
| 域名解析值、DNS RCODE、TTL、flags | `dns` |
| LLM API 是否可用、输出是否符合预期 | `llm` |
| WebSocket 可达性或消息交互验证 | `ws` |
## 通用字段
@@ -35,3 +39,7 @@ Checker 是 DiAL 的拨测执行单元。每个 target 通过 `type` 选择一
## 通用断言模型
各 checker 的 `expect` 字段复用 `ValueMatcher``ContentExpectations``KeyedExpectations`。详情见 [校验规则](../expectations.md)。
## 更新触发条件
新增、移除或修改 checker 类型、用途、选择建议、通用字段或通用断言模型时必须更新本文档。checker 专属字段变化还必须同步更新对应 `checkers/<type>.md`

81
docs/user/checkers/ws.md Normal file
View 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
```

View File

@@ -80,10 +80,10 @@ targets:
## 内置默认值
| 字段 | 默认值 |
| ---------- | ------ |
| `interval` | `30s` |
| `timeout` | `10s` |
| 字段 | 默认值 | 约束 |
| ---------- | ------ | ----------------------- |
| `interval` | `30s` | 最小 `10s` |
| `timeout` | `10s` | 必须小于等于 `interval` |
各 checker 专属默认值见 [Checker 参考](checkers/README.md)。
@@ -91,12 +91,12 @@ targets:
`variables` 是顶层动态键值表key 必须符合 `[a-zA-Z_][a-zA-Z0-9_]*`value 仅支持 string、number、boolean。`server``probes``targets` 中的字符串值可引用变量。
| 语法 | 说明 |
| --------- | ------------------------- | ------------------------------------------ |
| `${key}` | 引用 variables 或环境变量 |
| `${key | default}` | variables 和环境变量都不存在时使用默认值 |
| `${key | }` | variables 和环境变量都不存在时使用空字符串 |
| `$${key}` | 转义输出字面量 `${key}` |
| 语法 | 说明 |
| ----------------- | ------------------------------------------ |
| `${key}` | 引用 variables 或环境变量 |
| `${key\|default}` | variables 和环境变量都不存在时使用默认值 |
| `${key\|}` | variables 和环境变量都不存在时使用空字符串 |
| `$${key}` | 转义输出字面量 `${key}` |
解析优先级为 `variables -> process.env -> 默认值`。三者均不存在时配置校验失败。字段值完整等于单个变量引用时会保留 number、boolean、string 类型;部分拼接时统一转为字符串。
@@ -121,10 +121,10 @@ targets:
| `id` | 目标唯一标识,最长 30 字符,支持字母数字、下划线、连字符,不参与变量替换 | 是 | 无 |
| `name` | 展示名称,最长 30 字符,支持变量替换,可省略或显式 null前端展示时 null 回退到 `id` | 否 | 无 |
| `description` | 目标描述,最长 500 字符,支持变量替换,可省略或显式 null允许空字符串 | 否 | 无 |
| `type` | 目标类型:`http``cmd``db``tcp``udp``dns``icmp``llm` | 是 | 无 |
| `type` | 目标类型:`http``cmd``db``tcp``udp``dns``icmp``llm``ws` | 是 | 无 |
| `group` | 分组名称 | 否 | `default` |
| `interval` | 拨测间隔 | 否 | `30s` |
| `timeout` | 超时时间 | 否 | `10s` |
| `interval` | 拨测间隔,最小 `10s` | 否 | `30s` |
| `timeout` | 超时时间,必须小于等于 `interval` | 否 | `10s` |
## Checker 专属配置

View File

@@ -1,10 +1,62 @@
# 校验规则
本文档说明 `expect` 规则、状态判定、failure、observation 和各 checker 的快速失败顺序。
适用场景:编写 `expect`、理解 UP/DOWN、排查 mismatch/error、查看返回结果中的 `failure``observation`
`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
`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` |
| LLM http | `status -> headers -> 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
@@ -102,3 +155,7 @@ expect:
旧字段 `maxDurationMs``maxPacketLoss``maxAvgLatencyMs``maxMaxLatencyMs` 和旧正则字段 `match` 已移除,请分别改用 `durationMs`、ICMP matcher 字段和 `regex`
非法配置会阻止启动并输出错误信息。除动态键值表(`headers``env``variables`)外,未知字段会导致启动失败,请使用 YAML 注释表达说明。
## 更新触发条件
修改 expect 断言模型、状态判定、failure 字段、observation 字段、快速失败顺序或已移除字段说明时,必须更新本文档。

View File

@@ -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 主要负责存储、筛选、排序、分页和基础聚合。

View File

@@ -20,10 +20,10 @@ DiAL 启动时会校验 YAML 配置。除动态键值表(`headers`、`env`、`
常见修复:
| 问题 | 修复 |
| -------------- | ----------------------------------- | --------- |
| -------------- | ----------------------------------- |
| 环境变量未设置 | 设置环境变量或在 `variables` 中声明 |
| 希望允许空值 | 使用 `${key | }` |
| 希望提供默认值 | 使用 `${key | default}` |
| 希望允许空值 | 使用 `${key\|}` |
| 希望提供默认值 | 使用 `${key\|default}` |
| 希望输出字面量 | 使用 `$${key}` |
## ICMP checker 无法运行

View File

@@ -3,20 +3,21 @@ schema: fast-drive
context: |
- 使用中文(注释、文档、交流),面向中文开发者
- openspec文档的关键字按openspec规范使用不要翻译为中文
- 本项目openspec使用fast-drive自定义schema变更文档只包含design.md和tasks.md无proposal.md和specs
- **优先阅读docs/README.md**判断文档归属和本次任务需要读取的专题文档
- README.md用于项目概览、快速开始和用户入口DEVELOPMENT.md用于开发入口、全局规则和质量门禁CONTRIBUTING.md仅在新增或修改checker、贡献流程时必读
- 所有代码风格、命名、注解、依赖、API等开发规范以DEVELOPMENT.md和docs/development/下对应专题文档为准
- 新增或修改checker时必须阅读CONTRIBUTING.md和docs/development/checker-development.md
- 每次代码变更都必须执行文档影响分析判断是否影响用户可见行为、配置格式、checker行为、expect规则、API、部署方式、开发流程、架构边界、测试规范或构建发布流程
- 若影响用户使用方式、配置格式、checker行为、expect规则、部署方式或运行行为必须同步更新docs/user/下对应文档README.md仅在项目定位、快速开始、核心能力列表或文档导航变化时更新
- 若影响开发流程、架构边界、质量门禁、测试规范、构建发布流程或checker开发机制必须同步更新DEVELOPMENT.md、CONTRIBUTING.md或docs/development/下对应文档
- 若影响文档同步规则或文档归属矩阵必须同步更新docs/README.md和openspec/config.yaml
- 若无需更新文档,必须在收尾说明中说明原因
- README.md用于项目概览、快速开始和顶层文档引导docs/user/README.md用于用户使用入口docs/development/README.md用于开发入口、全局规则和质量门禁
- 所有代码风格、命名、注解、依赖、API等开发规范以docs/development/README.md和docs/development/下对应专题文档为准
- 新增或修改checker时必须阅读docs/development/checker.md、docs/user/checkers/README.md和相近checker用户文档
- 每次代码变更都必须执行文档影响分析判断是否影响用户可见行为、配置格式、checker行为、expect规则、API、部署方式、开发流程、架构边界、测试规范或构建发布流程
- 若影响用户使用方式、配置格式、checker行为、expect规则、部署方式或运行行为必须同步更新docs/user/下对应文档README.md仅在项目定位、快速开始、核心能力列表或文档导航变化时更新
- 若影响开发流程、架构边界、质量门禁、测试规范、构建发布流程或checker开发机制必须同步更新docs/development/README.md或docs/development/下对应专题文档
- 若影响文档同步规则或文档归属矩阵必须同步更新docs/README.md和openspec/config.yaml
- 若无需更新文档,必须在收尾说明中说明原因
- 新增代码优先复用已有组件、工具、依赖库,不引入新依赖
- 新增的逻辑必须编写完善的测试,并保证测试的正确性,不允许跳过任何测试
- 这是基于bun实现的前端后一体化项目使用bun作为唯一包管理器严禁使用pnpm、npm使用bunx运行工具严禁使用npx、pnpx
- src/server目录下是基于bun实现的后端代码
- 后端库使用优先级Bun 内置 API > es-toolkit > 主流三方库 > 项目公共工具 > 自行实现
- 后端库使用优先级Bun 内置 API > es-toolkit > 标准 Web API > 主流三方库 > 项目公共工具 > 自行实现
- src/web目录下是基于Bun HTML import、React、TDesign实现的前端代码
- 前端样式开发优先级TDesign组件 > 组件props > TDesign CSS tokens(--td-*) > styles.css CSS类 > 自行开发组件
- 前端严禁组件内联style属性、CSS覆盖TD内部类名、使用!important、硬编码色值
@@ -27,8 +28,6 @@ context: |
- (当前项目未上线,不需要考虑向前兼容)
rules:
explore:
- 本项目openspec使用fast-drive自定义schema变更文档只包含design.md和tasks.md无proposal.md和specs
design:
- 先前的讨论技术方案要尽可能体现在设计文档中,便于指导实现阶段不偏离已定的技术路线
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文档

View File

@@ -5746,6 +5746,633 @@
]
}
}
},
{
"additionalProperties": false,
"type": "object",
"required": [
"id",
"type",
"ws"
],
"properties": {
"description": {
"anyOf": [
{
"type": "null"
},
{
"anyOf": [
{
"maxLength": 500,
"type": "string"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
}
]
},
"expect": {
"additionalProperties": false,
"type": "object",
"properties": {
"connected": {
"anyOf": [
{
"type": "boolean"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
},
"connectTimeMs": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"additionalProperties": false,
"minProperties": 1,
"type": "object",
"properties": {
"contains": {
"type": "string"
},
"empty": {
"type": "boolean"
},
"equals": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"items": {},
"type": "array"
},
{
"additionalProperties": {},
"type": "object"
}
]
},
"exists": {
"type": "boolean"
},
"gt": {
"type": "number"
},
"gte": {
"type": "number"
},
"lt": {
"type": "number"
},
"lte": {
"type": "number"
},
"regex": {
"type": "string"
}
}
}
]
},
"durationMs": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"additionalProperties": false,
"minProperties": 1,
"type": "object",
"properties": {
"contains": {
"type": "string"
},
"empty": {
"type": "boolean"
},
"equals": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"items": {},
"type": "array"
},
{
"additionalProperties": {},
"type": "object"
}
]
},
"exists": {
"type": "boolean"
},
"gt": {
"type": "number"
},
"gte": {
"type": "number"
},
"lt": {
"type": "number"
},
"lte": {
"type": "number"
},
"regex": {
"type": "string"
}
}
}
]
},
"handshakeHeaders": {
"additionalProperties": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"additionalProperties": false,
"minProperties": 1,
"type": "object",
"properties": {
"contains": {
"type": "string"
},
"empty": {
"type": "boolean"
},
"equals": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"items": {},
"type": "array"
},
{
"additionalProperties": {},
"type": "object"
}
]
},
"exists": {
"type": "boolean"
},
"gt": {
"type": "number"
},
"gte": {
"type": "number"
},
"lt": {
"type": "number"
},
"lte": {
"type": "number"
},
"regex": {
"type": "string"
}
}
}
]
},
"type": "object"
},
"message": {
"type": "array",
"items": {
"additionalProperties": false,
"minProperties": 1,
"type": "object",
"properties": {
"contains": {
"type": "string"
},
"empty": {
"type": "boolean"
},
"equals": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"items": {},
"type": "array"
},
{
"additionalProperties": {},
"type": "object"
}
]
},
"exists": {
"type": "boolean"
},
"gt": {
"type": "number"
},
"gte": {
"type": "number"
},
"lt": {
"type": "number"
},
"lte": {
"type": "number"
},
"regex": {
"type": "string"
},
"css": {
"additionalProperties": false,
"type": "object",
"required": [
"selector"
],
"properties": {
"attr": {
"type": "string"
},
"selector": {
"minLength": 1,
"type": "string"
},
"contains": {
"type": "string"
},
"empty": {
"type": "boolean"
},
"equals": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"items": {},
"type": "array"
},
{
"additionalProperties": {},
"type": "object"
}
]
},
"exists": {
"type": "boolean"
},
"gt": {
"type": "number"
},
"gte": {
"type": "number"
},
"lt": {
"type": "number"
},
"lte": {
"type": "number"
},
"regex": {
"type": "string"
}
}
},
"json": {
"additionalProperties": false,
"type": "object",
"required": [
"path"
],
"properties": {
"path": {
"type": "string"
},
"contains": {
"type": "string"
},
"empty": {
"type": "boolean"
},
"equals": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"items": {},
"type": "array"
},
{
"additionalProperties": {},
"type": "object"
}
]
},
"exists": {
"type": "boolean"
},
"gt": {
"type": "number"
},
"gte": {
"type": "number"
},
"lt": {
"type": "number"
},
"lte": {
"type": "number"
},
"regex": {
"type": "string"
}
}
},
"xpath": {
"additionalProperties": false,
"type": "object",
"required": [
"path"
],
"properties": {
"path": {
"minLength": 1,
"type": "string"
},
"contains": {
"type": "string"
},
"empty": {
"type": "boolean"
},
"equals": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"items": {},
"type": "array"
},
{
"additionalProperties": {},
"type": "object"
}
]
},
"exists": {
"type": "boolean"
},
"gt": {
"type": "number"
},
"gte": {
"type": "number"
},
"lt": {
"type": "number"
},
"lte": {
"type": "number"
},
"regex": {
"type": "string"
}
}
}
}
}
}
}
},
"group": {
"type": "string"
},
"id": {
"maxLength": 30,
"minLength": 1,
"type": "string"
},
"interval": {
"type": "string"
},
"name": {
"anyOf": [
{
"type": "null"
},
{
"anyOf": [
{
"maxLength": 30,
"minLength": 1,
"type": "string"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
}
]
},
"timeout": {
"type": "string"
},
"type": {
"const": "ws",
"type": "string"
},
"ws": {
"additionalProperties": false,
"type": "object",
"required": [
"url"
],
"properties": {
"headers": {
"additionalProperties": {
"anyOf": [
{
"type": "string"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
},
"type": "object"
},
"ignoreSSL": {
"anyOf": [
{
"type": "boolean"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
},
"maxMessageBytes": {
"anyOf": [
{
"type": "string"
},
{
"minimum": 0,
"type": "integer"
}
]
},
"receiveTimeout": {
"anyOf": [
{
"minimum": 0,
"type": "number"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
},
"send": {
"type": "string"
},
"subprotocols": {
"type": "array",
"items": {
"minLength": 1,
"type": "string"
}
},
"url": {
"minLength": 1,
"type": "string"
}
}
}
}
}
]
}

View File

@@ -327,3 +327,29 @@ targets:
finishReason: "stop"
output:
- 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

View File

@@ -32,6 +32,8 @@ const DEFAULT_ROTATION_SIZE = "50MB";
const DEFAULT_ROTATION_FREQUENCY: RotationFrequency = "daily";
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_ROTATION_FREQUENCIES: RotationFrequency[] = ["hourly", "daily", "weekly"];
@@ -60,7 +62,7 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
throw new Error("配置文件内容为空或格式无效");
}
const normalizeResult = normalizeAuthoringConfig(parsed);
const normalizeResult = normalizeAuthoringConfig(parsed, checkerRegistry);
if (normalizeResult.issues.length > 0) {
throwConfigIssues(dedupeIssues(normalizeResult.issues));
}
@@ -208,6 +210,14 @@ function resolveTarget(
return result;
}
function tryParseDuration(value: string): null | number {
try {
return parseDuration(value);
} catch {
return null;
}
}
function validateConfig(config: NormalizedProbeConfig): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
if (!Array.isArray(config.targets) || config.targets.length === 0) {
@@ -291,18 +301,21 @@ function validateConfig(config: NormalizedProbeConfig): ConfigValidationIssue[]
: isString(targetIdValue)
? targetIdValue
: undefined;
validateDurationValue(
isString(targetRecord["interval"]) ? targetRecord["interval"] : undefined,
`targets[${i}].interval`,
issues,
targetName,
);
validateDurationValue(
isString(targetRecord["timeout"]) ? targetRecord["timeout"] : undefined,
`targets[${i}].timeout`,
issues,
targetName,
);
const intervalRaw = isString(targetRecord["interval"]) ? targetRecord["interval"] : undefined;
const timeoutRaw = isString(targetRecord["timeout"]) ? targetRecord["timeout"] : undefined;
validateDurationValue(intervalRaw, `targets[${i}].interval`, issues, targetName);
validateDurationValue(timeoutRaw, `targets[${i}].timeout`, issues, targetName);
const intervalMs = tryParseDuration(intervalRaw ?? DEFAULT_INTERVAL);
const timeoutMs = tryParseDuration(timeoutRaw ?? DEFAULT_TIMEOUT);
if (intervalMs !== null && intervalMs < MINIMUM_INTERVAL_MS) {
issues.push(issue("invalid-value", `targets[${i}].interval`, "interval 不能小于 10s", targetName));
}
if (intervalMs !== null && timeoutMs !== null && timeoutMs > intervalMs) {
issues.push(issue("invalid-value", `targets[${i}].timeout`, "timeout 不能大于 interval", targetName));
}
}
return issues;

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

View File

@@ -1,17 +1,17 @@
import { isPlainObject } from "es-toolkit";
import type { CheckerRegistry } from "./runner/registry";
import type { ConfigValidationIssue } from "./schema/issues";
import type { AuthoringProbeConfig, NormalizedProbeConfig } from "./schema/types";
import type { RawTargetConfig } from "./types";
import { resolveContentExpectations } from "./expect/content";
import { CONTENT_EXTRACTOR_KEY_SET, MATCHER_KEY_SET } from "./expect/keys";
import { isValueMatcherObject, isValueMatcherPrimitive, resolveValueExpectation } from "./expect/value";
import { checkerRegistry } from "./runner";
import { resolveVariables } from "./variables";
type ExpectRecord = Record<string, unknown>;
export function normalizeAuthoringConfig(config: unknown): {
export function normalizeAuthoringConfig(
config: unknown,
registry: CheckerRegistry = checkerRegistry,
): {
config: unknown;
issues: ConfigValidationIssue[];
} {
@@ -23,165 +23,20 @@ export function normalizeAuthoringConfig(config: unknown): {
const normalized = { ...(variableResult.config as Record<string, unknown>) };
delete normalized["variables"];
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 };
}
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]!]);
}
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 {
function normalizeTarget(target: unknown, registry: CheckerRegistry): unknown {
if (!isPlainObject(target)) return target;
const result = { ...(target as RawTargetConfig) };
if (result.expect !== undefined) {
result.expect = normalizeExpect(result.type, result.expect);
}
return 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;
const type = result.type;
if (typeof type !== "string") return result;
const checker = registry?.tryGet(type);
if (!checker) return result;
return checker.normalize(result);
}
export type { AuthoringProbeConfig, NormalizedProbeConfig };

View File

@@ -10,6 +10,7 @@ import { errorFailure } from "../../expect/failure";
import { checkValueExpectation } from "../../expect/value";
import { parseSize } from "../../utils";
import { checkExitCode } from "./expect";
import { normalizeTargetExpect } from "./normalize";
import { commandCheckerSchemas } from "./schema";
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 {
const t = target as RawTargetConfig & { cmd: CommandTargetConfig; type: "cmd" };

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

View File

@@ -9,6 +9,7 @@ import { checkContentExpectations } from "../../expect/content";
import { errorFailure } from "../../expect/failure";
import { checkValueExpectation } from "../../expect/value";
import { checkRowCount, checkRows } from "./expect";
import { normalizeTargetExpect } from "./normalize";
import { dbCheckerSchemas } from "./schema";
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 {
const t = target as RawTargetConfig & { db: DbTargetConfig; type: "db" };

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

View File

@@ -25,6 +25,7 @@ import {
checkTtlMin,
checkValueCount,
} from "./expect";
import { normalizeTargetExpect } from "./normalize";
import { dnsCheckerSchemas } from "./schema";
import { queryDns } from "./transport";
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 {
const dns = target["dns"] as DnsServerConfig | DnsSystemConfig;

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

View File

@@ -10,6 +10,7 @@ import { checkHeaderExpectations } from "../../expect/headers";
import { checkStatusCode } from "../../expect/status";
import { checkValueExpectation, displayValueExpectation } from "../../expect/value";
import { parseSize } from "../../utils";
import { normalizeTargetExpect } from "./normalize";
import { httpCheckerSchemas } from "./schema";
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 {
const t = target as RawTargetConfig & { http: HttpTargetConfig; type: "http" };

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

View File

@@ -8,6 +8,7 @@ import { errorFailure } from "../../expect/failure";
import { checkValueExpectation } from "../../expect/value";
import { buildPingCommand } from "./command";
import { checkAlive, checkAvgLatency, checkMaxLatency, checkPacketLoss } from "./expect";
import { normalizeTargetExpect } from "./normalize";
import { parsePingOutput } from "./parse";
import { icmpCheckerSchemas } from "./schema";
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 {
const t = target as RawTargetConfig & { icmp: PingTargetConfig; type: "icmp" };

View File

@@ -0,0 +1,20 @@
import { isPlainObject } from "es-toolkit";
import type { RawTargetConfig } from "../../types";
import { compactExpect, normalizeValue } from "../../expect/normalize";
export function normalizeTargetExpect(target: RawTargetConfig): RawTargetConfig {
if (target.expect === undefined || !isPlainObject(target.expect)) return target;
const raw = target.expect as Record<string, unknown>;
return {
...target,
expect: compactExpect(raw, {
alive: raw["alive"],
avgLatencyMs: normalizeValue(raw["avgLatencyMs"]),
durationMs: normalizeValue(raw["durationMs"]),
maxLatencyMs: normalizeValue(raw["maxLatencyMs"]),
packetLossPercent: normalizeValue(raw["packetLossPercent"]),
}),
};
}

View File

@@ -7,6 +7,7 @@ import { LlmChecker } from "./llm";
import { CheckerRegistry } from "./registry";
import { TcpChecker } from "./tcp";
import { UdpChecker } from "./udp";
import { WsChecker } from "./ws";
const checkers = [
new HttpChecker(),
@@ -17,6 +18,7 @@ const checkers = [
new UdpChecker(),
new LlmChecker(),
new DnsChecker(),
new WsChecker(),
];
export function createDefaultCheckerRegistry(): CheckerRegistry {

View File

@@ -8,6 +8,7 @@ import type { LlmTargetConfig, ResolvedLlmExpectConfig, ResolvedLlmTarget } from
import { errorFailure } from "../../expect/failure";
import { checkValueExpectation } from "../../expect/value";
import { runExpects } from "./expect";
import { normalizeTargetExpect } from "./normalize";
import {
buildObservationFromApiCallError,
buildObservationFromGenerateText,
@@ -127,6 +128,10 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
}
}
normalize(target: RawTargetConfig): RawTargetConfig {
return normalizeTargetExpect(target);
}
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedLlmTarget {
const t = target as RawTargetConfig & { llm: LlmTargetConfig; type: "llm" };

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

View File

@@ -8,6 +8,7 @@ import { errorFailure } from "../../expect/failure";
import { checkValueExpectation } from "../../expect/value";
import { parseSize } from "../../utils";
import { checkBanner, checkConnected } from "./expect";
import { normalizeTargetExpect } from "./normalize";
import { tcpCheckerSchemas } from "./schema";
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 {
const t = target as RawTargetConfig & { tcp: TcpTargetConfig; type: "tcp" };

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

View File

@@ -13,6 +13,7 @@ export interface CheckerDefinition<TResolved extends ResolvedTargetBase = Resolv
buildDetail(observation: Record<string, unknown>): null | string;
readonly configKey: string;
execute(target: TResolved, ctx: CheckerContext): Promise<CheckResult>;
normalize(target: RawTargetConfig): RawTargetConfig;
resolve(target: RawTargetConfig, context: ResolveContext): TResolved;
readonly schemas: CheckerSchemas;
serialize(target: TResolved): { config: string; target: string };

View File

@@ -9,6 +9,7 @@ import { checkValueExpectation } from "../../expect/value";
import { parseSize } from "../../utils";
import { decodePayload, encodeResponse } from "./encoding";
import { checkResponded, checkResponseSize, checkResponseText, checkSourceHost, checkSourcePort } from "./expect";
import { normalizeTargetExpect } from "./normalize";
import { udpCheckerSchemas } from "./schema";
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 {
const t = target as RawTargetConfig & { type: "udp"; udp: UdpTargetConfig };

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

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

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

View File

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

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

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

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

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

View File

@@ -1,6 +1,7 @@
import Ajv from "ajv";
import { describe, expect, test } from "bun:test";
import { normalizeAuthoringConfig } from "../../../../src/server/checker/normalizer";
import { createDefaultCheckerRegistry } from "../../../../src/server/checker/runner";
import { createProbeConfigJsonSchema } from "../../../../src/server/checker/schema/export";
import { formatConfigIssues, issue } from "../../../../src/server/checker/schema/issues";
@@ -274,4 +275,115 @@ describe("config contract", () => {
}),
).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);
}
});
});

View File

@@ -886,6 +886,144 @@ targets:
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 () => {
const configPath = join(tempDir, "expect.yaml");
await writeFile(

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

View File

@@ -2,7 +2,7 @@ import { Type } from "@sinclair/typebox";
import { describe, expect, test } from "bun:test";
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 { CheckerRegistry } from "../../../../src/server/checker/runner/registry";
@@ -12,6 +12,7 @@ function createChecker(type: string): Checker {
buildDetail: () => null,
configKey: type,
execute: () => Promise.resolve<CheckResult>({} as unknown as CheckResult),
normalize: (t: RawTargetConfig) => t,
resolve: () => ({}) as unknown as ResolvedTargetBase,
schemas: {
authoring: {
@@ -72,8 +73,8 @@ describe("CheckerRegistry", () => {
const second = createDefaultCheckerRegistry();
first.register(createChecker("custom"));
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "dns", "custom"]);
expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "dns"]);
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "dns", "ws", "custom"]);
expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "dns", "ws"]);
expect(
first.definitions.every((checker) => checker.schemas.authoring.config && checker.schemas.normalized.expect),
).toBe(true);

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

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

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

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

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