1
0

feat: 重构配置校验为 TypeBox + Ajv + semantic validator,严格禁止未知字段

- 新增 config-contract 模块(TypeBox fragments、Ajv 契约校验、ConfigValidationIssue)
- CheckerDefinition 扩展为含 configKey、schemas、validate 的完整插件接口
- HTTP/Command 各自维护 contract.ts + validate.ts,校验从 resolve 中分离
- resolve 不再承担校验,只做默认值合并和路径/单位解析
- config-loader 流程: unknown → RawProbeConfig → ValidatedProbeConfig → ResolvedConfig
- 导出 probe-config.schema.json,新增 schema/schema:check 脚本
- 更新 DEVELOPMENT.md 新增 1.7 开发新 Checker 完整指引
- 同步更新 4 个 main specs(probe-config、command-checker、expect-body-checkers、checker-runner-abstraction)
This commit is contained in:
2026-05-13 12:19:36 +08:00
parent bce0f8e7a8
commit 7b20b59b79
38 changed files with 3034 additions and 675 deletions

1
.gitignore vendored
View File

@@ -408,6 +408,7 @@ temp
.agents
skills-lock.json
.worktrees
data/
!scripts/build/
backend/bin
backend/server

View File

@@ -10,3 +10,4 @@ bun.lock
.agents/
skills-lock.json
data/
probe-config.schema.json

View File

