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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -408,6 +408,7 @@ temp
|
||||
.agents
|
||||
skills-lock.json
|
||||
.worktrees
|
||||
data/
|
||||
!scripts/build/
|
||||
backend/bin
|
||||
backend/server
|
||||
|
||||
@@ -10,3 +10,4 @@ bun.lock
|
||||
.agents/
|
||||
skills-lock.json
|
||||
data/
|
||||
probe-config.schema.json
|
||||
|
||||
453
DEVELOPMENT.md
453
DEVELOPMENT.md
@@ -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 HttpChecker(resolve/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 CommandChecker(resolve/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 契约 schema(config / defaults / expect 三部分) |
|
||||
| `validate.ts` | 启动期语义校验(JSON Schema 无法表达的规则) |
|
||||
| `runner.ts` | Checker 类:resolve(默认值合并 + 解析)、execute(执行检查)、serialize(DB 持久化) |
|
||||
| `expect.ts` | Checker 专用断言函数 |
|
||||
|
||||
#### 1.7.2 步骤一:定义类型
|
||||
|
||||
在 `src/server/checker/types.ts` 中添加 checker 专属类型接口,并更新联合类型:
|
||||
|
||||
```typescript
|
||||
// 1. 添加 TargetConfig(YAML 中 target.tcp 字段的原始类型)
|
||||
export interface TcpTargetConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
// 2. 添加 ExpectConfig 扩展(如果 checker 有专属 expect 字段)
|
||||
export interface TcpExpectConfig {
|
||||
connected?: boolean;
|
||||
}
|
||||
|
||||
// 3. 添加 DefaultsConfig(defaults.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 Schema(defaults.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_id(FK CASCADE)、timestamp、matched(0/1)、duration_ms、status_detail、failure(JSON)
|
||||
- 复合索引:`(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()` 写入 SQLite,engine 通过 `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` 是日常开发推荐的质量检查命令。
|
||||
|
||||
10
README.md
10
README.md
@@ -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`
|
||||
|
||||
|
||||
16
bun.lock
16
bun.lock
@@ -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=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()` 中的类型分支。
|
||||
|
||||
@@ -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 包含未知字段
|
||||
|
||||
@@ -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 至少包含一个已知 operator;body 提取规则可以不配置 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。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
724
probe-config.schema.json
Normal 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": {}
|
||||
}
|
||||
16
scripts/generate-config-schema.ts
Normal file
16
scripts/generate-config-schema.ts
Normal 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);
|
||||
}
|
||||
7
src/server/checker/config-contract/export.ts
Normal file
7
src/server/checker/config-contract/export.ts
Normal 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);
|
||||
}
|
||||
98
src/server/checker/config-contract/fragments.ts
Normal file
98
src/server/checker/config-contract/fragments.ts
Normal 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()),
|
||||
};
|
||||
}
|
||||
37
src/server/checker/config-contract/issues.ts
Normal file
37
src/server/checker/config-contract/issues.ts
Normal 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}`;
|
||||
}
|
||||
89
src/server/checker/config-contract/schema.ts
Normal file
89
src/server/checker/config-contract/schema.ts
Normal 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[]]);
|
||||
}
|
||||
13
src/server/checker/config-contract/types.ts
Normal file
13
src/server/checker/config-contract/types.ts
Normal 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;
|
||||
}
|
||||
145
src/server/checker/config-contract/validate.ts
Normal file
145
src/server/checker/config-contract/validate.ts
Normal 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"];
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
34
src/server/checker/runner/command/contract.ts
Normal file
34
src/server/checker/runner/command/contract.ts
Normal 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 },
|
||||
),
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
93
src/server/checker/runner/command/validate.ts
Normal file
93
src/server/checker/runner/command/validate.ts
Normal 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)];
|
||||
}
|
||||
}
|
||||
44
src/server/checker/runner/http/contract.ts
Normal file
44
src/server/checker/runner/http/contract.ts
Normal 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 },
|
||||
),
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
223
src/server/checker/runner/shared/validate.ts
Normal file
223
src/server/checker/runner/shared/validate.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
71
tests/server/checker/config-contract/validate.test.ts
Normal file
71
tests/server/checker/config-contract/validate.test.ts
Normal 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 正则不合法');
|
||||
});
|
||||
});
|
||||
@@ -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 是未知字段",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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("非负安全整数字节数");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user