From 7b20b59b7952868d2dfa20f1810aa5649d1e5416 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Wed, 13 May 2026 12:19:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E4=B8=BA=20TypeBox=20+=20Ajv=20+=20semantic?= =?UTF-8?q?=20validator=EF=BC=8C=E4=B8=A5=E6=A0=BC=E7=A6=81=E6=AD=A2?= =?UTF-8?q?=E6=9C=AA=E7=9F=A5=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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) --- .gitignore | 1 + .prettierignore | 1 + DEVELOPMENT.md | 453 ++++++++++- README.md | 10 +- bun.lock | 16 +- .../specs/checker-runner-abstraction/spec.md | 75 +- openspec/specs/command-checker/spec.md | 59 ++ openspec/specs/expect-body-checkers/spec.md | 34 +- openspec/specs/probe-config/spec.md | 62 +- package.json | 6 +- probe-config.schema.json | 724 ++++++++++++++++++ scripts/generate-config-schema.ts | 16 + src/server/checker/config-contract/export.ts | 7 + .../checker/config-contract/fragments.ts | 98 +++ src/server/checker/config-contract/issues.ts | 37 + src/server/checker/config-contract/schema.ts | 89 +++ src/server/checker/config-contract/types.ts | 13 + .../checker/config-contract/validate.ts | 145 ++++ src/server/checker/config-loader.ts | 167 +++- src/server/checker/runner/command/contract.ts | 34 + src/server/checker/runner/command/runner.ts | 18 +- src/server/checker/runner/command/validate.ts | 93 +++ src/server/checker/runner/http/contract.ts | 44 ++ src/server/checker/runner/http/runner.ts | 55 +- src/server/checker/runner/http/validate.ts | 350 +++------ src/server/checker/runner/index.ts | 14 +- src/server/checker/runner/registry.ts | 16 +- src/server/checker/runner/shared/operator.ts | 8 +- src/server/checker/runner/shared/validate.ts | 223 ++++++ src/server/checker/runner/types.ts | 31 +- src/server/checker/size.ts | 10 +- src/server/checker/types.ts | 9 +- .../checker/config-contract/validate.test.ts | 71 ++ tests/server/checker/config-loader.test.ts | 264 ++++++- .../server/checker/runner/http/runner.test.ts | 409 ++++------ tests/server/checker/runner/registry.test.ts | 35 + .../checker/runner/shared/operator.test.ts | 7 + tests/server/checker/size.test.ts | 5 + 38 files changed, 3034 insertions(+), 675 deletions(-) create mode 100644 probe-config.schema.json create mode 100644 scripts/generate-config-schema.ts create mode 100644 src/server/checker/config-contract/export.ts create mode 100644 src/server/checker/config-contract/fragments.ts create mode 100644 src/server/checker/config-contract/issues.ts create mode 100644 src/server/checker/config-contract/schema.ts create mode 100644 src/server/checker/config-contract/types.ts create mode 100644 src/server/checker/config-contract/validate.ts create mode 100644 src/server/checker/runner/command/contract.ts create mode 100644 src/server/checker/runner/command/validate.ts create mode 100644 src/server/checker/runner/http/contract.ts create mode 100644 src/server/checker/runner/shared/validate.ts create mode 100644 tests/server/checker/config-contract/validate.test.ts diff --git a/.gitignore b/.gitignore index 593b2cd..2a1e03c 100644 --- a/.gitignore +++ b/.gitignore @@ -408,6 +408,7 @@ temp .agents skills-lock.json .worktrees +data/ !scripts/build/ backend/bin backend/server diff --git a/.prettierignore b/.prettierignore index 2226868..d578adf 100644 --- a/.prettierignore +++ b/.prettierignore @@ -10,3 +10,4 @@ bun.lock .agents/ skills-lock.json data/ +probe-config.schema.json diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index fae0e27..fe2bc07 100644 --- a/DEVELOPMENT.md +++ b/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//` 下的自包含模块,包含四个文件: + +| 文件 | 职责 | +| ------------- | ------------------------------------------------------------------------------------- | +| `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`(用于 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 { + 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: }`,状态码 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` 是日常开发推荐的质量检查命令。 diff --git a/README.md b/README.md index 87e8aaa..994e081 100644 --- a/README.md +++ b/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` diff --git a/bun.lock b/bun.lock index cbf8386..2f9aa93 100644 --- a/bun.lock +++ b/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=="], } } diff --git a/openspec/specs/checker-runner-abstraction/spec.md b/openspec/specs/checker-runner-abstraction/spec.md index ca37793..1064cc8 100644 --- a/openspec/specs/checker-runner-abstraction/spec.md +++ b/openspec/specs/checker-runner-abstraction/spec.md @@ -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()` 中的类型分支。 diff --git a/openspec/specs/command-checker/spec.md b/openspec/specs/command-checker/spec.md index 50d7124..a00270d 100644 --- a/openspec/specs/command-checker/spec.md +++ b/openspec/specs/command-checker/spec.md @@ -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 包含未知字段 diff --git a/openspec/specs/expect-body-checkers/spec.md b/openspec/specs/expect-body-checkers/spec.md index 2ee03c0..bc41ae8 100644 --- a/openspec/specs/expect-body-checkers/spec.md +++ b/openspec/specs/expect-body-checkers/spec.md @@ -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。 diff --git a/openspec/specs/probe-config/spec.md b/openspec/specs/probe-config/spec.md index c0797a5..3689a4b 100644 --- a/openspec/specs/probe-config/spec.md +++ b/openspec/specs/probe-config/spec.md @@ -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 diff --git a/package.json b/package.json index 3404221..f8cbe37 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/probe-config.schema.json b/probe-config.schema.json new file mode 100644 index 0000000..67dcc46 --- /dev/null +++ b/probe-config.schema.json @@ -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": {} +} diff --git a/scripts/generate-config-schema.ts b/scripts/generate-config-schema.ts new file mode 100644 index 0000000..34c2e67 --- /dev/null +++ b/scripts/generate-config-schema.ts @@ -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); +} diff --git a/src/server/checker/config-contract/export.ts b/src/server/checker/config-contract/export.ts new file mode 100644 index 0000000..80722ac --- /dev/null +++ b/src/server/checker/config-contract/export.ts @@ -0,0 +1,7 @@ +import type { CheckerRegistry } from "../runner/registry"; + +import { createExternalProbeConfigSchema } from "./schema"; + +export function createProbeConfigJsonSchema(registry: CheckerRegistry): Record { + return createExternalProbeConfigSchema(registry.definitions); +} diff --git a/src/server/checker/config-contract/fragments.ts b/src/server/checker/config-contract/fragments.ts new file mode 100644 index 0000000..ebde9fa --- /dev/null +++ b/src/server/checker/config-contract/fragments.ts @@ -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({ + 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>({ + 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>({ + 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 { + 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()), + }; +} diff --git a/src/server/checker/config-contract/issues.ts b/src/server/checker/config-contract/issues.ts new file mode 100644 index 0000000..2b56de1 --- /dev/null +++ b/src/server/checker/config-contract/issues.ts @@ -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}`; +} diff --git a/src/server/checker/config-contract/schema.ts b/src/server/checker/config-contract/schema.ts new file mode 100644 index 0000000..acd8c50 --- /dev/null +++ b/src/server/checker/config-contract/schema.ts @@ -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 { + 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 = { + 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 { + return JSON.parse(JSON.stringify(schema)) as Record; +} + +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 = { + 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[]]); +} diff --git a/src/server/checker/config-contract/types.ts b/src/server/checker/config-contract/types.ts new file mode 100644 index 0000000..1f027d3 --- /dev/null +++ b/src/server/checker/config-contract/types.ts @@ -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; +} diff --git a/src/server/checker/config-contract/validate.ts b/src/server/checker/config-contract/validate.ts new file mode 100644 index 0000000..37a0aca --- /dev/null +++ b/src/server/checker/config-contract/validate.ts @@ -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): boolean { + return ["const", "enum", "maximum", "minimum", "minLength", "pattern"].some((keyword) => keywords.has(keyword)); +} + +function isRecord(value: unknown): value is Record { + 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>(); + + for (const error of candidates) { + const path = buildIssuePath(basePath, error); + const keywords = keywordsByPath.get(path) ?? new Set(); + keywords.add(error.keyword); + keywordsByPath.set(path, keywords); + } + + const seenValueErrors = new Set(); + return candidates.filter((error) => { + const path = buildIssuePath(basePath, error); + const keywords = keywordsByPath.get(path) ?? new Set(); + 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"]; +} diff --git a/src/server/checker/config-loader.ts b/src/server/checker/config-loader.ts index cbd8f85..485aa08 100644 --- a/src/server/checker/config-loader.ts +++ b/src/server/checker/config-loader.ts @@ -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 { } 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(); const supportedTypes = checkerRegistry.supportedTypes; for (let i = 0; i < config.targets.length; i++) { - const raw = config.targets[i] as unknown as Record; + 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(); + 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 { + 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)); + } } diff --git a/src/server/checker/runner/command/contract.ts b/src/server/checker/runner/command/contract.ts new file mode 100644 index 0000000..556816d --- /dev/null +++ b/src/server/checker/runner/command/contract.ts @@ -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 }, + ), +}; diff --git a/src/server/checker/runner/command/runner.ts b/src/server/checker/runner/command/runner.ts index e47ae0b..2eb4787 100644 --- a/src/server/checker/runner/command/runner.ts +++ b/src/server/checker/runner/command/runner.ts @@ -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 { @@ -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( diff --git a/src/server/checker/runner/command/validate.ts b/src/server/checker/runner/command/validate.ts new file mode 100644 index 0000000..368aa6b --- /dev/null +++ b/src/server/checker/runner/command/validate.ts @@ -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 | 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 { + 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, 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, 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)]; + } +} diff --git a/src/server/checker/runner/http/contract.ts b/src/server/checker/runner/http/contract.ts new file mode 100644 index 0000000..7d54582 --- /dev/null +++ b/src/server/checker/runner/http/contract.ts @@ -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 }, + ), +}; diff --git a/src/server/checker/runner/http/runner.ts b/src/server/checker/runner/http/runner.ts index 16b535f..1214a7d 100644 --- a/src/server/checker/runner/http/runner.ts +++ b/src/server/checker/runner/http/runner.ts @@ -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 { @@ -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 { diff --git a/src/server/checker/runner/http/validate.ts b/src/server/checker/runner/http/validate.ts index 4219e20..acdd335 100644 --- a/src/server/checker/runner/http/validate.ts +++ b/src/server/checker/runner/http/validate.ts @@ -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; + 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 | 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 { + 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, 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)) { - 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, 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; - - 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)) { - if (typeof value === "string") continue; - if (typeof value === "object" && value !== null && !Array.isArray(value)) { - validateOperators(value as Record, 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, 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; - 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; - if (typeof cr["selector"] !== "string" || cr["selector"].trim() === "") { - throw new Error(`target "${targetName}" 的 ${rulePath}.css.selector 必须为非空字符串`); - } - const cssOps: Record = {}; - 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; - 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 = {}; - 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; - if (typeof xr["path"] !== "string" || xr["path"].trim() === "") { - throw new Error(`target "${targetName}" 的 ${rulePath}.xpath.path 必须为非空字符串`); - } - try { - const doc = new DOMParser().parseFromString("", "text/xml"); - xpath.select(xr["path"], doc as unknown as Node); - } catch { - throw new Error(`target "${targetName}" 的 ${rulePath}.xpath.path xpath 不合法`); - } - const xpathOps: Record = {}; - 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; } diff --git a/src/server/checker/runner/index.ts b/src/server/checker/runner/index.ts index bd96794..61a9cef 100644 --- a/src/server/checker/runner/index.ts +++ b/src/server/checker/runner/index.ts @@ -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"; diff --git a/src/server/checker/runner/registry.ts b/src/server/checker/runner/registry.ts index 30ba135..a892142 100644 --- a/src/server/checker/runner/registry.ts +++ b/src/server/checker/runner/registry.ts @@ -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(); + private checkers = new Map(); - 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(); diff --git a/src/server/checker/runner/shared/operator.ts b/src/server/checker/runner/shared/operator.ts index 0bd4fb2..6613153 100644 --- a/src/server/checker/runner/shared/operator.ts +++ b/src/server/checker/runner/shared/operator.ts @@ -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 }); } export function evaluateJsonPath(json: unknown, path: string): unknown { diff --git a/src/server/checker/runner/shared/validate.ts b/src/server/checker/runner/shared/validate.ts new file mode 100644 index 0000000..612503a --- /dev/null +++ b/src/server/checker/runner/shared/validate.ts @@ -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(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).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, + allowedKeys: Set, + path: string, + targetName?: string, +): { issues: ConfigValidationIssue[]; operators: Record } { + const issues: ConfigValidationIssue[] = []; + const operators: Record = {}; + 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 { + 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("", "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; +} diff --git a/src/server/checker/runner/types.ts b/src/server/checker/runner/types.ts index e1bc3ac..2bdfef0 100644 --- a/src/server/checker/runner/types.ts +++ b/src/server/checker/runner/types.ts @@ -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; - 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; + 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; diff --git a/src/server/checker/size.ts b/src/server/checker/size.ts index 2f2f505..363479c 100644 --- a/src/server/checker/size.ts +++ b/src/server/checker/size.ts @@ -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; } diff --git a/src/server/checker/types.ts b/src/server/checker/types.ts index c8fea5c..0d0eb20 100644 --- a/src/server/checker/types.ts +++ b/src/server/checker/types.ts @@ -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 }; diff --git a/tests/server/checker/config-contract/validate.test.ts b/tests/server/checker/config-contract/validate.test.ts new file mode 100644 index 0000000..be0835c --- /dev/null +++ b/tests/server/checker/config-contract/validate.test.ts @@ -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 正则不合法'); + }); +}); diff --git a/tests/server/checker/config-loader.test.ts b/tests/server/checker/config-loader.test.ts index f1b2a5b..8c74fe9 100644 --- a/tests/server/checker/config-loader.test.ts +++ b/tests/server/checker/config-loader.test.ts @@ -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 { + 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 是未知字段", + ); }); }); diff --git a/tests/server/checker/runner/http/runner.test.ts b/tests/server/checker/runner/http/runner.test.ts index 8eadbaf..cd19e05 100644 --- a/tests/server/checker/runner/http/runner.test.ts +++ b/tests/server/checker/runner/http/runner.test.ts @@ -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[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[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[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[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[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[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[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[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[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[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[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[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[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[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[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[0]; - expect(() => checker.resolve(target, makeResolveContext())).toThrow("ignoreSSL 必须为布尔值"); - }); - - test("缺少 http 分组抛出清晰错误", () => { - const target = { name: "test", type: "http" } as unknown as Parameters[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" }, diff --git a/tests/server/checker/runner/registry.test.ts b/tests/server/checker/runner/registry.test.ts index 090fa46..b7d1d4f 100644 --- a/tests/server/checker/runner/registry.test.ts +++ b/tests/server/checker/runner/registry.test.ts @@ -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({} 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); + }); }); diff --git a/tests/server/checker/runner/shared/operator.test.ts b/tests/server/checker/runner/shared/operator.test.ts index 31e52e6..7e7d774 100644 --- a/tests/server/checker/runner/shared/operator.test.ts +++ b/tests/server/checker/runner/shared/operator.test.ts @@ -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); diff --git a/tests/server/checker/size.test.ts b/tests/server/checker/size.test.ts index 4fa562c..9fcf8e9 100644 --- a/tests/server/checker/size.test.ts +++ b/tests/server/checker/size.test.ts @@ -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("非负安全整数字节数"); + }); });