@@ -35,27 +35,32 @@ src/
trend.ts GET /api/targets/:id/trend
checker/
types.ts 类型定义
config-loader.ts YAML 配置解析与校验
config-loader.ts YAML 配置解析、契约校验、语义校验与运行期解析
config-contract/ TypeBox + Ajv 配置契约、schema fragments、issue 渲染和 schema 导出入口
store.ts SQLite 数据存储
engine.ts 调度引擎(按 interval 分组的 es-toolkit groupBy + Semaphore 并发控制)
size.ts 大小单位解析
runner/ Checker 统一抽象与注册机制
types.ts Checker 接口、CheckerContext、ResolveContext
types.ts CheckerDefinition、CheckerContext、ResolveContext
registry.ts CheckerRegistry 注册中心
index.ts 注册入口registerCheckers
shared/ 共享 expect 断言函数(跨 checker 复用)
shared/ 共享 expect 断言和启动期 validator(跨 checker 复用)
failure.ts 失败信息类型
operator.ts 操作符系统applyOperator、evaluateJsonPath
duration.ts 耗时断言
text.ts 文本规则断言
body.ts Body 规则断言JSONPath/XPath/CSS/contains/regex
validate.ts 共享 operator/text/body 语义校验
http/ HTTP Checker 子包
contract.ts HTTP defaults、target.http、expect TypeBox 契约
runner.ts HttpCheckerresolve/execute/serialize
expect.ts HTTP 专用断言status/headers
validate.ts HTTP 配置与 expect 启动期校验
validate.ts HTTP 专属启动期语义校验
command/ Command Checker 子包
contract.ts Command defaults、target.command、expect TypeBox 契约
runner.ts CommandCheckerresolve/execute/serialize
expect.ts Command 专用断言exitCode
validate.ts Command 专属启动期语义校验
shared/
api.ts 前后端共享 TypeScript 类型
web/ Vite + React 前端 Dashboard
@@ -63,9 +68,10 @@ src/
constants/ 常量定义(列配置、类型映射、排序/筛选/颜色阈值函数)
hooks/ TanStack Query 数据层useTargetDetail 集成轮询/条件查询)
utils/ 前端工具函数
scripts/ 开发、构建和 smoke test 脚本
scripts/ 开发、构建、schema 生成和 smoke test 脚本
tests/ Bun test 测试
openspec/ OpenSpec 变更与规格文档
probe-config.schema.json 用户配置 JSON Schema 导出物
```
## 前后端边界
@@ -143,9 +149,417 @@ export function handleXxx(params, store: ProbeStore, method: string, mode: Runti
- **严格联合类型**优先于宽类型:如 `phase: "status" | "duration" | ...` 而非 `phase: string`
- **后端内部扩展**`checker/types.ts``CheckResult` 通过 `extends` 共享版本的 `ApiCheckResult` 增加 `targetName` 等内部字段
- 存储层类型(`StoredTarget``StoredCheckResult`)独立定义,与 API 类型分离
- 配置类型`ProbeConfig``TargetConfig`)支持 discriminated union通过 `type` 字段区分 http/command
- 配置类型按生命周期区分YAML 解析后的 `RawProbeConfig`、已通过契约与语义校验的 `ValidatedProbeConfig`、运行期使用的 `ResolvedConfig`/`ResolvedTarget`
### 1.6 数据存储规范
### 1.6 配置契约与校验
配置加载流程固定为:`unknown -> RawProbeConfig -> ValidatedProbeConfig -> ResolvedConfig`
`config-loader.ts` 只负责 YAML 解析、契约调度、公共语义校验和最终运行期解析checker 专属规则必须下沉到对应 checker 的 `contract.ts``validate.ts`
契约层使用 `src/server/checker/config-contract/` 中的 TypeBox fragments 生成 JSON Schema并用 Ajv 执行启动期校验。Ajv 必须保持严格拒绝模式:`allErrors: true`、不启用类型强制转换、不注入默认值、不自动删除未知字段。
默认对象策略是 `additionalProperties: false`。只有明确声明的动态键值表可以开放任意键名,例如 `http.headers``defaults.http.headers``expect.headers``command.env`
契约校验和语义 validator 都必须返回 `ConfigValidationIssue[]`,不要在 validator 内直接拼接最终用户错误字符串。最终错误由 `formatConfigIssues()` 统一渲染,错误路径需要尽量包含 `targetName``defaults`/root 路径。
新增或修改配置字段时必须同步更新TypeBox schema fragments、`probe-config.schema.json` 导出、对应语义 validator、单元测试和 README/DEVELOPMENT 用户文档。提交前运行 `bun run schema:check` 确认导出 schema 与 fragments 一致。
### 1.7 开发新 Checker
Checker 是本项目的核心扩展单元。得益于插件式注册架构,完成一个新 checker 后,**配置校验、引擎调度、数据存储、API 层会自动适配**,无需修改这些中间层代码。
以下以新增 `tcp` 类型 checker 为例,说明完整的开发步骤。
#### 1.7.1 架构总览
```
checkerRegistry单例
├── registerCheckers() ← 注册入口,所有 checker 在此集中注册
│ ├── HttpChecker
│ ├── CommandChecker
│ └── TcpChecker ← 新增
├── config-contract/schema.ts ← 自动遍历 registry 生成全量 JSON Schema
├── config-loader.ts ← 自动遍历 registry 调用 validate() + resolve()
├── engine.ts ← 自动按 target.type 分发到 execute()
└── store.ts ← 自动按 target.type 分发到 serialize()
```
每个 checker 是 `src/server/checker/runner/<type>/` 下的自包含模块,包含四个文件:
| 文件 | 职责 |
| ------------- | ------------------------------------------------------------------------------------- |
| `contract.ts` | TypeBox 契约 schemaconfig / defaults / expect 三部分) |
| `validate.ts` | 启动期语义校验JSON Schema 无法表达的规则) |
| `runner.ts` | Checker 类resolve默认值合并 + 解析、execute执行检查、serializeDB 持久化) |
| `expect.ts` | Checker 专用断言函数 |
#### 1.7.2 步骤一:定义类型
`src/server/checker/types.ts` 中添加 checker 专属类型接口,并更新联合类型:
```typescript
// 1. 添加 TargetConfigYAML 中 target.tcp 字段的原始类型)
export interface TcpTargetConfig {
host: string;
port: number;
timeout?: number;
}
// 2. 添加 ExpectConfig 扩展(如果 checker 有专属 expect 字段)
export interface TcpExpectConfig {
connected?: boolean;
}
// 3. 添加 DefaultsConfigdefaults.tcp 字段)
export interface TcpDefaultsConfig {
timeout?: number;
}
// 4. 添加 Resolved 变体(运行期已合并默认值、已解析路径)
export interface ResolvedTcpTarget {
type: "tcp";
name: string;
group: string;
intervalMs: number;
timeoutMs: number;
tcp: {
host: string;
port: number;
connectTimeout: number;
};
expect?: TcpExpectConfig;
}
```
然后更新以下联合类型:
```typescript
// TargetConfig 联合 — 新增一个分支
export type TargetConfig = BaseTargetConfig &
(
| { http: HttpTargetConfig; type: "http" }
| { command: CommandTargetConfig; type: "command" }
| { tcp: TcpTargetConfig; type: "tcp" } // ← 新增
);
// ResolvedTarget 联合
export type ResolvedTarget = ResolvedHttpTarget | ResolvedCommandTarget | ResolvedTcpTarget; // ← 新增
// DefaultsConfig — 新增可选字段
export interface DefaultsConfig {
interval?: string;
timeout?: string;
http?: HttpDefaultsConfig;
command?: CommandDefaultsConfig;
tcp?: TcpDefaultsConfig; // ← 新增
}
// TargetType 联合
export type TargetType = "command" | "http" | "tcp"; // ← 新增
// ExpectConfig — 如有专属字段则扩展
export interface ExpectConfig {
// ... 现有字段
connected?: boolean; // ← TcpChecker 专属(如果复用公共字段则不需要)
}
```
#### 1.7.3 步骤二:创建 TypeBox 契约 Schema
`src/server/checker/runner/tcp/contract.ts` 中定义三部分 schema
```typescript
import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import { sizeSchema } from "../../config-contract/fragments"; // 复用共享 fragments
export const tcpCheckerSchemas: CheckerSchemas = {
// target.tcp 字段的 schema
config: Type.Object(
{
host: Type.String(),
port: Type.Integer({ maximum: 65535, minimum: 0 }),
connectTimeout: Type.Optional(Type.Integer({ minimum: 100 })),
},
{ additionalProperties: false },
),
// defaults.tcp 字段的 schema
defaults: Type.Object(
{
connectTimeout: Type.Optional(Type.Integer({ minimum: 100 })),
},
{ additionalProperties: false },
),
// target.expect 中 tcp 专属字段的 schema如果无专属字段则用 Type.Object({})
expect: Type.Object(
{
connected: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
),
};
```
**可复用的共享 fragments**(来自 `config-contract/fragments.ts`
| Fragment | 用途 |
| ---------------------------- | ---------------------------------------------- |
| `durationSchema` | 时长字符串(`"30s"``"5m"``"500ms"` |
| `httpMethodSchema` | HTTP 方法枚举 |
| `sizeSchema` | 大小单位(字符串如 `"10MB"` 或整数) |
| `statusCodePatternSchema` | 状态码(`100`-`599``"2xx"` |
| `stringMapSchema` | `Record<string, string>`(用于 headers / env |
| `createBodyRulesSchema()` | body 规则数组json/css/xpath/contains/regex |
| `createTextRulesSchema()` | 文本规则数组stdout/stderr |
| `createPureOperatorSchema()` | 操作符对象 |
| `operatorProperties()` | 所有操作符字段的 Record |
**注意**:默认对象策略为 `additionalProperties: false`。只有明确的动态键值表(如 `http.headers``command.env`)可以开放任意键名。
#### 1.7.4 步骤三:实现语义校验
`src/server/checker/runner/tcp/validate.ts` 中实现 JSON Schema 无法表达的语义规则:
```typescript
import type { ConfigValidationIssue } from "../../config-contract/issues";
import type { CheckerValidationInput } from "../types";
import { issue } from "../../config-contract/issues";
export function validateTcpConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
// 1. 校验 defaults.tcp如需要
const defaults = input.defaults.tcp;
if (defaults) {
// 语义校验示例connectTimeout 不能超过某个上限
}
// 2. 遍历所有 tcp 类型的 target
for (const target of input.targets) {
if (target.type !== "tcp") continue;
const name = target.name;
// 校验 target.tcp 中的语义规则
const tcp = (target as any).tcp;
if (tcp) {
// 示例host 不能为空字符串
if (typeof tcp.host === "string" && tcp.host.trim() === "") {
issues.push(issue("invalid-value", "tcp.host", "host 不能为空字符串", name));
}
}
// 校验 expect如有公共部分可使用 shared/validate.ts 的工具函数)
// validateBodyRules、validateTextRules、validateOperatorObject 等
}
return issues;
}
```
**共享校验工具**`runner/shared/validate.ts`
| 函数 | 用途 |
| --------------------------------------------------------- | --------------------------------- |
| `validateBodyRules(body, path, targetName)` | 校验 body 规则数组 |
| `validateTextRules(rules, path, targetName)` | 校验文本规则数组stdout/stderr |
| `validateOperatorObject(ops, path, targetName, options?)` | 校验操作符对象 |
| `validateJsonPath(path, rulePath, targetName)` | 校验 JSONPath 格式 |
#### 1.7.5 步骤四:实现 Checker 类
`src/server/checker/runner/tcp/runner.ts` 中实现 `CheckerDefinition` 接口的全部成员:
```typescript
import type { CheckResult, ResolvedTarget, TargetConfig } from "../../types";
import type { Checker, CheckerContext, CheckerValidationInput, ResolveContext } from "../types";
import { tcpCheckerSchemas } from "./contract";
import { validateTcpConfig } from "./validate";
export class TcpChecker implements Checker {
readonly configKey = "tcp"; // YAML 中 target.tcp / defaults.tcp 的键名
readonly type = "tcp"; // target.type 的判别值
readonly schemas = tcpCheckerSchemas;
// 启动期语义校验入口
validate(input: CheckerValidationInput): ConfigValidationIssue[] {
return validateTcpConfig(input);
}
// 将原始配置解析为运行期配置(合并默认值、解析路径和单位)
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget {
const t = target as TargetConfig & { tcp: TcpTargetConfig; type: "tcp" };
const defaults = context.defaults.tcp;
return {
expect: target.expect,
group: target.group ?? "default",
intervalMs: context.defaultIntervalMs,
name: t.name,
tcp: {
connectTimeout: t.tcp.connectTimeout ?? defaults?.connectTimeout ?? 3000,
host: t.tcp.host,
port: t.tcp.port,
},
timeoutMs: context.defaultTimeoutMs,
type: "tcp",
} satisfies ResolvedTcpTarget;
}
// 执行实际检查,评估 expect返回 CheckResult
async execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult> {
const t = target as ResolvedTcpTarget;
const timestamp = new Date().toISOString();
const start = performance.now();
try {
// 执行检查逻辑(如 TCP 连接)
// ...
// 评估 expect 规则
// 首个失败即停止,返回 failure
const durationMs = Math.round(performance.now() - start);
return {
durationMs,
failure: null,
matched: true,
statusDetail: "TCP connected",
targetName: t.name,
timestamp,
};
} catch (error) {
const durationMs = Math.round(performance.now() - start);
return {
durationMs,
failure: errorFailure("connection", "connection", isError(error) ? error.message : String(error)),
matched: false,
statusDetail: null,
targetName: t.name,
timestamp,
};
}
}
// 序列化为 DB 存储格式
serialize(target: ResolvedTarget): { config: string; target: string } {
const t = target as ResolvedTcpTarget;
return {
config: JSON.stringify({ host: t.tcp.host, port: t.tcp.port, connectTimeout: t.tcp.connectTimeout }),
target: `${t.tcp.host}:${t.tcp.port}`,
};
}
}
```
**`resolve()` 规范**
- 只做默认值合并、路径解析、单位转换,**不执行校验**
- 返回 `satisfies ResolvedXxxTarget` 确保类型正确
- 通过 `context.defaults[this.configKey]` 访问 checker 专属默认值
**`execute()` 规范**
- 始终记录 `timestamp`ISO 字符串)和 `start = performance.now()`
- 通过 `ctx.signal``AbortSignal`)支持超时取消
- 首个 expect 失败即停止,返回带 `failure` 的结果
- 成功时 `failure: null, matched: true`
- 异常时使用 `errorFailure(phase, path, message)` 构造 failure
- 不匹配时使用 `mismatchFailure(phase, path, expected, actual, message)` 构造 failure
**可用的共享断言工具**`runner/shared/`
| 模块 | 函数 | 用途 |
| ------------- | ----------------------------------------------------- | ---------------------- |
| `failure.ts` | `errorFailure(phase, path, msg)` | 构造错误类型 failure |
| `failure.ts` | `mismatchFailure(phase, path, expected, actual, msg)` | 构造不匹配类型 failure |
| `duration.ts` | `checkDuration(ms, maxMs?)` | 耗时断言 |
| `body.ts` | `checkBodyExpect(body, rules)` | Body 规则断言 |
| `text.ts` | `checkTextRules(text, rules, phase)` | 文本规则断言 |
| `operator.ts` | `applyOperator(actual, operator)` | 执行单个操作符比较 |
| `operator.ts` | `evaluateJsonPath(json, path)` | JSONPath 提取 |
#### 1.7.6 步骤五:注册 Checker
`src/server/checker/runner/index.ts` 中注册:
```typescript
import { TcpChecker } from "./tcp/runner"; // ← 新增导入
export function registerCheckers(registry = checkerRegistry): void {
registry.register(new HttpChecker());
registry.register(new CommandChecker());
registry.register(new TcpChecker()); // ← 新增注册
}
```
注册后,以下管线会自动适配,**无需修改**
| 模块 | 自动行为 |
| ----------------------------- | ------------------------------------------------------------------------ |
| `config-contract/schema.ts` | 遍历 registry 生成全量 JSON Schemadefaults.tcp + target.tcp + expect |
| `config-contract/validate.ts` | 按注册 checker 构建 Ajv 校验,自动识别 `type: tcp` |
| `config-loader.ts` | 遍历 registry 调用每个 checker 的 `validate()` + `resolve()` |
| `engine.ts` | 按 `target.type` 从 registry 取对应 checker 执行 `execute()` |
| `store.ts` | 按 `target.type` 从 registry 取对应 checker 执行 `serialize()` |
#### 1.7.7 步骤六:更新前端展示
| 文件 | 修改内容 |
| ------------------------------------------- | ------------------------------------------------------------ |
| `src/web/constants/target-type-display.ts` | 在 `TARGET_TYPE_DISPLAY` 中添加 `"tcp": "TCP"` |
| `src/web/constants/target-table-filters.ts` | 在 `typeFilter.list` 中添加 `{ label: "TCP", value: "tcp" }` |
#### 1.7.8 步骤七:编写测试
测试文件放在 `tests/server/checker/runner/tcp/` 下,镜像源文件结构。必须覆盖:
| 测试类别 | 覆盖内容 | 参考 |
| ---------------- | ------------------------------------------ | ---------------------------------------------------------- |
| **契约测试** | TypeBox schema 与 JSON Schema 导出一致性 | `config-contract/validate.test.ts` |
| **语义校验测试** | `validateTcpConfig()` 各种合法/非法输入 | `http/validate.test.ts`(通过 `runner.test.ts` 间接测试) |
| **resolve 测试** | 默认值合并、路径解析、单位转换 | `http/runner.test.ts``HttpChecker.resolve` describe 块 |
| **execute 测试** | 成功/失败/超时/expect 各种规则组合 | `http/runner.test.ts` 的集成测试 |
| **注册测试** | fresh registry 不污染全局、多 checker 注册 | `registry.test.ts` |
| **配置加载测试** | 含新 checker 的 YAML 完整加载流程 | `config-loader.test.ts` |
#### 1.7.9 步骤八:更新文档和 Schema
| 操作 | 命令/文件 |
| --------------------------------- | -------------------------------------------- |
| 重新生成 JSON Schema 导出 | `bun run schema` |
| 检查导出 schema 与 fragments 一致 | `bun run schema:check` |
| 更新配置示例 | `probes.example.yaml` 中添加新类型示例 |
| 更新用户文档 | `README.md` 中的配置格式说明 |
| 更新项目结构 | `DEVELOPMENT.md` 项目结构中的 runner/ 目录树 |
#### 1.7.10 完整检查清单
```
□ src/server/checker/types.ts — 新增类型接口 + 更新联合类型
□ src/server/checker/runner/tcp/contract.ts — TypeBox schemas
□ src/server/checker/runner/tcp/validate.ts — 语义校验
□ src/server/checker/runner/tcp/runner.ts — Checker 类
□ src/server/checker/runner/tcp/expect.ts — 专用断言(如需要)
□ src/server/checker/runner/index.ts — 注册
□ src/web/constants/target-type-display.ts — 前端类型标签
□ src/web/constants/target-table-filters.ts — 前端类型筛选
□ tests/ — 契约 + 校验 + resolve + execute + 注册 测试
□ probes.example.yaml — 配置示例
□ bun run schema + bun run schema:check — Schema 导出同步
□ bun run check — 全量质量检查通过
□ bun run verify — 完整验证(含 build + smoke test
□ README.md — 用户文档
□ DEVELOPMENT.md — 项目结构目录树
```
### 1.8 数据存储规范
基于 `bun:sqlite`WAL 模式运行,数据库文件位于配置的 `dataDir` 下。
@@ -168,7 +582,7 @@ export function handleXxx(params, store: ProbeStore, method: string, mode: Runti
- `check_results`target_idFK CASCADE、timestamp、matched0/1、duration_ms、status_detail、failureJSON
- 复合索引:`(target_id, timestamp)`
### 1.7 拨测引擎
### 1.9 拨测引擎
- **调度**`ProbeEngine``es-toolkit/groupBy` 按 interval 分组,每组独立 `setInterval` 定时触发
- **并发控制**`es-toolkit/Semaphore` 限制全局最大并发数(`maxConcurrentChecks``acquire()` 阻塞等待
@@ -177,7 +591,7 @@ export function handleXxx(params, store: ProbeStore, method: string, mode: Runti
- **结果写入**:检查结果通过 `store.insertCheckResult()` 写入 SQLiteengine 通过 `targetNameToId` 缓存 name→id 映射
- **生命周期**`start()`/`stop()` 管理定时器,`stop()` 清理所有 `setInterval`
### 1.8 expect 断言系统
### 1.10 expect 断言系统
两层模型:**观测值收集** → **规则校验**
@@ -209,14 +623,14 @@ runCommandCheck → 收集观测(exitCode/stdout/stderr/durationMs)
**操作符**`equals`(深度比较,`es-toolkit/isEqual`)、`contains``match`(正则)、`empty``isNil`+`isEmptyObject`)、`exists``gte`/`lte`/`gt`/`lt`
### 1.9 错误模式
### 1.11 错误模式
- **API 错误**`{ error: "描述", status: <code> }`,状态码 400/404/405/503
- **CheckFailure**`{ kind: "error"|"mismatch", phase, path, expected?, actual?, message }`
- **错误处理**expect 校验失败记录首个失败原因;网络/超时/进程崩溃统一为 `kind:"error"`,请求/TLS/timeout 错误归属 `phase:"request"`body 超限/解码/解析错误归属 `phase:"body"`
- **日志**:解析失败等非致命异常用 `console.warn`,启动失败用 `console.error` + `process.exit(1)`
### 1.10 测试规范
### 1.12 测试规范
- 测试文件与源文件对应:`tests/server/checker/runner/shared/body.test.ts``src/server/checker/runner/shared/body.ts`
- 使用 `bun:test` 框架(`describe`/`test`/`expect`),测试数据库用临时目录 + `tmpdir()`
@@ -591,12 +1005,14 @@ bun run test:smoke
### 3.6 脚本说明
| 脚本 | 文件 | 说明 |
| -------------------- | ------------------ | ------------------------------ |
| `bun run dev` | `scripts/dev.ts` | 同时启动前后端开发服务 |
| `bun run build` | `scripts/build.ts` | Vite 构建 + Bun 编译可执行文件 |
| `bun run test:smoke` | `scripts/smoke.ts` | 构建后的端到端验证 |
| `bun run clean` | `scripts/clean.ts` | 清理构建缓存与临时文件 |
| 脚本 | 文件 | 说明 |
| ---------------------- | ----------------------------------- | ------------------------------- |
| `bun run dev` | `scripts/dev.ts` | 同时启动前后端开发服务 |
| `bun run build` | `scripts/build.ts` | Vite 构建 + Bun 编译可执行文件 |
| `bun run schema` | `scripts/generate-config-schema.ts` | 生成 `probe-config.schema.json` |
| `bun run schema:check` | `scripts/generate-config-schema.ts` | 检查配置 schema 导出物是否同步 |
| `bun run test:smoke` | `scripts/smoke.ts` | 构建后的端到端验证 |
| `bun run clean` | `scripts/clean.ts` | 清理构建缓存与临时文件 |
### 3.7 环境变量
@@ -648,9 +1064,10 @@ bun run test:smoke
```bash
bun run lint # ESLint 检查含类型感知规则、导入排序、导入验证、Prettier 格式)
bun run format # Prettier 自动格式化
bun run schema:check # 检查 probe-config.schema.json 是否与 TypeBox fragments 同步
bun run typecheck # TypeScript 类型检查(含 noUnusedLocals、noPropertyAccessFromIndexSignature
bun test # 运行所有测试
bun run check # 一键运行 typecheck + lint + test
bun run check # 一键运行 schema:check + typecheck + lint + test
```
`check` 是日常开发推荐的质量检查命令。

View File

@@ -24,6 +24,8 @@ bun run dev:web
程序通过 YAML 配置文件定义所有运行参数:
```yaml
# yaml-language-server: $schema=./probe-config.schema.json
server:
host: "127.0.0.1"
port: 3000
@@ -100,7 +102,7 @@ targets:
- `interval`: 拨测间隔,默认 `30s`
- `timeout`: 超时时间,默认 `10s`
- `http`: HTTP 类型默认值
- `method`: HTTP 方法,默认 `GET`,支持 `GET``HEAD``POST``PUT``PATCH``DELETE``OPTIONS`
- `method`: HTTP 方法,默认 `GET`必须使用大写枚举值,支持 `GET``HEAD``POST``PUT``PATCH``DELETE``OPTIONS`
- `maxBodyBytes`: 响应体最大字节数,默认 `100MB`
- `command`: Command 类型默认值
- `maxOutputBytes`: 输出最大字节数,默认 `100MB`
@@ -135,7 +137,11 @@ targets:
大小说明:`maxBodyBytes``maxOutputBytes` 支持单位 `KB``MB``GB`,也可直接使用数字(非负安全整数字节数)。
配置校验:系统启动时严格校验所有已支持字段类型和格式,非法配置会阻止启动并输出清晰的错误信息。未知字段会被忽略,不影响启动和运行。
配置校验:系统启动时会先用 TypeBox 生成的 JSON Schema 契约校验字段类型、必填字段、枚举、数组/对象形状和未知字段,再执行语义 validator 校验 target name 唯一性、URL、正则、JSONPath、XPath、size/duration 解析等规则。非法配置会阻止启动并输出中文错误信息。
未知字段:除 `http.headers``defaults.http.headers``expect.headers``command.env` 等动态键值表外,未知字段会导致启动失败。配置备注请使用 YAML 注释,不要添加 `note``comment` 等未声明字段。
JSON Schema仓库根目录导出 `probe-config.schema.json`,可在 YAML 文件顶部添加 `# yaml-language-server: $schema=./probe-config.schema.json` 获取编辑器提示和静态校验。该 schema 由运行期契约 fragments 生成,提交前可用 `bun run schema:check` 检查同步。
时长格式支持:`30s``5m``500ms`

View File

@@ -5,8 +5,10 @@
"": {
"name": "gateway-checker",
"dependencies": {
"@sinclair/typebox": "^0.34.49",
"@tanstack/react-query": "^5.100.10",
"@xmldom/xmldom": "^0.9.10",
"ajv": "^8.20.0",
"cheerio": "^1.2.0",
"es-toolkit": "^1.46.1",
"react": "^19.2.6",
@@ -203,6 +205,8 @@
"@simple-libs/stream-utils": ["@simple-libs/stream-utils@1.2.0", "https://registry.npmmirror.com/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", {}, "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA=="],
"@sinclair/typebox": ["@sinclair/typebox@0.34.49", "https://registry.npmmirror.com/@sinclair/typebox/-/typebox-0.34.49.tgz", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "https://registry.npmmirror.com/@standard-schema/utils/-/utils-0.3.0.tgz", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
@@ -323,7 +327,7 @@
"acorn-jsx": ["acorn-jsx@5.3.2", "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["ajv@6.15.0", "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
"ajv": ["ajv@8.20.0", "https://registry.npmmirror.com/ajv/-/ajv-8.20.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"ansi-escapes": ["ansi-escapes@7.3.0", "https://registry.npmmirror.com/ansi-escapes/-/ansi-escapes-7.3.0.tgz", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
@@ -709,7 +713,7 @@
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "https://registry.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
@@ -1039,8 +1043,6 @@
"@babel/core/json5": ["json5@2.2.3", "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"@commitlint/config-validator/ajv": ["ajv@8.20.0", "https://registry.npmmirror.com/ajv/-/ajv-8.20.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"@commitlint/is-ignored/semver": ["semver@7.8.0", "https://registry.npmmirror.com/semver/-/semver-7.8.0.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
"@conventional-changelog/git-client/semver": ["semver@7.8.0", "https://registry.npmmirror.com/semver/-/semver-7.8.0.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
@@ -1067,6 +1069,8 @@
"cliui/wrap-ansi": ["wrap-ansi@9.0.2", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"eslint/ajv": ["ajv@6.15.0", "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
"eslint-import-resolver-node/debug": ["debug@3.2.7", "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"eslint-module-utils/debug": ["debug@3.2.7", "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
@@ -1099,10 +1103,10 @@
"wrap-ansi/string-width": ["string-width@8.2.1", "https://registry.npmmirror.com/string-width/-/string-width-8.2.1.tgz", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="],
"@commitlint/config-validator/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"eslint-plugin-import/minimatch/brace-expansion": ["brace-expansion@1.1.14", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.14.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
"eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"eslint-plugin-import/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
}
}

View File

@@ -4,17 +4,70 @@
## Requirements
### Requirement: Checker 配置契约片段
系统 SHALL 支持 checker 提供自身 TypeBox 配置契约片段,用于描述该 checker 的 defaults 分组、target 领域分组和 expect 分组。公共配置加载模块 SHALL 通过 registry 获取已注册 checker 的契约片段,并组合为启动期 Ajv 契约校验流程和外部 `probe-config.schema.json` 导出流程。
#### Scenario: HTTP checker 提供契约片段
- **WHEN** HTTP checker 被注册
- **THEN** registry SHALL 能提供 HTTP defaults、HTTP target 和 HTTP expect 的 TypeBox 契约片段
#### Scenario: Command checker 提供契约片段
- **WHEN** Command checker 被注册
- **THEN** registry SHALL 能提供 Command defaults、Command target 和 Command expect 的 TypeBox 契约片段
#### Scenario: 新 checker 只维护自身契约
- **WHEN** 开发者新增一个 checker 类型
- **THEN** 该 checker SHALL 提供自身 TypeBox 配置契约和语义 validator而不需要把 checker 专属字段写入中央手工校验逻辑
#### Scenario: 外部 schema 通过 registry 生成
- **WHEN** 系统生成 `probe-config.schema.json`
- **THEN** 生成流程 SHALL 从 registry 获取已注册 checker 的契约片段,并将其组合进完整配置 schema
#### Scenario: 契约组装不依赖全局 singleton
- **WHEN** 测试或 schema 生成流程需要组装配置契约
- **THEN** 系统 SHALL 支持传入 fresh CheckerRegistry 实例完成契约组装,避免重复注册或全局状态污染
### Requirement: Checker 启动期语义校验
系统 SHALL 支持 checker 提供启动期语义 validator用于校验 TypeBox/Ajv 契约不适合表达或需要 checker 业务知识判断的配置规则。语义 validator MUST 在 resolver 填充最终 ResolvedTarget 之前执行,并 MUST 返回 `ConfigValidationIssue[]`
#### Scenario: checker 语义校验先于 resolve
- **WHEN** config-loader 准备解析一个 target
- **THEN** 系统 SHALL 先完成该 target 的 checker 语义校验,再调用 checker.resolve()
#### Scenario: 语义校验失败阻止启动
- **WHEN** checker 语义 validator 发现非法配置
- **THEN** 系统 SHALL 以配置错误退出,不进入 checker 执行阶段
### Requirement: 结构化配置校验 issue
系统 SHALL 使用统一 `ConfigValidationIssue` 表示配置校验问题,至少包含 `code``path``message`,并支持可选 `targetName`。契约校验和 checker 语义校验都 SHALL 产出该结构,由配置加载模块统一渲染为中文错误。
#### Scenario: Ajv 错误转换为 issue
- **WHEN** Ajv 校验发现 required、type 或 additionalProperties 错误
- **THEN** 系统 SHALL 将该错误转换为 `ConfigValidationIssue`,保留配置路径和可读 message
#### Scenario: checker validator 返回 issue
- **WHEN** checker 语义 validator 发现非法 XPath 或正则表达式
- **THEN** checker SHALL 返回 `ConfigValidationIssue`,而不是直接抛出最终用户错误字符串
### Requirement: Checker 接口定义
系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义 `Checker` 接口,包含 `type``resolve``execute``serialize` 四个成员。`CheckerContext` SHALL 包含引擎注入的 `AbortSignal`
系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义面向扩展的 `CheckerDefinition`,包含 `type``configKey`、TypeBox 配置契约、启动期语义校验、`resolve``execute``serialize` 成员。`CheckerContext` SHALL 包含引擎注入的 `AbortSignal`
#### Scenario: Checker 接口包含必要方法
- **WHEN** 开发者实现一个新的 Checker
- **THEN** 该实现 MUST 提供 `type`(字符串标识)、`resolve(target, context)`(解析配置并校验)、`execute(target, ctx)`(执行探测返回 CheckResult`serialize(target)`(返回 target 展示文本和 config JSON
- **THEN** 该实现 MUST 提供 `type`(字符串标识)、`configKey`配置分组名、TypeBox 配置契约、启动期语义校验、`resolve(target, context)`(解析配置并填充默认值)、`execute(target, ctx)`(执行探测返回 CheckResult`serialize(target)`(返回 target 展示文本和 config JSON
#### Scenario: CheckerContext 注入 signal
- **WHEN** 引擎调用 `checker.execute(target, ctx)`
- **THEN** `ctx.signal` SHALL 是一个由引擎创建的 `AbortSignal`,在超时或引擎关闭时 abort
#### Scenario: resolve 不承担通用契约校验
- **WHEN** config-loader 调用 checker.resolve()
- **THEN** checker.resolve() SHALL 假定配置已经通过 TypeBox/Ajv 契约校验和启动期语义校验,只负责默认值填充、路径解析和领域配置转换
#### Scenario: type 与 configKey 默认一致
- **WHEN** checker 定义 `type: "tcp"`
- **THEN** checker 的 `configKey` SHALL 默认使用 `"tcp"`,对应 target 的 `tcp` 分组和 defaults.tcp 分组
### Requirement: CheckerRegistry 注册中心
系统 SHALL 在 `src/server/checker/runner/registry.ts` 中提供 `CheckerRegistry` 类,支持 `register(checker)``get(type)``supportedTypes`。重复注册同一 type SHALL 抛出错误。
@@ -46,27 +99,31 @@
- **THEN** engine SHALL 创建 `AbortController`,设置超时定时器,将 `controller.signal` 注入 `CheckerContext`,执行完成后清理定时器
### Requirement: 配置解析通过 registry 委托 checker
系统 SHALL 在 `config-loader.ts` `resolveTarget()` 中通过 `checkerRegistry.get(target.type).resolve(target, context)` 委托解析,替代原有的 `if/else` 分支`validateConfig()` SHALL 仅校验通用字段name 非空、name 不重复、group 类型,不包含 type 专属字段校验。
系统 SHALL 在 `config-loader.ts`配置加载流程中通过 `checkerRegistry` 发现已注册 checker组合公共 TypeBox 契约与 checker 契约,并将 checker 专属语义校验和解析委托给对应 checker`validateConfig()` SHALL 仅保留公共语义校验name 非空、name 不重复、group 类型、type 已注册等)和契约调度职责,不包含 checker 专属字段校验。
#### Scenario: 配置契约通过 registry 组合
- **WHEN** config-loader 校验配置文件
- **THEN** config-loader SHALL 从 `checkerRegistry` 获取已注册 checker 的契约片段,并用于校验 defaults 与 targets 中对应 checker 的配置形状
#### Scenario: 配置解析委托 checker
- **WHEN** config-loader 解析一个 type 为 "command" 的 target
- **THEN** config-loader SHALL 调用 `checkerRegistry.get("command").resolve()` 进行解析、校验和默认值填充
- **THEN** config-loader SHALL 调用 `checkerRegistry.get("command")` 获取对应 checker并委托该 checker 执行语义校验和 resolve
#### Scenario: 通用字段校验保留在 config-loader
- **WHEN** YAML 配置中某个 target 缺少 name 或 type 字段
- **THEN** config-loader 的 `validateConfig()` SHALL 仍负责校验这些通用字段
- **THEN** config-loader 的公共校验流程 SHALL 仍负责校验这些通用字段
#### Scenario: type 专属校验下沉到 checker
- **WHEN** YAML 配置中 HTTP target 缺少 `http.url`
- **THEN** HttpChecker 的 `resolve()` SHALL 抛出校验错误,提示缺少必填字段
- **THEN** HTTP checker 的契约或语义校验 SHALL 抛出校验错误,提示缺少必填字段
#### Scenario: HTTP method 非法校验
- **WHEN** YAML 配置中 HTTP target 的 `http.method`合法方法列表中
- **THEN** HttpChecker `resolve()` SHALL 抛出校验错误,提示 method 不合法
- **WHEN** YAML 配置中 HTTP target 的 `http.method`是大写合法方法枚举值
- **THEN** HTTP checker 契约或语义校验 SHALL 抛出校验错误,提示 method 不合法
#### Scenario: URL 格式校验
- **WHEN** YAML 配置中 HTTP target 的 `http.url` 不以 `http://``https://` 开头
- **THEN** HttpChecker 的 `resolve()` SHALL 抛出校验错误,提示 URL 格式不合法
- **THEN** HttpChecker 的语义校验 SHALL 抛出校验错误,提示 URL 格式不合法
### Requirement: 存储序列化通过 registry 获取展示格式
系统 SHALL 在 `ProbeStore.syncTargets()` 中通过 `checkerRegistry.get(t.type).serialize(t)` 获取每个 target 的展示摘要(`target` 列)和配置 JSON`config` 列),替代 `buildTargetDisplay()` / `buildTargetConfig()` 中的类型分支。

View File

@@ -80,3 +80,62 @@
#### Scenario: stdout 失败后不检查 stderr
- **WHEN** command target 同时配置 stdout 和 stderr 规则,且 stdout 规则失败
- **THEN** 系统 SHALL 快速失败并 MUST NOT 继续执行 stderr 规则
### Requirement: command checker 启动期配置校验
系统 SHALL 在启动期对 command checker 的配置契约和语义执行严格校验。Command target 的 `command` 分组 SHALL 只允许 `exec``args``cwd``env``maxOutputBytes` 字段Command expect SHALL 只允许 `exitCode``maxDurationMs``stdout``stderr` 字段。未知字段、非法类型和不可编译正则 MUST 导致启动期配置错误。`expect.exitCode` SHALL 保留原有有限整数数组语义,不限制到特定平台范围。
#### Scenario: command args 类型非法
- **WHEN** YAML 中 command target 配置 `command.args` 不是字符串数组
- **THEN** 系统 SHALL 以配置错误退出,提示 command.args 格式错误
#### Scenario: command cwd 类型非法
- **WHEN** YAML 中 command target 配置 `command.cwd` 不是字符串
- **THEN** 系统 SHALL 以配置错误退出,提示 command.cwd 必须为字符串
#### Scenario: command env 值类型非法
- **WHEN** YAML 中 command target 配置 `command.env`,且任一环境变量值不是字符串
- **THEN** 系统 SHALL 以配置错误退出,提示 command.env 对应变量值必须为字符串
#### Scenario: command maxOutputBytes 非法
- **WHEN** YAML 中 command target 或 defaults.command 配置的 `maxOutputBytes` 不是合法 size 值
- **THEN** 系统 SHALL 以配置错误退出,提示 maxOutputBytes 格式错误
#### Scenario: command 分组未知字段失败
- **WHEN** YAML 中 command target 的 `command` 分组包含 `shell: true` 等未知字段
- **THEN** 系统 SHALL 以配置错误退出,提示 command 分组包含未知字段
#### Scenario: command expect exitCode 类型非法
- **WHEN** YAML 中 command target 配置 `expect.exitCode` 不是整数数组
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.exitCode 必须为整数数组
#### Scenario: command expect exitCode 不限制平台范围
- **WHEN** YAML 中 command target 配置 `expect.exitCode` 为有限整数数组
- **THEN** 系统 SHALL 接受该数组,不额外限制为 0-255 等平台相关范围
#### Scenario: command expect maxDurationMs 非法
- **WHEN** YAML 中 command target 配置 `expect.maxDurationMs` 不是非负有限数字
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.maxDurationMs 格式错误
#### Scenario: stdout 必须为规则数组
- **WHEN** YAML 中 command target 配置 `expect.stdout` 但其值不是数组
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.stdout 必须为数组
#### Scenario: stderr 必须为规则数组
- **WHEN** YAML 中 command target 配置 `expect.stderr` 但其值不是数组
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.stderr 必须为数组
#### Scenario: stdout text rule 空对象非法
- **WHEN** YAML 中 command target 配置 `expect.stdout: [{}]`
- **THEN** 系统 SHALL 以配置错误退出,提示 stdout 规则必须包含至少一个合法 operator
#### Scenario: stderr text rule 未知字段非法
- **WHEN** YAML 中 command target 配置 `expect.stderr: [{foo: "bar"}]`
- **THEN** 系统 SHALL 以配置错误退出,提示 stderr 规则包含未知 operator
#### Scenario: stdout match 正则非法
- **WHEN** YAML 中 command target 配置 `expect.stdout: [{match: "[invalid"}]`
- **THEN** 系统 SHALL 在启动期配置校验失败,而不是延迟到运行期抛错
#### Scenario: command expect 未知字段失败
- **WHEN** YAML 中 command target 的 expect 包含 `status: [200]` 或其他非 command expect 字段
- **THEN** 系统 SHALL 以配置错误退出,提示 expect 包含未知字段

View File

@@ -163,7 +163,7 @@
- **THEN** 系统 SHALL 在启动期配置校验失败
### Requirement: HTTP expect 规则启动期校验
系统 SHALL 在启动期校验 HTTP expect 中已支持字段的类型、格式和可编译表达式。未知字段 SHALL 被忽略,但每个规则对象 MUST 至少包含可产生有效断言的支持字段
系统 SHALL 在启动期校验 HTTP expect 中已支持字段的类型、格式、未知字段和可编译表达式。HTTP expect、body rule、json/css/xpath rule 和 operator 对象中的未知字段 SHALL 导致启动期配置失败。每个 body rule 对象 MUST 恰好包含 contains、regex、json、css、xpath 中的一种规则类型。纯 operator 对象 MUST 至少包含一个已知 operatorbody 提取规则可以不配置 operator并以路径、元素或节点存在作为通过语义。`equals` operator SHALL 支持任意 JSON value包括数组和对象
#### Scenario: body rule 使用 regex 字段
- **WHEN** HTTP target 配置 `expect.body: [{regex: "ok|healthy"}]` 且 regex 可编译
@@ -173,9 +173,9 @@
- **WHEN** HTTP target 配置 `expect.body: [{match: "ok"}]` 且该规则没有 contains、regex、json、css、xpath 任一支持字段
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: body rule 忽略未知字段
#### Scenario: body rule 忽略未知字段 → body rule 未知字段启动失败
- **WHEN** HTTP target 配置 `expect.body: [{contains: "ok", note: "ignored"}]`
- **THEN** 系统 SHALL 忽略 note 字段并按 contains 规则校验响应体
- **THEN** 系统 SHALL 在启动期配置校验失败,提示 `note` 是未知字段
#### Scenario: body rule 多支持字段非法
- **WHEN** HTTP target 的同一条 body rule 同时配置 contains 和 regex
@@ -197,6 +197,34 @@
- **WHEN** HTTP target 的 json body rule path 不符合系统支持的 JSONPath 子集
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: operator 未知字段非法
- **WHEN** HTTP target 的 expect operator 配置了 `foo: "bar"` 等未知 operator 字段
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: equals 支持对象
- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.payload", equals: {status: "ok"}}}]`
- **THEN** 系统 SHALL 接受该配置,并在运行期使用深度相等比较提取值和对象期望
#### Scenario: equals 支持数组
- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.items", equals: ["a", "b"]}}]`
- **THEN** 系统 SHALL 接受该配置,并在运行期使用深度相等比较提取值和数组期望
#### Scenario: 纯 operator 对象不能为空
- **WHEN** HTTP target 的 `expect.headers` 中某个 header 期望配置为空对象 `{}`
- **THEN** 系统 SHALL 在启动期配置校验失败,要求显式配置至少一个 operator
#### Scenario: json rule 允许存在性语义
- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.status"}}]`
- **THEN** 系统 SHALL 接受该配置,并在运行期以 JSONPath 值存在作为通过语义
#### Scenario: css rule 未知字段非法
- **WHEN** HTTP target 配置 `expect.body: [{css: {selector: "h1", unknown: true}}]`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示未知字段
#### Scenario: xpath rule 未知字段非法
- **WHEN** HTTP target 配置 `expect.body: [{xpath: {path: "/html/body", unknown: true}}]`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示未知字段
### Requirement: HTTP body 运行期失败结构化
系统 SHALL 将 HTTP body 运行期失败记录为结构化 CheckFailure并保留与具体规则相关的 phase 和 path。响应内容不符合配置 SHALL 记录为 mismatch响应内容无法按配置解析或解码 SHALL 记录为 error。

View File

@@ -47,7 +47,13 @@
- **THEN** 系统 SHALL 以错误退出并提示文件不存在
### Requirement: 配置校验
系统 SHALL 在启动时对 YAML 配置进行完整校验,校验失败时以非零状态退出并输出清晰的错误信息。HTTP checker SHALL 对已支持字段执行严格启动期校验;未知字段 SHALL 被忽略,不触发启动失败且不影响运行行为
系统 SHALL 在启动时对 YAML 配置进行完整校验,校验失败时以非零状态退出并输出清晰的错误信息。系统 SHALL 使用 TypeBox 定义配置契约和 raw config TypeScript 类型,由 Ajv 校验 TypeBox 生成的 JSON Schema再执行启动期语义 validator。配置加载流程 SHALL 明确区分 `RawProbeConfig``ValidatedProbeConfig``ResolvedConfig` 三段生命周期。JSON Schema 契约 SHALL 覆盖业务无关的结构规则,包括字段类型、必填字段、枚举、数组与对象形状、数值范围和未知字段。语义 validator SHALL 覆盖契约不适合表达的业务规则,包括 target name 唯一性、checker type 注册状态、时长和大小解析、HTTP URL、正则可编译、JSONPath 子集和 XPath 可编译
契约校验和语义 validator SHALL 统一产出 `ConfigValidationIssue`,最终由配置加载流程统一渲染为中文错误信息。
系统 SHALL 导出完整 `probe-config.schema.json`,该文件 SHALL 与运行期 TypeBox fragments 生成的 JSON Schema 保持一致,用于用户配置引用和编辑器提示。
`headers``env` 等明确声明为动态键值表的对象外,配置中的未知字段 SHALL 导致启动期配置错误。系统 MUST NOT 静默忽略未知字段。
#### Scenario: target 缺少必填字段
- **WHEN** YAML 中某个 target 缺少 name 或 type 字段
@@ -62,8 +68,8 @@
- **THEN** 系统 SHALL 以错误退出,提示该 target 缺少 command.exec 字段
#### Scenario: target type 非法
- **WHEN** YAML 中某个 target 的 type 不是 `http``command`
- **THEN** 系统 SHALL 以错误退出,提示不支持的 target type
- **WHEN** YAML 中某个 target 的 type 不是已注册 checker 类型
- **THEN** 系统 SHALL 以错误退出,提示不支持的 target type 和当前支持的 type 列表
#### Scenario: target name 重复
- **WHEN** YAML 中存在两个 name 相同的 target
@@ -81,16 +87,28 @@
- **WHEN** runtime.maxConcurrentChecks 不是正整数
- **THEN** 系统 SHALL 以错误退出并提示 runtime.maxConcurrentChecks 格式错误
#### Scenario: interval 或 timeout 解析结果非法
- **WHEN** interval 或 timeout 解析结果不是正整数毫秒(如 `0ms``1.5ms`
- **THEN** 系统 SHALL 以错误退出并提示必须为正整数毫秒
#### Scenario: size 格式非法
- **WHEN** maxBodyBytes 或 maxOutputBytes 值不是有效的 size 格式
- **THEN** 系统 SHALL 以错误退出并提示支持 B、KB、MB、GB 格式
#### Scenario: size 解析结果非法
- **WHEN** maxBodyBytes 或 maxOutputBytes 解析结果不是非负安全整数字节数(如 `1.5B`
- **THEN** 系统 SHALL 以错误退出并提示必须为非负安全整数字节数
#### Scenario: HTTP method 非法
- **WHEN** YAML 中某个 HTTP target 的 `http.method` 不是 GET、HEAD、POST、PUT、PATCH、DELETE、OPTIONS 之一
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 method 不合法
#### Scenario: HTTP method 小写非法
- **WHEN** YAML 中某个 HTTP target 配置 `http.method: get`
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 method 必须为大写枚举值
#### Scenario: URL 格式非法
- **WHEN** YAML 中某个 HTTP target 的 `http.url` `http://` `https://` 开头
- **WHEN** YAML 中某个 HTTP target 的 `http.url`是合法 URL或协议不是 `http:` / `https:`
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 URL 格式不合法
#### Scenario: maxRedirects 非法
@@ -106,7 +124,7 @@
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 ignoreSSL 必须为布尔值
#### Scenario: HTTP headers 类型非法
- **WHEN** YAML 中某个 HTTP target 的 `http.headers` 不是对象,或任一 header 名和值不能作为字符串使用
- **WHEN** YAML 中某个 HTTP target 的 `http.headers` 不是对象,或任一 header 值不是字符串
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 http.headers 格式错误
#### Scenario: HTTP body 类型非法
@@ -165,11 +183,37 @@
- **WHEN** YAML 中某个 HTTP expect operator 的 match 不是可编译正则字符串empty/exists 不是布尔值,或 gt/gte/lt/lte 不是有限数字
- **THEN** 系统 SHALL 以错误退出,提示对应 operator 配置不合法
#### Scenario: unknown 字段忽略
- **WHEN** YAML 中某个 HTTP target、expect 或 rule 对象包含未知字段,且所有已支持字段均合法
- **THEN** 系统 SHALL 忽略未知字段并正常启动
#### Scenario: expect operator 类型非法
- **WHEN** YAML 中某个 expect operator 的 match 不是可编译正则字符串empty/exists 不是布尔值,或 gt/gte/lt/lte 不是有限数字
- **THEN** 系统 SHALL 以错误退出,提示对应 operator 配置不合法
### Requirement: size 配置解析
#### Scenario: unknown 字段失败
- **WHEN** YAML 中任一结构化配置对象包含契约未声明的字段,且该对象不是明确允许动态键的对象
- **THEN** 系统 SHALL 以错误退出,提示未知字段所在路径
#### Scenario: 动态 headers 字段允许
- **WHEN** YAML 中 `http.headers``defaults.http.headers``expect.headers` 包含任意 header 名称,且对应值符合契约
- **THEN** 系统 SHALL 接受这些动态 header 名称
#### Scenario: 动态 env 字段允许
- **WHEN** YAML 中 `command.env` 包含任意环境变量名称,且对应值为字符串
- **THEN** 系统 SHALL 接受这些动态 env 名称
#### Scenario: JSON Schema 不修改输入
- **WHEN** 系统执行 JSON Schema 契约校验
- **THEN** 系统 MUST NOT 通过契约校验器强制转换类型、注入默认值或删除未知字段
#### Scenario: 配置生命周期分离
- **WHEN** 系统加载配置文件
- **THEN** 系统 SHALL 按 `unknown -> RawProbeConfig -> ValidatedProbeConfig -> ResolvedConfig` 的顺序执行契约校验、语义校验和运行期配置解析
#### Scenario: 结构化校验 issue
- **WHEN** 契约校验或语义 validator 发现非法配置
- **THEN** 系统 SHALL 先生成包含 code、path、message 和可选 targetName 的结构化 `ConfigValidationIssue`,再统一渲染为中文错误
#### Scenario: 导出配置 JSON Schema
- **WHEN** 仓库生成或检查配置契约
- **THEN** 根目录 SHALL 存在 draft-07 `probe-config.schema.json`,且其内容 SHALL 与当前公共 fragments 和已注册 checker fragments 组装出的完整 schema 一致
系统 SHALL 支持使用单位字符串配置读取上限,单位包括 `B``KB``MB``GB`
#### Scenario: 解析 MB

View File

@@ -9,7 +9,9 @@
"build": "bun run scripts/build.ts",
"lint": "eslint .",
"format": "prettier . --write",
"check": "bun run typecheck && bun run lint && bun test",
"schema": "bun run scripts/generate-config-schema.ts",
"schema:check": "bun run scripts/generate-config-schema.ts --check",
"check": "bun run schema:check && bun run typecheck && bun run lint && bun test",
"verify": "bun run check && bun run build && bun run test:smoke",
"test": "bun test",
"test:smoke": "bun run scripts/smoke.ts",
@@ -42,8 +44,10 @@
"vite": "^8.0.11"
},
"dependencies": {
"@sinclair/typebox": "^0.34.49",
"@tanstack/react-query": "^5.100.10",
"@xmldom/xmldom": "^0.9.10",
"ajv": "^8.20.0",
"cheerio": "^1.2.0",
"es-toolkit": "^1.46.1",
"react": "^19.2.6",

724
probe-config.schema.json Normal file
View File

@@ -0,0 +1,724 @@
{
"additionalProperties": false,
"type": "object",
"required": [
"targets"
],
"properties": {
"defaults": {
"additionalProperties": false,
"type": "object",
"properties": {
"interval": {
"type": "string"
},
"timeout": {
"type": "string"
},
"http": {
"additionalProperties": false,
"type": "object",
"properties": {
"headers": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"maxBodyBytes": {
"anyOf": [
{
"type": "string"
},
{
"minimum": 0,
"type": "integer"
}
]
},
"method": {
"anyOf": [
{
"const": "DELETE",
"type": "string"
},
{
"const": "GET",
"type": "string"
},
{
"const": "HEAD",
"type": "string"
},
{
"const": "OPTIONS",
"type": "string"
},
{
"const": "PATCH",
"type": "string"
},
{
"const": "POST",
"type": "string"
},
{
"const": "PUT",
"type": "string"
}
]
}
}
},
"command": {
"additionalProperties": false,
"type": "object",
"properties": {
"cwd": {
"type": "string"
},
"maxOutputBytes": {
"anyOf": [
{
"type": "string"
},
{
"minimum": 0,
"type": "integer"
}
]
}
}
}
}
},
"runtime": {
"additionalProperties": false,
"type": "object",
"properties": {
"maxConcurrentChecks": {
"minimum": 1,
"type": "integer"
}
}
},
"server": {
"additionalProperties": false,
"type": "object",
"properties": {
"dataDir": {
"type": "string"
},
"host": {
"type": "string"
},
"port": {
"maximum": 65535,
"minimum": 0,
"type": "integer"
}
}
},
"targets": {
"minItems": 1,
"type": "array",
"items": {
"anyOf": [
{
"additionalProperties": false,
"type": "object",
"required": [
"name",
"type",
"http"
],
"properties": {
"expect": {
"additionalProperties": false,
"type": "object",
"properties": {
"body": {
"type": "array",
"items": {
"additionalProperties": false,
"type": "object",
"properties": {
"contains": {
"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"
},
"match": {
"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"
},
"match": {
"type": "string"
}
}
},
"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"
},
"match": {
"type": "string"
}
}
}
}
}
},
"headers": {
"additionalProperties": {
"anyOf": [
{
"type": "string"
},
{
"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"
},
"match": {
"type": "string"
}
}
}
]
},
"type": "object"
},
"maxDurationMs": {
"minimum": 0,
"type": "number"
},
"status": {
"type": "array",
"items": {
"anyOf": [
{
"maximum": 599,
"minimum": 100,
"type": "integer"
},
{
"pattern": "^[1-5]xx$",
"type": "string"
}
]
}
}
}
},
"group": {
"type": "string"
},
"interval": {
"type": "string"
},
"name": {
"minLength": 1,
"type": "string"
},
"timeout": {
"type": "string"
},
"type": {
"const": "http",
"type": "string"
},
"http": {
"additionalProperties": false,
"type": "object",
"required": [
"url"
],
"properties": {
"body": {
"type": "string"
},
"headers": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"ignoreSSL": {
"type": "boolean"
},
"maxBodyBytes": {
"anyOf": [
{
"type": "string"
},
{
"minimum": 0,
"type": "integer"
}
]
},
"maxRedirects": {
"minimum": 0,
"type": "integer"
},
"method": {
"anyOf": [
{
"const": "DELETE",
"type": "string"
},
{
"const": "GET",
"type": "string"
},
{
"const": "HEAD",
"type": "string"
},
{
"const": "OPTIONS",
"type": "string"
},
{
"const": "PATCH",
"type": "string"
},
{
"const": "POST",
"type": "string"
},
{
"const": "PUT",
"type": "string"
}
]
},
"url": {
"minLength": 1,
"type": "string"
}
}
}
}
},
{
"additionalProperties": false,
"type": "object",
"required": [
"name",
"type",
"command"
],
"properties": {
"expect": {
"additionalProperties": false,
"type": "object",
"properties": {
"exitCode": {
"type": "array",
"items": {
"type": "integer"
}
},
"maxDurationMs": {
"minimum": 0,
"type": "number"
},
"stderr": {
"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"
},
"match": {
"type": "string"
}
}
}
},
"stdout": {
"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"
},
"match": {
"type": "string"
}
}
}
}
}
},
"group": {
"type": "string"
},
"interval": {
"type": "string"
},
"name": {
"minLength": 1,
"type": "string"
},
"timeout": {
"type": "string"
},
"type": {
"const": "command",
"type": "string"
},
"command": {
"additionalProperties": false,
"type": "object",
"required": [
"exec"
],
"properties": {
"args": {
"type": "array",
"items": {
"type": "string"
}
},
"cwd": {
"type": "string"
},
"env": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"exec": {
"minLength": 1,
"type": "string"
},
"maxOutputBytes": {
"anyOf": [
{
"type": "string"
},
{
"minimum": 0,
"type": "integer"
}
]
}
}
}
}
}
]
}
}
},
"$id": "https://dial.local/probe-config.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {}
}

View File

@@ -0,0 +1,16 @@
import { createProbeConfigJsonSchema } from "../src/server/checker/config-contract/export";
import { createDefaultCheckerRegistry } from "../src/server/checker/runner";
const schemaPath = "probe-config.schema.json";
const schema = `${JSON.stringify(createProbeConfigJsonSchema(createDefaultCheckerRegistry()), null, 2)}\n`;
if (process.argv.includes("--check")) {
const existing = await Bun.file(schemaPath)
.text()
.catch(() => null);
if (existing !== schema) {
throw new Error(`${schemaPath} 未同步,请运行 bun run schema`);
}
} else {
await Bun.write(schemaPath, schema);
}

View File

@@ -0,0 +1,7 @@
import type { CheckerRegistry } from "../runner/registry";
import { createExternalProbeConfigSchema } from "./schema";
export function createProbeConfigJsonSchema(registry: CheckerRegistry): Record<string, unknown> {
return createExternalProbeConfigSchema(registry.definitions);
}

View File

@@ -0,0 +1,98 @@
import type { TSchema } from "@sinclair/typebox";
import { Type } from "@sinclair/typebox";
import type { JsonValue } from "./types";
export const HTTP_METHODS = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] as const;
export const BodyRuleTypeKeys = ["contains", "regex", "json", "css", "xpath"] as const;
export const OperatorKeys = ["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "match"] as const;
export const durationSchema = Type.String();
export const httpMethodSchema = Type.Union(
HTTP_METHODS.map((method) => Type.Literal(method)) as unknown as [TSchema, ...TSchema[]],
);
export const jsonValueSchema = Type.Unsafe<JsonValue>({
anyOf: [
{ type: "string" },
{ type: "number" },
{ type: "boolean" },
{ type: "null" },
{ items: {}, type: "array" },
{ additionalProperties: {}, type: "object" },
],
});
export const sizeSchema = Type.Union([Type.String(), Type.Integer({ minimum: 0 })]);
export const statusCodePatternSchema = Type.Union([
Type.Integer({ maximum: 599, minimum: 100 }),
Type.String({ pattern: "^[1-5]xx$" }),
]);
export const stringMapSchema = Type.Unsafe<Record<string, string>>({
additionalProperties: { type: "string" },
type: "object",
});
export function createBodyRulesSchema(): TSchema {
return Type.Array(
Type.Object(
{
contains: Type.Optional(Type.String()),
css: Type.Optional(
Type.Object(
{ attr: Type.Optional(Type.String()), selector: Type.String({ minLength: 1 }), ...operatorProperties() },
{ additionalProperties: false },
),
),
json: Type.Optional(
Type.Object({ path: Type.String(), ...operatorProperties() }, { additionalProperties: false }),
),
regex: Type.Optional(Type.String()),
xpath: Type.Optional(
Type.Object(
{ path: Type.String({ minLength: 1 }), ...operatorProperties() },
{ additionalProperties: false },
),
),
},
{ additionalProperties: false },
),
);
}
export function createHeaderExpectSchema(): TSchema {
return Type.Unsafe<Record<string, unknown>>({
additionalProperties: {
anyOf: [{ type: "string" }, createPureOperatorSchema()],
},
type: "object",
});
}
export function createPureOperatorSchema(): TSchema {
return Type.Object(operatorProperties(), { additionalProperties: false, minProperties: 1 });
}
export function createTextRulesSchema(): TSchema {
return Type.Array(createPureOperatorSchema());
}
export function operatorProperties(): Record<string, TSchema> {
return {
contains: Type.Optional(Type.String()),
empty: Type.Optional(Type.Boolean()),
equals: Type.Optional(jsonValueSchema),
exists: Type.Optional(Type.Boolean()),
gt: Type.Optional(Type.Number()),
gte: Type.Optional(Type.Number()),
lt: Type.Optional(Type.Number()),
lte: Type.Optional(Type.Number()),
match: Type.Optional(Type.String()),
};
}

View File

@@ -0,0 +1,37 @@
export interface ConfigValidationIssue {
code: string;
message: string;
path: string;
targetName?: string;
}
export function formatConfigIssues(issues: ConfigValidationIssue[]): string {
return issues.map(formatConfigIssue).join("\n");
}
export function issue(code: string, path: string, message: string, targetName?: string): ConfigValidationIssue {
return targetName === undefined ? { code, message, path } : { code, message, path, targetName };
}
export function joinPath(base: string, key: string): string {
if (base === "") return key;
if (key.startsWith("[")) return `${base}${key}`;
return `${base}.${key}`;
}
export function renderPath(path: string): string {
return path === "" ? "配置文件" : path;
}
export function throwConfigIssues(issues: ConfigValidationIssue[]): never {
throw new Error(formatConfigIssues(issues));
}
function formatConfigIssue(issue: ConfigValidationIssue): string {
if (issue.targetName) {
const path = issue.path.replace(/^targets\[\d+\]\.?/, "");
const renderedPath = path === "" ? "配置" : path;
return `target "${issue.targetName}" 的 ${renderedPath} ${issue.message}`;
}
return `${renderPath(issue.path)} ${issue.message}`;
}

View File

@@ -0,0 +1,89 @@
import type { TSchema } from "@sinclair/typebox";
import { Type } from "@sinclair/typebox";
import type { CheckerDefinition } from "../runner/types";
import { durationSchema } from "./fragments";
export function createExternalProbeConfigSchema(checkers: CheckerDefinition[]): Record<string, unknown> {
return {
...cloneSchema(createProbeConfigSchema(checkers, true)),
$id: "https://dial.local/probe-config.schema.json",
$schema: "http://json-schema.org/draft-07/schema#",
definitions: {},
};
}
export function createProbeConfigSchema(checkers: CheckerDefinition[], external = false): TSchema {
return Type.Object(
{
defaults: Type.Optional(createDefaultsSchema(checkers)),
runtime: Type.Optional(
Type.Object(
{ maxConcurrentChecks: Type.Optional(Type.Integer({ minimum: 1 })) },
{ additionalProperties: false },
),
),
server: Type.Optional(
Type.Object(
{
dataDir: Type.Optional(Type.String()),
host: Type.Optional(Type.String()),
port: Type.Optional(Type.Integer({ maximum: 65535, minimum: 0 })),
},
{ additionalProperties: false },
),
),
targets: Type.Array(external ? createExternalTargetSchema(checkers) : createBaseTargetSchema(checkers), {
minItems: 1,
}),
},
{ additionalProperties: false },
);
}
export function createTargetSchema(checker: CheckerDefinition): TSchema {
const properties: Record<string, TSchema> = {
expect: Type.Optional(checker.schemas.expect),
group: Type.Optional(Type.String()),
interval: Type.Optional(durationSchema),
name: Type.String({ minLength: 1 }),
timeout: Type.Optional(durationSchema),
type: Type.Literal(checker.type),
};
properties[checker.configKey] = checker.schemas.config;
return Type.Object(properties, { additionalProperties: false });
}
function cloneSchema(schema: TSchema): Record<string, unknown> {
return JSON.parse(JSON.stringify(schema)) as Record<string, unknown>;
}
function createBaseTargetSchema(checkers: CheckerDefinition[]): TSchema {
return Type.Object(
{
group: Type.Optional(Type.String()),
interval: Type.Optional(durationSchema),
name: Type.String({ minLength: 1 }),
timeout: Type.Optional(durationSchema),
type: Type.Union(checkers.map((checker) => Type.Literal(checker.type)) as unknown as [TSchema, ...TSchema[]]),
},
{ additionalProperties: true },
);
}
function createDefaultsSchema(checkers: CheckerDefinition[]): TSchema {
const properties: Record<string, TSchema> = {
interval: Type.Optional(durationSchema),
timeout: Type.Optional(durationSchema),
};
for (const checker of checkers) {
properties[checker.configKey] = Type.Optional(checker.schemas.defaults);
}
return Type.Object(properties, { additionalProperties: false });
}
function createExternalTargetSchema(checkers: CheckerDefinition[]): TSchema {
return Type.Union(checkers.map((checker) => createTargetSchema(checker)) as [TSchema, ...TSchema[]]);
}

View File

@@ -0,0 +1,13 @@
import type { ProbeConfig } from "../types";
declare const validatedConfigBrand: unique symbol;
export type JsonValue = boolean | JsonValue[] | null | number | string | { [key: string]: JsonValue };
export type RawProbeConfig = ProbeConfig;
export type ValidatedProbeConfig = RawProbeConfig & { readonly [validatedConfigBrand]: true };
export function asValidatedConfig(config: RawProbeConfig): ValidatedProbeConfig {
return config as ValidatedProbeConfig;
}

View File

@@ -0,0 +1,145 @@
import type { ErrorObject } from "ajv";
import Ajv from "ajv";
import type { CheckerRegistry } from "../runner/registry";
import type { ConfigValidationIssue } from "./issues";
import type { RawProbeConfig } from "./types";
import { issue } from "./issues";
import { createProbeConfigSchema, createTargetSchema } from "./schema";
export function createConfigAjv(): Ajv {
return new Ajv({ allErrors: true, coerceTypes: false, removeAdditional: false, strict: true, useDefaults: false });
}
export function issuesFromAjvErrors(errors: ErrorObject[], root: unknown, basePath = ""): ConfigValidationIssue[] {
return normalizeAjvErrors(errors, basePath).map((error) => issueFromAjvError(error, root, basePath));
}
export function validateProbeConfigContract(
config: unknown,
registry: CheckerRegistry,
): { config: null; issues: ConfigValidationIssue[] } | { config: RawProbeConfig; issues: [] } {
const ajv = createConfigAjv();
const checkers = registry.definitions;
const issues: ConfigValidationIssue[] = [];
const rootValidate = ajv.compile(createProbeConfigSchema(checkers));
if (!rootValidate(config)) {
issues.push(...issuesFromAjvErrors(rootValidate.errors ?? [], config));
}
if (isRecord(config) && isUnknownArray(config["targets"])) {
const targets = config["targets"];
for (let i = 0; i < targets.length; i++) {
const target = targets[i];
if (!isRecord(target) || typeof target["type"] !== "string") continue;
const checker = registry.tryGet(target["type"]);
if (!checker) continue;
const targetValidate = ajv.compile(createTargetSchema(checker));
if (!targetValidate(target)) {
issues.push(...issuesFromAjvErrors(targetValidate.errors ?? [], config, `targets[${i}]`));
}
}
}
return issues.length > 0 ? { config: null, issues } : { config: config as RawProbeConfig, issues: [] };
}
function buildIssuePath(basePath: string, error: ErrorObject): string {
const pointerPath = jsonPointerToPath(error.instancePath);
let path = basePath ? joinBasePath(basePath, pointerPath) : pointerPath;
if (error.keyword === "required" && "missingProperty" in error.params) {
path = joinBasePath(path, String(error.params["missingProperty"]));
}
if (error.keyword === "additionalProperties" && "additionalProperty" in error.params) {
path = joinBasePath(path, String(error.params["additionalProperty"]));
}
return path;
}
function hasMoreSpecificError(keywords: Set<string>): boolean {
return ["const", "enum", "maximum", "minimum", "minLength", "pattern"].some((keyword) => keywords.has(keyword));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function issueFromAjvError(error: ErrorObject, root: unknown, basePath: string): ConfigValidationIssue {
const path = buildIssuePath(basePath, error);
const targetName = targetNameFromPath(root, path);
switch (error.keyword) {
case "additionalProperties":
return issue("unknown-field", path, "是未知字段", targetName);
case "const":
case "enum":
return issue("invalid-value", path, "不在允许范围内", targetName);
case "maximum":
case "minimum":
return issue("invalid-range", path, "数值范围不合法", targetName);
case "minLength":
return issue("invalid-format", path, "不能为空", targetName);
case "pattern":
return issue("invalid-format", path, "格式不合法", targetName);
case "required":
return issue("required", path, "缺少必填字段", targetName);
case "type":
return issue("invalid-type", path, "类型不合法", targetName);
default:
return issue("invalid-config", path, error.message ?? "配置不合法", targetName);
}
}
function isUnknownArray(value: unknown): value is unknown[] {
return Array.isArray(value);
}
function joinBasePath(basePath: string, path: string): string {
if (basePath === "") return path;
if (path === "") return basePath;
if (path.startsWith("[")) return `${basePath}${path}`;
return `${basePath}.${path}`;
}
function jsonPointerToPath(pointer: string): string {
if (pointer === "") return "";
return pointer
.slice(1)
.split("/")
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))
.reduce((path, part) => (/^\d+$/.test(part) ? `${path}[${part}]` : joinBasePath(path, part)), "");
}
function normalizeAjvErrors(errors: ErrorObject[], basePath: string): ErrorObject[] {
const nonCompositeErrors = errors.filter((error) => error.keyword !== "anyOf" && error.keyword !== "oneOf");
const candidates = nonCompositeErrors.length > 0 ? nonCompositeErrors : errors;
const keywordsByPath = new Map<string, Set<string>>();
for (const error of candidates) {
const path = buildIssuePath(basePath, error);
const keywords = keywordsByPath.get(path) ?? new Set<string>();
keywords.add(error.keyword);
keywordsByPath.set(path, keywords);
}
const seenValueErrors = new Set<string>();
return candidates.filter((error) => {
const path = buildIssuePath(basePath, error);
const keywords = keywordsByPath.get(path) ?? new Set<string>();
if (error.keyword === "type" && hasMoreSpecificError(keywords)) return false;
if (error.keyword === "const" || error.keyword === "enum") {
if (seenValueErrors.has(path)) return false;
seenValueErrors.add(path);
}
return true;
});
}
function targetNameFromPath(root: unknown, path: string): string | undefined {
const match = /^targets\[(\d+)\]/.exec(path);
if (!match || !isRecord(root) || !isUnknownArray(root["targets"])) return undefined;
const target = root["targets"][Number(match[1])];
if (!isRecord(target) || typeof target["name"] !== "string") return undefined;
return target["name"];
}

View File

@@ -1,7 +1,11 @@
import { dirname, resolve } from "node:path";
import type { DefaultsConfig, EngineRuntimeConfig, ProbeConfig, ResolvedTarget, TargetConfig } from "./types";
import type { ConfigValidationIssue } from "./config-contract/issues";
import type { DefaultsConfig, EngineRuntimeConfig, ResolvedTarget, TargetConfig } from "./types";
import { issue, throwConfigIssues } from "./config-contract/issues";
import { asValidatedConfig, type RawProbeConfig } from "./config-contract/types";
import { validateProbeConfigContract } from "./config-contract/validate";
import { checkerRegistry } from "./runner";
const DEFAULT_HOST = "127.0.0.1";
@@ -28,38 +32,68 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
}
const content = await file.text();
const raw = Bun.YAML.parse(content) as null | ProbeConfig;
const parsed = Bun.YAML.parse(content);
if (!raw) {
if (!parsed) {
throw new Error("配置文件内容为空或格式无效");
}
validateConfig(raw);
const contractResult = validateProbeConfigContract(parsed, checkerRegistry);
if (contractResult.config === null && !canRunSemanticValidation(parsed)) {
throwConfigIssues(contractResult.issues);
}
const semanticInput = (contractResult.config ?? parsed) as RawProbeConfig;
const validationIssues = validateConfig(semanticInput);
const allIssues = [...contractResult.issues, ...validationIssues];
if (contractResult.config === null) {
if (allIssues.length > 0) {
throwConfigIssues(dedupeIssues(allIssues));
}
throw new Error("配置文件内容为空或格式无效");
}
const raw = contractResult.config;
const validated = asValidatedConfig(raw);
const configDir = dirname(resolve(configPath));
const server = raw.server ?? {};
const runtime = raw.runtime ?? {};
const defaults = raw.defaults ?? {};
const server = validated.server ?? {};
const runtime = validated.runtime ?? {};
const defaults = validated.defaults ?? {};
const host = server.host ?? DEFAULT_HOST;
const port = server.port ?? DEFAULT_PORT;
const dataDir = server.dataDir ?? DEFAULT_DATA_DIR;
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error(`无效端口号: ${port},需要 0-65535 之间的整数`);
const maxConcurrentChecks = resolveMaxConcurrentChecks(runtime);
const allRuntimeIssues = [...allIssues];
if (allRuntimeIssues.length > 0) {
throwConfigIssues(dedupeIssues(allRuntimeIssues));
}
const maxConcurrentChecks = validateRuntime(runtime);
const defaultIntervalMs = parseDuration(defaults.interval ?? DEFAULT_INTERVAL);
const defaultTimeoutMs = parseDuration(defaults.timeout ?? DEFAULT_TIMEOUT);
const targets: ResolvedTarget[] = raw.targets.map((target) =>
const targets: ResolvedTarget[] = validated.targets.map((target) =>
resolveTarget(target, defaults, defaultIntervalMs, defaultTimeoutMs, configDir),
);
return { configDir, dataDir, host, maxConcurrentChecks, port, targets };
}
function resolveMaxConcurrentChecks(runtime: EngineRuntimeConfig): number {
if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS;
if (
typeof runtime.maxConcurrentChecks !== "number" ||
!Number.isInteger(runtime.maxConcurrentChecks) ||
runtime.maxConcurrentChecks <= 0
)
return DEFAULT_MAX_CONCURRENT_CHECKS;
return runtime.maxConcurrentChecks;
}
function resolveTarget(
target: TargetConfig,
defaults: DefaultsConfig,
@@ -79,56 +113,83 @@ function resolveTarget(
return result;
}
function validateConfig(config: ProbeConfig): void {
if (!config.targets || !Array.isArray(config.targets) || config.targets.length === 0) {
throw new Error("配置文件必须包含至少一个 target");
function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
if (!Array.isArray(config.targets) || config.targets.length === 0) {
issues.push(issue("required", "targets", "配置文件必须包含至少一个 target"));
return issues;
}
const names = new Set<string>();
const supportedTypes = checkerRegistry.supportedTypes;
for (let i = 0; i < config.targets.length; i++) {
const raw = config.targets[i] as unknown as Record<string, unknown>;
const rawTarget = config.targets[i] as unknown;
if (!isRecord(rawTarget)) {
issues.push(issue("invalid-type", `targets[${i}]`, "必须为对象"));
continue;
}
const raw = rawTarget;
const name = raw["name"];
if (!name || typeof name !== "string" || name.trim() === "") {
throw new Error(`${i + 1} 个 target 缺少 name 字段`);
issues.push(issue("required", `targets[${i}].name`, "缺少 name 字段"));
continue;
}
const type = raw["type"];
if (!type || typeof type !== "string") {
throw new Error(`target "${name}" 缺少 type 字段`);
issues.push(issue("required", `targets[${i}].type`, "缺少 type 字段", name));
continue;
}
if (!supportedTypes.includes(type)) {
throw new Error(`target "${name}" 使用不支持的 type: "${type}",支持: ${supportedTypes.join(", ")}`);
issues.push(
issue(
"unsupported-type",
`targets[${i}].type`,
`使用不支持的 type: "${type}",支持: ${supportedTypes.join(", ")}`,
name,
),
);
}
const group = raw["group"];
if (group !== undefined && typeof group !== "string") {
throw new Error(`target "${name}" 的 group 字段必须为字符串`);
issues.push(issue("invalid-type", `targets[${i}].group`, "必须为字符串", name));
}
if (names.has(name)) {
throw new Error(`target name 重复: "${name}"`);
issues.push(issue("duplicate-name", `targets[${i}].name`, `target name 重复: "${name}"`, name));
}
names.add(name);
}
}
function validateRuntime(runtime: EngineRuntimeConfig): number {
if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS;
if (
typeof runtime.maxConcurrentChecks !== "number" ||
!Number.isInteger(runtime.maxConcurrentChecks) ||
runtime.maxConcurrentChecks <= 0
) {
throw new Error("runtime.maxConcurrentChecks 必须为正整数");
for (const checker of checkerRegistry.definitions) {
issues.push(...checker.validate({ defaults: config.defaults ?? {}, targets: config.targets }));
}
return runtime.maxConcurrentChecks;
validateDurationValue(config.defaults?.interval, "defaults.interval", issues);
validateDurationValue(config.defaults?.timeout, "defaults.timeout", issues);
for (let i = 0; i < config.targets.length; i++) {
const target = config.targets[i] as unknown;
if (!isRecord(target)) continue;
const targetName = typeof target["name"] === "string" ? target["name"] : undefined;
validateDurationValue(
typeof target["interval"] === "string" ? target["interval"] : undefined,
`targets[${i}].interval`,
issues,
targetName,
);
validateDurationValue(
typeof target["timeout"] === "string" ? target["timeout"] : undefined,
`targets[${i}].timeout`,
issues,
targetName,
);
}
return issues;
}
const DURATION_REGEX = /^(\d+(?:\.\d+)?)(ms|s|m)$/;
@@ -142,7 +203,43 @@ export function parseDuration(value: string): number {
const num = parseFloat(match[1]!);
const unit = match[2]!;
if (unit === "ms") return num;
if (unit === "s") return num * 1000;
return num * 60 * 1000;
const durationMs = unit === "ms" ? num : unit === "s" ? num * 1000 : num * 60 * 1000;
if (!Number.isInteger(durationMs) || durationMs <= 0 || !Number.isFinite(durationMs)) {
throw new Error(`无效的时长格式: "${value}",解析结果必须为正整数毫秒`);
}
return durationMs;
}
function canRunSemanticValidation(value: unknown): boolean {
return typeof value === "object" && value !== null;
}
function dedupeIssues(issues: ConfigValidationIssue[]): ConfigValidationIssue[] {
const seen = new Set<string>();
const result: ConfigValidationIssue[] = [];
for (const item of issues) {
const key = `${item.code}:${item.path}:${item.message}:${item.targetName ?? ""}`;
if (seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function validateDurationValue(
value: string | undefined,
path: string,
issues: ConfigValidationIssue[],
targetName?: string,
): void {
if (value === undefined) return;
try {
parseDuration(value);
} catch (error) {
issues.push(issue("invalid-duration", path, error instanceof Error ? error.message : "时长格式不合法", targetName));
}
}

View File

@@ -0,0 +1,34 @@
import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import { createTextRulesSchema, sizeSchema, stringMapSchema } from "../../config-contract/fragments";
export const commandCheckerSchemas: CheckerSchemas = {
config: Type.Object(
{
args: Type.Optional(Type.Array(Type.String())),
cwd: Type.Optional(Type.String()),
env: Type.Optional(stringMapSchema),
exec: Type.String({ minLength: 1 }),
maxOutputBytes: Type.Optional(sizeSchema),
},
{ additionalProperties: false },
),
defaults: Type.Object(
{
cwd: Type.Optional(Type.String()),
maxOutputBytes: Type.Optional(sizeSchema),
},
{ additionalProperties: false },
),
expect: Type.Object(
{
exitCode: Type.Optional(Type.Array(Type.Integer())),
maxDurationMs: Type.Optional(Type.Number({ minimum: 0 })),
stderr: Type.Optional(createTextRulesSchema()),
stdout: Type.Optional(createTextRulesSchema()),
},
{ additionalProperties: false },
),
};

View File

@@ -8,15 +8,21 @@ import type {
ResolvedTarget,
TargetConfig,
} from "../../types";
import type { Checker, CheckerContext, ResolveContext } from "../types";
import type { Checker, CheckerContext, CheckerValidationInput, ResolveContext } from "../types";
import { parseSize } from "../../size";
import { checkDuration } from "../shared/duration";
import { errorFailure } from "../shared/failure";
import { checkTextRules } from "../shared/text";
import { commandCheckerSchemas } from "./contract";
import { checkExitCode } from "./expect";
import { validateCommandConfig } from "./validate";
export class CommandChecker implements Checker {
readonly configKey = "command";
readonly schemas = commandCheckerSchemas;
readonly type = "command";
async execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult> {
@@ -29,7 +35,7 @@ export class CommandChecker implements Checker {
try {
proc = Bun.spawn([t.command.exec, ...t.command.args], {
cwd: t.command.cwd,
env: t.command.env,
env: { ...process.env, ...t.command.env },
stderr: "pipe",
stdin: "ignore",
stdout: "pipe",
@@ -172,10 +178,6 @@ export class CommandChecker implements Checker {
const t = target as TargetConfig & { command: CommandTargetConfig; type: "command" };
const commandDefaults = context.defaults.command;
if (!t.command.exec || t.command.exec.trim() === "") {
throw new Error(`target "${t.name}" 缺少 command.exec 字段`);
}
const cwd = t.command.cwd ?? commandDefaults?.cwd ?? ".";
const resolvedCwd = resolve(context.configDir, cwd);
@@ -214,6 +216,10 @@ export class CommandChecker implements Checker {
target: `exec ${parts.join(" ")}`,
};
}
validate(input: CheckerValidationInput) {
return validateCommandConfig(input);
}
}
async function readOutput(

View File

@@ -0,0 +1,93 @@
import type { ConfigValidationIssue } from "../../config-contract/issues";
import type { CheckerValidationInput } from "../types";
import { issue, joinPath } from "../../config-contract/issues";
import { parseSize } from "../../size";
import { validateTextRules } from "../shared/validate";
export function validateCommandConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const defaults =
isRecord(input.defaults) && isRecord(input.defaults["command"]) ? input.defaults["command"] : undefined;
if (isSizeInput(defaults?.["maxOutputBytes"])) {
issues.push(...validateSizeValue(defaults["maxOutputBytes"], "defaults.command.maxOutputBytes"));
}
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isRecord(target)) continue;
if (target["type"] !== "command") continue;
issues.push(...validateCommandTarget(target, `targets[${i}]`));
}
return issues;
}
function getTargetName(target: Record<string, unknown>): string | undefined {
return typeof target["name"] === "string" ? target["name"] : undefined;
}
function isNonNegativeFiniteNumber(value: unknown): boolean {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isSizeInput(value: unknown): value is number | string {
return typeof value === "number" || typeof value === "string";
}
function validateCommandExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const targetName = getTargetName(target);
const expect = target["expect"];
if (expect === undefined || expect === null || !isRecord(expect)) return [];
const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect");
if (expect["stdout"] !== undefined) {
issues.push(...validateTextRules(expect["stdout"], joinPath(expectPath, "stdout"), targetName));
}
if (expect["stderr"] !== undefined) {
issues.push(...validateTextRules(expect["stderr"], joinPath(expectPath, "stderr"), targetName));
}
if (expect["maxDurationMs"] !== undefined && !isNonNegativeFiniteNumber(expect["maxDurationMs"])) {
issues.push(issue("invalid-type", joinPath(expectPath, "maxDurationMs"), "必须为非负有限数字", targetName));
}
return issues;
}
function validateCommandTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const command = target["command"];
if (!isRecord(command)) {
issues.push(issue("required", joinPath(path, "command"), "缺少 command.exec 字段", targetName));
issues.push(...validateCommandExpect(target, path));
return issues;
}
if (typeof command["exec"] !== "string" || command["exec"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "command"), "exec"), "缺少 command.exec 字段", targetName));
}
if (isSizeInput(command["maxOutputBytes"])) {
issues.push(
...validateSizeValue(
command["maxOutputBytes"],
joinPath(joinPath(path, "command"), "maxOutputBytes"),
targetName,
),
);
}
issues.push(...validateCommandExpect(target, path));
return issues;
}
function validateSizeValue(value: number | string, path: string, targetName?: string): ConfigValidationIssue[] {
try {
parseSize(value);
return [];
} catch (error) {
return [issue("invalid-size", path, error instanceof Error ? error.message : "size 格式不合法", targetName)];
}
}

View File

@@ -0,0 +1,44 @@
import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import {
createBodyRulesSchema,
createHeaderExpectSchema,
httpMethodSchema,
sizeSchema,
statusCodePatternSchema,
stringMapSchema,
} from "../../config-contract/fragments";
export const httpCheckerSchemas: CheckerSchemas = {
config: Type.Object(
{
body: Type.Optional(Type.String()),
headers: Type.Optional(stringMapSchema),
ignoreSSL: Type.Optional(Type.Boolean()),
maxBodyBytes: Type.Optional(sizeSchema),
maxRedirects: Type.Optional(Type.Integer({ minimum: 0 })),
method: Type.Optional(httpMethodSchema),
url: Type.String({ minLength: 1 }),
},
{ additionalProperties: false },
),
defaults: Type.Object(
{
headers: Type.Optional(stringMapSchema),
maxBodyBytes: Type.Optional(sizeSchema),
method: Type.Optional(httpMethodSchema),
},
{ additionalProperties: false },
),
expect: Type.Object(
{
body: Type.Optional(createBodyRulesSchema()),
headers: Type.Optional(createHeaderExpectSchema()),
maxDurationMs: Type.Optional(Type.Number({ minimum: 0 })),
status: Type.Optional(Type.Array(statusCodePatternSchema)),
},
{ additionalProperties: false },
),
};

View File

@@ -1,22 +1,25 @@
import { isError } from "es-toolkit";
import type { CheckResult, HttpTargetConfig, ResolvedHttpTarget, ResolvedTarget, TargetConfig } from "../../types";
import type { Checker, CheckerContext, ResolveContext } from "../types";
import type { Checker, CheckerContext, CheckerValidationInput, ResolveContext } from "../types";
import { parseSize } from "../../size";
import { checkBodyExpect } from "../shared/body";
import { checkDuration } from "../shared/duration";
import { errorFailure, mismatchFailure } from "../shared/failure";
import { httpCheckerSchemas } from "./contract";
import { checkHeaders, checkStatus } from "./expect";
import { validateHttpConfig, validateHttpExpect } from "./validate";
import { validateHttpConfig } from "./validate";
const ALLOWED_METHODS = new Set(["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]);
const CHARSET_RE = /charset="?([^";\s]+)"?/i;
const URL_RE = /^https?:\/\/.+/;
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
const SENSITIVE_HEADERS = new Set(["authorization", "cookie"]);
export class HttpChecker implements Checker {
readonly configKey = "http";
readonly schemas = httpCheckerSchemas;
readonly type = "http";
async execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult> {
@@ -117,45 +120,7 @@ export class HttpChecker implements Checker {
const t = target as TargetConfig & { http: HttpTargetConfig; type: "http" };
const httpDefaults = context.defaults.http;
if (!t.http || typeof t.http !== "object") {
throw new Error(`target "${t.name}" 缺少 http.url 字段`);
}
validateHttpConfig(t.http, t.name);
if (typeof t.http.url !== "string" || t.http.url.trim() === "") {
throw new Error(`target "${t.name}" 缺少 http.url 字段`);
}
const rawMethod = t.http.method ?? httpDefaults?.method ?? "GET";
if (typeof rawMethod !== "string") {
throw new Error(`target "${t.name}" 的 http.method 必须为字符串`);
}
const method = rawMethod.toUpperCase();
if (!ALLOWED_METHODS.has(method)) {
throw new Error(
`target "${t.name}" 的 http.method "${method}" 不合法,合法值: ${[...ALLOWED_METHODS].join(", ")}`,
);
}
if (!URL_RE.test(t.http.url)) {
throw new Error(`target "${t.name}" 的 http.url "${t.http.url}" 格式不合法,必须以 http:// 或 https:// 开头`);
}
if (t.http.ignoreSSL !== undefined && typeof t.http.ignoreSSL !== "boolean") {
throw new Error(`target "${t.name}" 的 http.ignoreSSL 必须为布尔值`);
}
if (
t.http.maxRedirects !== undefined &&
(typeof t.http.maxRedirects !== "number" || !Number.isInteger(t.http.maxRedirects) || t.http.maxRedirects < 0)
) {
throw new Error(`target "${t.name}" 的 http.maxRedirects 必须为非负整数`);
}
validateHttpExpect(target.expect, t.name);
const method = t.http.method ?? httpDefaults?.method ?? "GET";
const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB");
return {
@@ -192,6 +157,10 @@ export class HttpChecker implements Checker {
target: t.http.url,
};
}
validate(input: CheckerValidationInput) {
return validateHttpConfig(input);
}
}
function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: string, toUrl: string): RequestInit {

View File

@@ -1,251 +1,139 @@
import { DOMParser } from "@xmldom/xmldom";
import * as xpath from "xpath";
import type { ConfigValidationIssue } from "../../config-contract/issues";
import type { CheckerValidationInput } from "../types";
const BODY_RULE_TYPES = ["contains", "regex", "json", "css", "xpath"];
import { issue, joinPath } from "../../config-contract/issues";
import { parseSize } from "../../size";
import { validateBodyRules, validateOperatorObject } from "../shared/validate";
const OPERATOR_KEYS = new Set(["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "match"]);
const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
export function validateHttpConfig(http: unknown, targetName: string): void {
if (!http || typeof http !== "object") {
throw new Error(`target "${targetName}" 缺少 http 配置`);
export function validateHttpConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const defaults = isRecord(input.defaults) && isRecord(input.defaults["http"]) ? input.defaults["http"] : undefined;
if (isSizeInput(defaults?.["maxBodyBytes"])) {
issues.push(...validateSizeValue(defaults["maxBodyBytes"], "defaults.http.maxBodyBytes"));
}
const h = http as Record<string, unknown>;
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isRecord(target)) continue;
if (target["type"] !== "http") continue;
issues.push(...validateHttpTarget(target, `targets[${i}]`));
}
if ("headers" in h && h["headers"] !== undefined) {
if (typeof h["headers"] !== "object" || h["headers"] === null || Array.isArray(h["headers"])) {
throw new Error(`target "${targetName}" 的 http.headers 必须为对象`);
return issues;
}
function getTargetName(target: Record<string, unknown>): string | undefined {
return typeof target["name"] === "string" ? target["name"] : undefined;
}
function isNonNegativeFiniteNumber(value: unknown): boolean {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isSizeInput(value: unknown): value is number | string {
return typeof value === "number" || typeof value === "string";
}
function validateHttpExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const targetName = getTargetName(target);
const expect = target["expect"];
if (expect === undefined || expect === null || !isRecord(expect)) return [];
const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect");
if (isRecord(expect["headers"])) {
for (const [key, value] of Object.entries(expect["headers"])) {
if (typeof value === "string") continue;
issues.push(...validateOperatorObject(value, joinPath(joinPath(expectPath, "headers"), key), targetName));
}
for (const [key, value] of Object.entries(h["headers"] as Record<string, unknown>)) {
if (typeof value !== "string") {
throw new Error(`target "${targetName}" 的 http.headers.${key} 必须为字符串`);
}
if (expect["body"] !== undefined) {
issues.push(...validateBodyRules(expect["body"], joinPath(expectPath, "body"), targetName));
}
if (Array.isArray(expect["status"])) {
issues.push(...validateStatusValues(expect["status"], joinPath(expectPath, "status"), targetName));
}
if (expect["maxDurationMs"] !== undefined && !isNonNegativeFiniteNumber(expect["maxDurationMs"])) {
issues.push(issue("invalid-type", joinPath(expectPath, "maxDurationMs"), "必须为非负有限数字", targetName));
}
return issues;
}
function validateHttpTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const http = target["http"];
if (!isRecord(http)) {
issues.push(issue("required", joinPath(path, "http"), "缺少 http.url 字段", targetName));
issues.push(...validateHttpExpect(target, path));
return issues;
}
if (typeof http["url"] !== "string" || http["url"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "http"), "url"), "缺少 http.url 字段", targetName));
} else {
try {
const url = new URL(http["url"]);
if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
issues.push(
issue(
"invalid-url",
joinPath(joinPath(path, "http"), "url"),
"格式不合法,必须以 http:// 或 https:// 开头",
targetName,
),
);
}
} catch {
issues.push(issue("invalid-url", joinPath(joinPath(path, "http"), "url"), "格式不合法", targetName));
}
}
if ("body" in h && h["body"] !== undefined) {
if (typeof h["body"] !== "string") {
throw new Error(`target "${targetName}" 的 http.body 必须为字符串`);
}
}
}
export function validateHttpExpect(expect: unknown, targetName: string): void {
if (expect === undefined || expect === null) return;
if (typeof expect !== "object" || Array.isArray(expect)) {
throw new Error(`target "${targetName}" 的 expect 必须为对象`);
}
const e = expect as Record<string, unknown>;
if ("status" in e) validateStatus(e["status"], targetName);
if ("maxDurationMs" in e) validateMaxDurationMs(e["maxDurationMs"], targetName);
if ("headers" in e) validateExpectHeaders(e["headers"], targetName);
if ("body" in e) validateBodyRules(e["body"], targetName);
}
function validateBodyRules(body: unknown, targetName: string): void {
if (!Array.isArray(body)) {
throw new Error(`target "${targetName}" 的 expect.body 必须为数组`);
}
for (let i = 0; i < body.length; i++) {
validateSingleBodyRule(body[i], i, targetName);
}
}
function validateExpectHeaders(headers: unknown, targetName: string): void {
if (typeof headers !== "object" || headers === null || Array.isArray(headers)) {
throw new Error(`target "${targetName}" 的 expect.headers 必须为对象`);
}
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
if (typeof value === "string") continue;
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
validateOperators(value as Record<string, unknown>, targetName, `expect.headers.${key}`);
} else {
throw new Error(`target "${targetName}" 的 expect.headers.${key} 必须为字符串或操作符对象`);
}
}
}
function validateJsonPath(path: string, targetName: string, rulePath: string): void {
const segments = path.slice(2).split(".");
for (const seg of segments) {
if (seg === "") {
throw new Error(`target "${targetName}" 的 ${rulePath}.path 包含空段`);
}
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
if (bracketMatch?.[1]!.trim() === "") {
throw new Error(`target "${targetName}" 的 ${rulePath}.path 数组访问缺少属性名`);
}
}
}
function validateMaxDurationMs(value: unknown, targetName: string): void {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new Error(`target "${targetName}" 的 expect.maxDurationMs 必须为非负有限数字`);
}
}
function validateOperators(ops: Record<string, unknown>, targetName: string, path: string): void {
for (const [key, value] of Object.entries(ops)) {
if (!OPERATOR_KEYS.has(key)) continue;
switch (key) {
case "contains":
if (typeof value !== "string") {
throw new Error(`target "${targetName}" 的 ${path}.contains 必须为字符串`);
}
break;
case "empty":
case "exists":
if (typeof value !== "boolean") {
throw new Error(`target "${targetName}" 的 ${path}.${key} 必须为布尔值`);
}
break;
case "equals":
if (typeof value !== "boolean" && typeof value !== "number" && typeof value !== "string" && value !== null) {
throw new Error(`target "${targetName}" 的 ${path}.equals 类型不合法`);
}
if (typeof value === "number" && !Number.isFinite(value)) {
throw new Error(`target "${targetName}" 的 ${path}.equals 不能为 NaN 或 Infinity`);
}
break;
case "gt":
case "gte":
case "lt":
case "lte":
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error(`target "${targetName}" 的 ${path}.${key} 必须为有限数字`);
}
break;
case "match":
if (typeof value !== "string") {
throw new Error(`target "${targetName}" 的 ${path}.match 必须为字符串`);
}
try {
new RegExp(value);
} catch {
throw new Error(`target "${targetName}" 的 ${path}.match 正则不合法`);
}
break;
}
}
}
function validateSingleBodyRule(rule: unknown, index: number, targetName: string): void {
if (typeof rule !== "object" || rule === null) {
throw new Error(`target "${targetName}" 的 expect.body[${index}] 必须为对象`);
}
const ruleObj = rule as Record<string, unknown>;
const found: string[] = [];
for (const type of BODY_RULE_TYPES) {
if (type in ruleObj) found.push(type);
}
if (found.length === 0) {
throw new Error(
`target "${targetName}" 的 expect.body[${index}] 缺少支持的规则类型contains/regex/json/css/xpath`,
);
}
if (found.length > 1) {
throw new Error(
`target "${targetName}" 的 expect.body[${index}] 只能配置一种规则类型,当前包含: ${found.join(", ")}`,
if (isSizeInput(http["maxBodyBytes"])) {
issues.push(
...validateSizeValue(http["maxBodyBytes"], joinPath(joinPath(path, "http"), "maxBodyBytes"), targetName),
);
}
issues.push(...validateHttpExpect(target, path));
return issues;
}
const ruleType = found[0]!;
const rulePath = `expect.body[${index}]`;
switch (ruleType) {
case "contains":
if (typeof ruleObj["contains"] !== "string") {
throw new Error(`target "${targetName}" 的 ${rulePath}.contains 必须为字符串`);
}
break;
case "css": {
const cssRule = ruleObj["css"];
if (typeof cssRule !== "object" || cssRule === null) {
throw new Error(`target "${targetName}" 的 ${rulePath}.css 必须为对象`);
}
const cr = cssRule as Record<string, unknown>;
if (typeof cr["selector"] !== "string" || cr["selector"].trim() === "") {
throw new Error(`target "${targetName}" 的 ${rulePath}.css.selector 必须为非空字符串`);
}
const cssOps: Record<string, unknown> = {};
for (const [k, v] of Object.entries(cr)) {
if (k !== "selector" && k !== "attr") cssOps[k] = v;
}
validateOperators(cssOps, targetName, `${rulePath}.css`);
break;
}
case "json": {
const jsonRule = ruleObj["json"];
if (typeof jsonRule !== "object" || jsonRule === null) {
throw new Error(`target "${targetName}" 的 ${rulePath}.json 必须为对象`);
}
const jr = jsonRule as Record<string, unknown>;
if (typeof jr["path"] !== "string" || !jr["path"].startsWith("$.") || jr["path"].length <= 2) {
throw new Error(`target "${targetName}" 的 ${rulePath}.json.path 必须为以 "$." 开头的有效 JSONPath`);
}
validateJsonPath(jr["path"], targetName, `${rulePath}.json`);
const jsonOps: Record<string, unknown> = {};
for (const [k, v] of Object.entries(jr)) {
if (k !== "path") jsonOps[k] = v;
}
validateOperators(jsonOps, targetName, `${rulePath}.json`);
break;
}
case "regex":
if (typeof ruleObj["regex"] !== "string") {
throw new Error(`target "${targetName}" 的 ${rulePath}.regex 必须为字符串`);
}
try {
new RegExp(ruleObj["regex"]);
} catch {
throw new Error(`target "${targetName}" 的 ${rulePath}.regex 正则不合法`);
}
break;
case "xpath": {
const xpathRule = ruleObj["xpath"];
if (typeof xpathRule !== "object" || xpathRule === null) {
throw new Error(`target "${targetName}" 的 ${rulePath}.xpath 必须为对象`);
}
const xr = xpathRule as Record<string, unknown>;
if (typeof xr["path"] !== "string" || xr["path"].trim() === "") {
throw new Error(`target "${targetName}" 的 ${rulePath}.xpath.path 必须为非空字符串`);
}
try {
const doc = new DOMParser().parseFromString("<x/>", "text/xml");
xpath.select(xr["path"], doc as unknown as Node);
} catch {
throw new Error(`target "${targetName}" 的 ${rulePath}.xpath.path xpath 不合法`);
}
const xpathOps: Record<string, unknown> = {};
for (const [k, v] of Object.entries(xr)) {
if (k !== "path") xpathOps[k] = v;
}
validateOperators(xpathOps, targetName, `${rulePath}.xpath`);
break;
}
function validateSizeValue(value: number | string, path: string, targetName?: string): ConfigValidationIssue[] {
try {
parseSize(value);
return [];
} catch (error) {
return [issue("invalid-size", path, error instanceof Error ? error.message : "size 格式不合法", targetName)];
}
}
function validateStatus(status: unknown, targetName: string): void {
if (!Array.isArray(status)) {
throw new Error(`target "${targetName}" 的 expect.status 必须为数组`);
}
for (const p of status) {
if (typeof p === "number") {
if (!Number.isInteger(p) || p < 100 || p > 599) {
throw new Error(`target "${targetName}" 的 expect.status 数字 ${p} 不合法,必须为 100-599 之间的整数`);
function validateStatusValues(values: unknown[], path: string, targetName?: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
for (let i = 0; i < values.length; i++) {
const value = values[i];
const itemPath = `${path}[${i}]`;
if (typeof value === "number") {
if (!Number.isInteger(value) || value < 100 || value > 599) {
issues.push(issue("invalid-status", itemPath, "status 数字必须为 100-599 之间的整数", targetName));
}
} else if (typeof p === "string") {
if (!/^[1-5]xx$/.test(p)) {
throw new Error(`target "${targetName}" 的 expect.status 模式 "${p}" 不合法,字符串必须为 "1xx" 到 "5xx" 格式`);
}
} else {
throw new Error(`target "${targetName}" 的 expect.status 只能包含数字或范围模式字符串`);
continue;
}
if (typeof value === "string") {
if (!/^[1-5]xx$/.test(value)) {
issues.push(issue("invalid-status", itemPath, "status 模式必须为 1xx 到 5xx", targetName));
}
continue;
}
issues.push(issue("invalid-status", itemPath, "status 必须为整数或 1xx 到 5xx 模式", targetName));
}
return issues;
}

View File

@@ -1,10 +1,16 @@
import { CommandChecker } from "./command/runner";
import { HttpChecker } from "./http/runner";
import { checkerRegistry } from "./registry";
import { CheckerRegistry, checkerRegistry } from "./registry";
export function registerCheckers(): void {
checkerRegistry.register(new HttpChecker());
checkerRegistry.register(new CommandChecker());
export function createDefaultCheckerRegistry(): CheckerRegistry {
const registry = new CheckerRegistry();
registerCheckers(registry);
return registry;
}
export function registerCheckers(registry = checkerRegistry): void {
registry.register(new HttpChecker());
registry.register(new CommandChecker());
}
export { checkerRegistry } from "./registry";

View File

@@ -1,13 +1,17 @@
import type { Checker } from "./types";
import type { CheckerDefinition } from "./types";
export class CheckerRegistry {
get definitions(): CheckerDefinition[] {
return [...this.checkers.values()];
}
get supportedTypes(): string[] {
return [...this.checkers.keys()];
}
private checkers = new Map<string, Checker>();
private checkers = new Map<string, CheckerDefinition>();
get(type: string): Checker {
get(type: string): CheckerDefinition {
const checker = this.checkers.get(type);
if (!checker) {
throw new Error(`不支持的 probe type: "${type}"`);
@@ -15,12 +19,16 @@ export class CheckerRegistry {
return checker;
}
register(checker: Checker): void {
register(checker: CheckerDefinition): void {
if (this.checkers.has(checker.type)) {
throw new Error(`Checker type "${checker.type}" 已注册`);
}
this.checkers.set(checker.type, checker);
}
tryGet(type: string): CheckerDefinition | undefined {
return this.checkers.get(type);
}
}
export const checkerRegistry = new CheckerRegistry();

View File

@@ -2,6 +2,8 @@ import { isEmptyObject, isEqual, isNil, isPlainObject } from "es-toolkit";
import type { ExpectOperator, ExpectValue } from "../../types";
const OPERATOR_KEYS = new Set(["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "match"]);
export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
for (const [key, expected] of Object.entries(op)) {
if (expected === undefined) continue;
@@ -48,10 +50,10 @@ export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
}
export function checkExpectValue(actual: unknown, expected: ExpectValue): boolean {
if (isPlainObject(expected)) {
return applyOperator(actual, expected);
if (isPlainObject(expected) && Object.keys(expected).some((key) => OPERATOR_KEYS.has(key))) {
return applyOperator(actual, expected as ExpectOperator);
}
return applyOperator(actual, { equals: expected });
return applyOperator(actual, { equals: expected as Exclude<ExpectValue, ExpectOperator> });
}
export function evaluateJsonPath(json: unknown, path: string): unknown {

View File

@@ -0,0 +1,223 @@
import { DOMParser } from "@xmldom/xmldom";
import * as xpath from "xpath";
import type { ConfigValidationIssue } from "../../config-contract/issues";
import type { JsonValue } from "../../types";
import { BodyRuleTypeKeys, OperatorKeys } from "../../config-contract/fragments";
import { issue, joinPath } from "../../config-contract/issues";
const OPERATOR_KEY_SET = new Set<string>(OperatorKeys);
export function isJsonValue(value: unknown): value is JsonValue {
if (value === null) return true;
if (typeof value === "string" || typeof value === "boolean") return true;
if (typeof value === "number") return Number.isFinite(value);
if (Array.isArray(value)) return value.every(isJsonValue);
if (typeof value === "object") {
return Object.values(value as Record<string, unknown>).every(isJsonValue);
}
return false;
}
export function validateBodyRules(body: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!Array.isArray(body)) return [issue("invalid-type", path, "必须为数组", targetName)];
return body.flatMap((rule, index) => validateSingleBodyRule(rule, `${path}[${index}]`, targetName));
}
export function validateJsonPath(path: string, rulePath: string, targetName?: string): ConfigValidationIssue[] {
if (!path.startsWith("$.") || path.length <= 2) {
return [issue("invalid-jsonpath", joinPath(rulePath, "path"), '必须为以 "$." 开头的有效 JSONPath', targetName)];
}
const issues: ConfigValidationIssue[] = [];
const segments = path.slice(2).split(".");
for (const seg of segments) {
if (seg === "") {
issues.push(issue("invalid-jsonpath", joinPath(rulePath, "path"), "包含空段", targetName));
}
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
if (bracketMatch?.[1]!.trim() === "") {
issues.push(issue("invalid-jsonpath", joinPath(rulePath, "path"), "数组访问缺少属性名", targetName));
}
}
return issues;
}
export function validateOperatorObject(
operators: unknown,
path: string,
targetName?: string,
options: { requireAtLeastOne: boolean } = { requireAtLeastOne: true },
): ConfigValidationIssue[] {
if (!isPlainRecord(operators)) return [issue("invalid-type", path, "必须为操作符对象", targetName)];
const issues: ConfigValidationIssue[] = [];
let found = 0;
for (const [key, value] of Object.entries(operators)) {
if (!OPERATOR_KEY_SET.has(key)) {
issues.push(issue("unknown-operator", joinPath(path, key), "是未知 operator", targetName));
continue;
}
if (value === undefined) continue;
found++;
issues.push(...validateOperatorValue(key, value, joinPath(path, key), targetName));
}
if (options.requireAtLeastOne && found === 0) {
issues.push(issue("empty-operator", path, "必须包含至少一个合法 operator", targetName));
}
return issues;
}
export function validateTextRules(rules: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!Array.isArray(rules)) return [issue("invalid-type", path, "必须为数组", targetName)];
return rules.flatMap((rule, index) => validateOperatorObject(rule, `${path}[${index}]`, targetName));
}
function collectOperatorObject(
object: Record<string, unknown>,
allowedKeys: Set<string>,
path: string,
targetName?: string,
): { issues: ConfigValidationIssue[]; operators: Record<string, unknown> } {
const issues: ConfigValidationIssue[] = [];
const operators: Record<string, unknown> = {};
for (const [key, value] of Object.entries(object)) {
if (allowedKeys.has(key)) continue;
if (OPERATOR_KEY_SET.has(key)) {
operators[key] = value;
} else {
issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName));
}
}
return { issues, operators };
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function validateCssRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (typeof rule["selector"] !== "string" || rule["selector"].trim() === "") {
issues.push(issue("invalid-type", joinPath(path, "selector"), "必须为非空字符串", targetName));
}
if ("attr" in rule && typeof rule["attr"] !== "string") {
issues.push(issue("invalid-type", joinPath(path, "attr"), "必须为字符串", targetName));
}
const result = collectOperatorObject(rule, new Set(["attr", "selector"]), path, targetName);
issues.push(
...result.issues,
...validateOperatorObject(result.operators, path, targetName, { requireAtLeastOne: false }),
);
return issues;
}
function validateJsonRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (typeof rule["path"] !== "string") {
issues.push(issue("invalid-type", joinPath(path, "path"), "必须为字符串", targetName));
} else {
issues.push(...validateJsonPath(rule["path"], path, targetName));
}
const result = collectOperatorObject(rule, new Set(["path"]), path, targetName);
issues.push(
...result.issues,
...validateOperatorObject(result.operators, path, targetName, { requireAtLeastOne: false }),
);
return issues;
}
function validateOperatorValue(
key: string,
value: unknown,
path: string,
targetName?: string,
): ConfigValidationIssue[] {
switch (key) {
case "contains":
return typeof value === "string" ? [] : [issue("invalid-type", path, "必须为字符串", targetName)];
case "empty":
case "exists":
return typeof value === "boolean" ? [] : [issue("invalid-type", path, "必须为布尔值", targetName)];
case "equals":
return isJsonValue(value) ? [] : [issue("invalid-type", path, "必须为 JSON value", targetName)];
case "gt":
case "gte":
case "lt":
case "lte":
return typeof value === "number" && Number.isFinite(value)
? []
: [issue("invalid-type", path, "必须为有限数字", targetName)];
case "match":
if (typeof value !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)];
try {
new RegExp(value);
return [];
} catch {
return [issue("invalid-regex", path, "正则不合法", targetName)];
}
default:
return [issue("unknown-operator", path, "是未知 operator", targetName)];
}
}
function validateRegexRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (typeof rule !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)];
try {
new RegExp(rule);
return [];
} catch {
return [issue("invalid-regex", path, "正则不合法", targetName)];
}
}
function validateSingleBodyRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const found = BodyRuleTypeKeys.filter((type) => type in rule);
if (found.length === 0) return [issue("missing-body-rule", path, "缺少支持的规则类型", targetName)];
if (found.length > 1) return [issue("multiple-body-rules", path, "只能配置一种规则类型", targetName)];
const ruleType = found[0]!;
const issues: ConfigValidationIssue[] = [];
for (const key of Object.keys(rule)) {
if (key !== ruleType) issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName));
}
if (issues.length > 0) return issues;
switch (ruleType) {
case "contains":
return typeof rule["contains"] === "string"
? []
: [issue("invalid-type", joinPath(path, "contains"), "必须为字符串", targetName)];
case "css":
return validateCssRule(rule["css"], joinPath(path, "css"), targetName);
case "json":
return validateJsonRule(rule["json"], joinPath(path, "json"), targetName);
case "regex":
return validateRegexRule(rule["regex"], joinPath(path, "regex"), targetName);
case "xpath":
return validateXpathRule(rule["xpath"], joinPath(path, "xpath"), targetName);
}
}
function validateXpathRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (typeof rule["path"] !== "string" || rule["path"].trim() === "") {
issues.push(issue("invalid-type", joinPath(path, "path"), "必须为非空字符串", targetName));
} else {
try {
const doc = new DOMParser().parseFromString("<x/>", "text/xml");
xpath.select(rule["path"], doc as unknown as Node);
} catch {
issues.push(issue("invalid-xpath", joinPath(path, "path"), "xpath 不合法", targetName));
}
}
const result = collectOperatorObject(rule, new Set(["path"]), path, targetName);
issues.push(
...result.issues,
...validateOperatorObject(result.operators, path, targetName, { requireAtLeastOne: false }),
);
return issues;
}

View File

@@ -1,16 +1,35 @@
import type { TSchema } from "@sinclair/typebox";
import type { ConfigValidationIssue } from "../config-contract/issues";
import type { CheckResult, DefaultsConfig, ResolvedTarget, TargetConfig } from "../types";
export interface Checker {
execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult>;
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget;
serialize(target: ResolvedTarget): { config: string; target: string };
readonly type: string;
}
export type Checker = CheckerDefinition;
export interface CheckerContext {
signal: AbortSignal;
}
export interface CheckerDefinition {
readonly configKey: string;
execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult>;
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget;
readonly schemas: CheckerSchemas;
serialize(target: ResolvedTarget): { config: string; target: string };
readonly type: string;
validate(input: CheckerValidationInput): ConfigValidationIssue[];
}
export interface CheckerSchemas {
config: TSchema;
defaults: TSchema;
expect: TSchema;
}
export interface CheckerValidationInput {
defaults: DefaultsConfig;
targets: TargetConfig[];
}
export interface ResolveContext {
configDir: string;
defaultIntervalMs: number;

View File

@@ -16,8 +16,10 @@ export function parseSize(value: number | string): number {
const num = parseFloat(match[1]!);
const unit = match[2]!;
if (unit === "B") return num;
if (unit === "KB") return num * 1024;
if (unit === "MB") return num * 1024 * 1024;
return num * 1024 * 1024 * 1024;
const bytes =
unit === "B" ? num : unit === "KB" ? num * 1024 : unit === "MB" ? num * 1024 * 1024 : num * 1024 * 1024 * 1024;
if (!Number.isInteger(bytes) || bytes < 0 || !Number.isSafeInteger(bytes)) {
throw new Error(`无效的 size 数值: ${value},必须解析为非负安全整数字节数`);
}
return bytes;
}

View File

@@ -49,7 +49,7 @@ export type ExpectConfig = CommandExpectConfig | HttpExpectConfig;
export interface ExpectOperator {
contains?: string;
empty?: boolean;
equals?: boolean | null | number | string;
equals?: JsonValue;
exists?: boolean;
gt?: number;
gte?: number;
@@ -58,7 +58,7 @@ export interface ExpectOperator {
match?: string;
}
export type ExpectValue = boolean | ExpectOperator | null | number | string;
export type ExpectValue = ExpectOperator | JsonValue;
export type HeaderExpect = ExpectOperator | string;
@@ -87,6 +87,8 @@ export interface HttpTargetConfig {
export type JsonRule = ExpectOperator & { path: string };
export type JsonValue = boolean | JsonValue[] | null | number | string | { [key: string]: JsonValue };
export interface ProbeConfig {
defaults?: DefaultsConfig;
runtime?: EngineRuntimeConfig;
@@ -165,9 +167,8 @@ export interface StoredTarget {
export type TargetConfig = BaseTargetConfig &
({ command: CommandTargetConfig; type: "command" } | { http: HttpTargetConfig; type: "http" });
export type TargetType = "command" | "http";
export type { CheckFailure };
export type TargetType = "command" | "http";
export type TextRule = ExpectOperator;
export type XpathRule = ExpectOperator & { path: string };

View File

@@ -0,0 +1,71 @@
import Ajv from "ajv";
import { describe, expect, test } from "bun:test";
import { createProbeConfigJsonSchema } from "../../../../src/server/checker/config-contract/export";
import { formatConfigIssues, issue } from "../../../../src/server/checker/config-contract/issues";
import { validateProbeConfigContract } from "../../../../src/server/checker/config-contract/validate";
import { createDefaultCheckerRegistry } from "../../../../src/server/checker/runner";
describe("config contract", () => {
test("导出的 probe-config.schema.json 与 fragments 生成结果一致", async () => {
const expected = `${JSON.stringify(createProbeConfigJsonSchema(createDefaultCheckerRegistry()), null, 2)}\n`;
const actual = await Bun.file("probe-config.schema.json").text();
expect(actual).toBe(expected);
});
test("导出 schema 拒绝未知字段和小写 HTTP method", () => {
const ajv = new Ajv({
allErrors: true,
coerceTypes: false,
removeAdditional: false,
strict: true,
useDefaults: false,
});
const validate = ajv.compile(createProbeConfigJsonSchema(createDefaultCheckerRegistry()));
expect(
validate({
targets: [
{
http: { method: "get", unknownHttpField: true, url: "https://example.com" },
name: "api",
type: "http",
},
],
}),
).toBe(false);
});
test("Ajv 错误转换为中文结构化 issue", () => {
const result = validateProbeConfigContract(
{
targets: [
{
group: 123,
http: { extra: true },
name: "api",
type: "http",
},
],
unknownRoot: true,
},
createDefaultCheckerRegistry(),
);
expect(result.config).toBeNull();
const message = formatConfigIssues(result.issues);
expect(message).toContain("unknownRoot 是未知字段");
expect(message).toContain('target "api" 的 group 类型不合法');
expect(message).toContain('target "api" 的 http.url 缺少必填字段');
expect(message).toContain('target "api" 的 http.extra 是未知字段');
});
test("ConfigValidationIssue 聚合渲染保留契约和语义错误", () => {
const message = formatConfigIssues([
issue("unknown-field", "targets[0].http.extra", "是未知字段", "api"),
issue("invalid-regex", "targets[0].expect.body[0].regex", "正则不合法", "api"),
]);
expect(message).toBe('target "api" 的 http.extra 是未知字段\ntarget "api" 的 expect.body[0].regex 正则不合法');
});
});

View File

@@ -40,6 +40,11 @@ describe("parseDuration", () => {
expect(parseDuration("1.5s")).toBe(1500);
});
test("拒绝非正整数毫秒结果", () => {
expect(() => parseDuration("0ms")).toThrow("正整数毫秒");
expect(() => parseDuration("1.5ms")).toThrow("正整数毫秒");
});
test("无效格式抛出错误", () => {
expect(() => parseDuration("30")).toThrow("无效的时长格式");
expect(() => parseDuration("abc")).toThrow("无效的时长格式");
@@ -70,6 +75,19 @@ describe("loadConfig", () => {
await rm(tempDir, { force: true, recursive: true });
});
async function expectConfigError(fileName: string, content: string, message: string): Promise<void> {
const configPath = join(tempDir, fileName);
await writeFile(configPath, content);
let error: unknown;
try {
await loadConfig(configPath);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain(message);
}
test("解析最简 HTTP 配置", async () => {
const configPath = join(tempDir, "minimal-http.yaml");
await writeFile(
@@ -310,7 +328,7 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("ignoreSSL 必须为布尔值");
await expect(loadConfig(configPath)).rejects.toThrow("http.ignoreSSL 类型不合法");
});
test("HTTP target maxRedirects 非负整数校验", async () => {
@@ -326,7 +344,7 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("maxRedirects 必须为非负整数");
await expect(loadConfig(configPath)).rejects.toThrow("http.maxRedirects 类型不合法");
});
test("HTTP target status 模式非法抛出错误", async () => {
@@ -413,7 +431,7 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("无效端口号");
await expect(loadConfig(configPath)).rejects.toThrow("server.port 数值范围不合法");
});
test("非法 maxConcurrentChecks 抛出错误", async () => {
@@ -430,7 +448,7 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("maxConcurrentChecks 必须为正整数");
await expect(loadConfig(configPath)).rejects.toThrow("runtime.maxConcurrentChecks 数值范围不合法");
});
test("非法 size 格式抛出错误", async () => {
@@ -623,7 +641,7 @@ targets:
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("group 字段必须为字符串");
await expect(loadConfig(configPath)).rejects.toThrow("group 必须为字符串");
});
test("HTTP headers 非字符串值抛出错误", async () => {
@@ -656,7 +674,7 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("http.body 必须为字符串");
await expect(loadConfig(configPath)).rejects.toThrow("http.body 类型不合法");
});
test("maxBodyBytes 负数抛出错误", async () => {
@@ -930,7 +948,7 @@ targets:
await expect(loadConfig(configPath)).rejects.toThrow("exists 必须为布尔值");
});
test("未知字段忽略不影响启动", async () => {
test("未知字段导致启动失败", async () => {
const configPath = join(tempDir, "unknown-fields.yaml");
await writeFile(
configPath,
@@ -948,12 +966,8 @@ targets:
note: "ignored"
`,
);
const config = await loadConfig(configPath);
expect(config.targets).toHaveLength(1);
const t = config.targets[0]!;
if (t.type === "http") {
expect(t.expect?.status).toEqual([200]);
}
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("unknownHttpField 是未知字段");
});
test("xpath path 非空字符串校验", async () => {
@@ -989,6 +1003,228 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("expect.headers 必须为对象");
await expect(loadConfig(configPath)).rejects.toThrow("expect.headers 类型不合法");
});
test("HTTP method 小写输入失败", async () => {
await expectConfigError(
"lowercase-method.yaml",
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
method: get
`,
"http.method 不在允许范围内",
);
});
test("defaults.http.method 小写输入失败", async () => {
await expectConfigError(
"lowercase-default-method.yaml",
`defaults:
http:
method: post
targets:
- name: "test"
type: http
http:
url: "http://example.com"
`,
"defaults.http.method 不在允许范围内",
);
});
test("HTTP method 大写输入通过", async () => {
const configPath = join(tempDir, "uppercase-method.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
method: POST
`,
);
const config = await loadConfig(configPath);
const target = config.targets[0]!;
expect(target.type).toBe("http");
if (target.type === "http") expect(target.http.method).toBe("POST");
});
test("动态 headers 和 env 允许任意键名", async () => {
const configPath = join(tempDir, "dynamic-maps.yaml");
await writeFile(
configPath,
`defaults:
http:
headers:
X-Default-Header: "default"
targets:
- name: "http-test"
type: http
http:
url: "http://example.com"
headers:
X-Custom-Header: "custom"
expect:
headers:
X-Response-Header:
contains: "ok"
- name: "cmd-test"
type: command
command:
exec: "true"
env:
CUSTOM_ENV_NAME: "custom"
`,
);
const config = await loadConfig(configPath);
const http = config.targets[0]!;
const command = config.targets[1]!;
expect(http.type).toBe("http");
expect(command.type).toBe("command");
if (http.type === "http") {
expect(http.http.headers["X-Default-Header"]).toBe("default");
expect(http.http.headers["X-Custom-Header"]).toBe("custom");
}
if (command.type === "command") expect(command.command.env["CUSTOM_ENV_NAME"]).toBe("custom");
});
test("command args 类型非法", async () => {
await expectConfigError(
"bad-command-args.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
args: "hello"
`,
"command.args 类型不合法",
);
});
test("command cwd 类型非法", async () => {
await expectConfigError(
"bad-command-cwd.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
cwd: 123
`,
"command.cwd 类型不合法",
);
});
test("command env 值类型非法", async () => {
await expectConfigError(
"bad-command-env.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
env:
COUNT: 123
`,
"command.env.COUNT 类型不合法",
);
});
test("command maxOutputBytes 非法", async () => {
await expectConfigError(
"bad-command-max-output.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
maxOutputBytes: "1TB"
`,
"maxOutputBytes 无效的 size 格式",
);
});
test("command expect exitCode 类型非法", async () => {
await expectConfigError(
"bad-command-exit-code.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
expect:
exitCode: [1.5]
`,
"expect.exitCode[0] 类型不合法",
);
});
test("command stdout 空 text rule 非法", async () => {
await expectConfigError(
"bad-command-stdout-empty.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
expect:
stdout:
- {}
`,
"stdout[0] 必须包含至少一个合法 operator",
);
});
test("command stderr 未知 operator 非法", async () => {
await expectConfigError(
"bad-command-stderr-operator.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
expect:
stderr:
- foo: "bar"
`,
"expect.stderr[0].foo 是未知字段",
);
});
test("command stdout match 正则非法", async () => {
await expectConfigError(
"bad-command-stdout-regex.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
expect:
stdout:
- match: "[invalid"
`,
"stdout[0].match 正则不合法",
);
});
test("command expect 未知字段失败", async () => {
await expectConfigError(
"bad-command-expect-unknown.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
expect:
status: [200]
`,
"expect.status 是未知字段",
);
});
});

View File

@@ -3,10 +3,16 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { CheckerContext, ResolveContext } from "../../../../../src/server/checker/runner/types";
import type { ResolvedHttpTarget } from "../../../../../src/server/checker/types";
import { formatConfigIssues } from "../../../../../src/server/checker/config-contract/issues";
import { checkStatus } from "../../../../../src/server/checker/runner/http/expect";
import { HttpChecker } from "../../../../../src/server/checker/runner/http/runner";
const checker = new HttpChecker();
function validateHttpTarget(target: unknown): string {
return formatConfigIssues(checker.validate({ defaults: {}, targets: [target as never] }));
}
const SELF_SIGNED_CERT = `-----BEGIN CERTIFICATE-----
MIIDJTCCAg2gAwIBAgIUTwQU8FzvnvxNYR7mMO0DLcnq+wQwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDUxMjE1NDAyOFoXDTM2MDUw
@@ -561,257 +567,168 @@ describe("HttpChecker", () => {
});
test("6xx 范围模式启动校验失败", () => {
expect(() =>
checker.resolve(
{ expect: { status: ["6xx"] }, http: { url: "https://example.com" }, name: "test", type: "http" },
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("5xx");
const errors = validateHttpTarget({
expect: { status: ["6xx"] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("status 模式必须为 1xx 到 5xx");
});
test("status 数字 99 启动校验失败", () => {
expect(() =>
checker.resolve(
{ expect: { status: [99] }, http: { url: "https://example.com" }, name: "test", type: "http" },
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("100-599");
const errors = validateHttpTarget({
expect: { status: [99] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("100-599");
});
test("body rule 忽略未知字段", () => {
const result = checker.resolve(
{
expect: { body: [{ contains: "ok", note: "ignored" }], status: [200] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
);
expect((result as ResolvedHttpTarget).expect?.body).toEqual([
{ contains: "ok", note: "ignored" },
] as unknown as Array<{ contains: string }>);
test("body rule 未知字段启动失败", () => {
const errors = validateHttpTarget({
expect: { body: [{ contains: "ok", note: "ignored" }], status: [200] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("note 是未知字段");
});
test("body rule 使用 match 字段启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ match: "ok" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("缺少支持的规则类型");
const errors = validateHttpTarget({
expect: { body: [{ match: "ok" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("缺少支持的规则类型");
});
test("非法 regex 启动校验失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ regex: "[invalid" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("regex 正则不合法");
const errors = validateHttpTarget({
expect: { body: [{ regex: "[invalid" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("regex 正则不合法");
});
test("非法 JSONPath 启动校验失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ json: { equals: "ok", path: "status" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
},
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("json.path");
const errors = validateHttpTarget({
expect: { body: [{ json: { equals: "ok", path: "status" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("json.path");
});
test("非法 operator match 启动校验失败", () => {
expect(() =>
checker.resolve(
{
expect: { headers: { "x-test": { match: "[invalid" } } },
http: { url: "https://example.com" },
name: "test",
type: "http",
},
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("match 正则不合法");
const errors = validateHttpTarget({
expect: { headers: { "x-test": { match: "[invalid" } } },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("match 正则不合法");
});
test("非法 operator gte 类型启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ json: { gte: "abc", path: "$.count" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("gte 必须为有限数字");
const errors = validateHttpTarget({
expect: { body: [{ json: { gte: "abc", path: "$.count" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("gte 必须为有限数字");
});
test("非法 operator exists 类型启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ json: { exists: "yes", path: "$.status" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("exists 必须为布尔值");
const errors = validateHttpTarget({
expect: { body: [{ json: { exists: "yes", path: "$.status" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("exists 必须为布尔值");
});
test("纯 operator 空对象启动失败", () => {
const errors = validateHttpTarget({
expect: { headers: { "x-test": {} } },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("必须包含至少一个合法 operator");
});
test("body rule 多个支持字段启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ contains: "ok", regex: "ok" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("只能配置一种规则类型");
const errors = validateHttpTarget({
expect: { body: [{ contains: "ok", regex: "ok" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("只能配置一种规则类型");
});
test("body rule 缺少支持字段启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ foo: "bar" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("缺少支持的规则类型");
const errors = validateHttpTarget({
expect: { body: [{ foo: "bar" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("缺少支持的规则类型");
});
test("css selector 为空启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ css: { selector: "" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("css.selector 必须为非空字符串");
const errors = validateHttpTarget({
expect: { body: [{ css: { selector: "" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("css.selector 必须为非空字符串");
});
test("xpath path 为空启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ xpath: { path: "" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("xpath.path 必须为非空字符串");
const errors = validateHttpTarget({
expect: { body: [{ xpath: { path: "" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("xpath.path 必须为非空字符串");
});
test("expect.headers 非对象启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { headers: "invalid" },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("expect.headers 必须为对象");
test("json rule 允许存在性语义", () => {
const errors = validateHttpTarget({
expect: { body: [{ json: { path: "$.status" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toBe("");
});
test("expect.body 非数组启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: "not-array" },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("expect.body 必须为数组");
});
test("maxDurationMs 负数启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { maxDurationMs: -100 },
http: { url: "https://example.com" },
name: "test",
type: "http",
},
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("maxDurationMs 必须为非负有限数字");
});
test("http.body 非字符串启动失败", () => {
expect(() =>
checker.resolve(
{
http: { body: 123, url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("http.body 必须为字符串");
});
test("http.headers 非字符串值启动失败", () => {
expect(() =>
checker.resolve(
{
http: { headers: { "X-Test": 123 }, url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("http.headers");
});
test("http.headers 非对象启动失败", () => {
expect(() =>
checker.resolve(
{
http: { headers: "invalid", url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("http.headers 必须为对象");
test("equals 支持对象和数组", () => {
const errors = validateHttpTarget({
expect: {
body: [
{ json: { equals: { status: "ok" }, path: "$.payload" } },
{ json: { equals: ["a", "b"], path: "$.items" } },
],
},
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toBe("");
});
});
@@ -825,60 +742,14 @@ describe("HttpChecker.resolve", () => {
};
}
test("method 非法抛出错误", () => {
expect(() =>
checker.resolve(
{ http: { method: "INVALID", url: "https://example.com" }, name: "test", type: "http" },
makeResolveContext(),
),
).toThrow("不合法");
});
test("URL 不以 http(s):// 开头抛出错误", () => {
expect(() =>
checker.resolve({ http: { url: "ftp://example.com" }, name: "test", type: "http" }, makeResolveContext()),
).toThrow("格式不合法");
});
test("maxRedirects 为负数抛出错误", () => {
expect(() =>
checker.resolve(
{ http: { maxRedirects: -1, url: "https://example.com" }, name: "test", type: "http" },
makeResolveContext(),
),
).toThrow("非负整数");
});
test("maxRedirects 非整数抛出错误", () => {
const target = {
http: { maxRedirects: 1.5, url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0];
expect(() => checker.resolve(target, makeResolveContext())).toThrow("非负整数");
});
test("ignoreSSL 非布尔值抛出错误", () => {
const target = {
http: { ignoreSSL: "true", url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0];
expect(() => checker.resolve(target, makeResolveContext())).toThrow("ignoreSSL 必须为布尔值");
});
test("缺少 http 分组抛出清晰错误", () => {
const target = { name: "test", type: "http" } as unknown as Parameters<HttpChecker["resolve"]>[0];
expect(() => checker.resolve(target, makeResolveContext())).toThrow("缺少 http.url 字段");
});
test("expect.status 非法模式抛出错误", () => {
expect(() =>
checker.resolve(
{ expect: { status: ["abc"] }, http: { url: "https://example.com" }, name: "test", type: "http" },
makeResolveContext(),
),
).toThrow("不合法");
const errors = validateHttpTarget({
expect: { status: ["abc"] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("status 模式必须为 1xx 到 5xx");
});
test("ignoreSSL 默认值为 false", () => {
@@ -897,14 +768,6 @@ describe("HttpChecker.resolve", () => {
expect((result as ResolvedHttpTarget).http.maxRedirects).toBe(0);
});
test("method 统一转大写", () => {
const result = checker.resolve(
{ http: { method: "get", url: "https://example.com" }, name: "test", type: "http" },
makeResolveContext(),
);
expect((result as ResolvedHttpTarget).http.method).toBe("GET");
});
test("合法 status 范围模式通过校验", () => {
const result = checker.resolve(
{ expect: { status: ["2xx", 301] }, http: { url: "https://example.com" }, name: "test", type: "http" },

View File

@@ -1,16 +1,25 @@
import { Type } from "@sinclair/typebox";
import { describe, expect, test } from "bun:test";
import type { Checker } from "../../../../src/server/checker/runner/types";
import type { CheckResult, ResolvedTarget } from "../../../../src/server/checker/types";
import { createDefaultCheckerRegistry } from "../../../../src/server/checker/runner";
import { CheckerRegistry } from "../../../../src/server/checker/runner/registry";
function createChecker(type: string): Checker {
return {
configKey: type,
execute: () => Promise.resolve<CheckResult>({} as unknown as CheckResult),
resolve: () => ({}) as unknown as ResolvedTarget,
schemas: {
config: Type.Object({}, { additionalProperties: false }),
defaults: Type.Object({}, { additionalProperties: false }),
expect: Type.Object({}, { additionalProperties: false }),
},
serialize: () => ({ config: "", target: "" }),
type,
validate: () => [],
};
}
@@ -39,4 +48,30 @@ describe("CheckerRegistry", () => {
registry.register(createChecker("command"));
expect(registry.supportedTypes).toEqual(["http", "command"]);
});
test("definitions 返回注册定义", () => {
const registry = new CheckerRegistry();
const checker = createChecker("http");
registry.register(checker);
expect(registry.definitions).toEqual([checker]);
});
test("tryGet 未注册返回 undefined", () => {
const registry = new CheckerRegistry();
expect(registry.tryGet("missing")).toBeUndefined();
});
test("默认 registry 创建 fresh 实例且互不污染", () => {
const first = createDefaultCheckerRegistry();
const second = createDefaultCheckerRegistry();
first.register(createChecker("custom"));
expect(first.supportedTypes).toEqual(["http", "command", "custom"]);
expect(second.supportedTypes).toEqual(["http", "command"]);
expect(
first.definitions.every(
(checker) => checker.schemas.config && checker.schemas.defaults && checker.schemas.expect,
),
).toBe(true);
});
});

View File

@@ -69,6 +69,13 @@ describe("applyOperator", () => {
expect(applyOperator(true, { equals: true })).toBe(true);
});
test("equals 支持 JSON 对象和数组", () => {
expect(applyOperator({ status: "ok" }, { equals: { status: "ok" } })).toBe(true);
expect(applyOperator({ status: "ok" }, { equals: { status: "fail" } })).toBe(false);
expect(applyOperator(["a", "b"], { equals: ["a", "b"] })).toBe(true);
expect(applyOperator(["a", "b"], { equals: ["b", "a"] })).toBe(false);
});
test("contains 操作符", () => {
expect(applyOperator("hello world", { contains: "hello" })).toBe(true);
expect(applyOperator("hello world", { contains: "missing" })).toBe(false);

View File

@@ -24,6 +24,7 @@ describe("parseSize", () => {
test("解析小数", () => {
expect(parseSize("1.5MB")).toBe(1572864);
expect(parseSize("1.5KB")).toBe(1536);
});
test("数字直接返回", () => {
@@ -48,4 +49,8 @@ describe("parseSize", () => {
expect(() => parseSize("abc")).toThrow("无效的 size 格式");
expect(() => parseSize("")).toThrow("无效的 size 格式");
});
test("字符串解析为非整数字节时抛出错误", () => {
expect(() => parseSize("1.5B")).toThrow("非负安全整数字节数");
});
});