From bb6b2bc20b1d0151683f970c5002820b6b698490 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Wed, 13 May 2026 14:38:21 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20checker=20=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E5=86=85=E8=81=9A=E5=8C=96=20=E2=80=94=20=E6=AF=8F=E4=B8=AA=20?= =?UTF-8?q?checker=20=E8=87=AA=E5=8C=85=E5=90=AB=E4=BA=8E=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 checker 架构重构为完全内聚模式:每个 checker 目录包含自身的 types、schema、validate、execute、expect 和 index,新增 checker 只需创建一个目录并在 runner/index.ts 添加一行注册。 主要变更: - runner/shared/ 拆分:断言基础设施迁入 checker/expect/, body.ts 迁入 http/,text.ts 迁入 command/ - config-contract/ 重命名为 schema/,schema.ts → builder.ts - size.ts + parseDuration 合并为 utils.ts - 顶层 types.ts 改为 base interface + index signature, checker 专属类型下沉到各自 types.ts - runner/index.ts 改为显式数组注册模式 - 更新 DEVELOPMENT.md 项目结构和开发新 Checker 指南 --- DEVELOPMENT.md | 349 ++++++++---------- .../refactor-checker-cohesion/tasks.md | 72 ++-- scripts/generate-config-schema.ts | 2 +- src/server/checker/config-loader.ts | 79 ++-- src/server/checker/engine.ts | 10 +- .../{runner/shared => expect}/duration.ts | 7 +- .../{runner/shared => expect}/failure.ts | 2 +- .../{runner/shared => expect}/operator.ts | 2 +- src/server/checker/expect/types.ts | 6 + .../checker/expect/validate-operator.ts | 80 ++++ .../runner/command/{runner.ts => execute.ts} | 31 +- src/server/checker/runner/command/expect.ts | 4 +- src/server/checker/runner/command/index.ts | 1 + .../runner/command/{contract.ts => schema.ts} | 2 +- .../runner/{shared => command}/text.ts | 8 +- src/server/checker/runner/command/types.ts | 41 ++ src/server/checker/runner/command/validate.ts | 13 +- .../checker/runner/{shared => http}/body.ts | 8 +- .../runner/http/{runner.ts => execute.ts} | 27 +- src/server/checker/runner/http/expect.ts | 8 +- src/server/checker/runner/http/index.ts | 1 + .../runner/http/{contract.ts => schema.ts} | 2 +- src/server/checker/runner/http/types.ts | 59 +++ src/server/checker/runner/http/validate.ts | 163 +++++++- src/server/checker/runner/index.ts | 19 +- src/server/checker/runner/registry.ts | 2 - src/server/checker/runner/shared/validate.ts | 223 ----------- src/server/checker/runner/types.ts | 12 +- .../schema.ts => schema/builder.ts} | 0 .../{config-contract => schema}/export.ts | 2 +- .../{config-contract => schema}/fragments.ts | 0 .../{config-contract => schema}/issues.ts | 0 .../{config-contract => schema}/types.ts | 0 .../{config-contract => schema}/validate.ts | 2 +- src/server/checker/store.ts | 4 +- src/server/checker/types.ts | 124 +------ src/server/checker/{size.ts => utils.ts} | 18 + src/server/dev.ts | 3 - tests/server/app.test.ts | 4 +- .../checker/config-contract/validate.test.ts | 6 +- tests/server/checker/config-loader.test.ts | 121 +++--- tests/server/checker/engine.test.ts | 46 +-- .../checker/runner/command/runner.test.ts | 4 +- .../server/checker/runner/http/runner.test.ts | 6 +- tests/server/checker/runner/registry.test.ts | 4 +- .../server/checker/runner/shared/body.test.ts | 2 +- .../checker/runner/shared/duration.test.ts | 2 +- .../checker/runner/shared/failure.test.ts | 2 +- .../checker/runner/shared/operator.test.ts | 6 +- .../server/checker/runner/shared/text.test.ts | 2 +- tests/server/checker/size.test.ts | 2 +- tests/server/checker/store.test.ts | 16 +- 52 files changed, 789 insertions(+), 820 deletions(-) rename src/server/checker/{runner/shared => expect}/duration.ts (79%) rename src/server/checker/{runner/shared => expect}/failure.ts (93%) rename src/server/checker/{runner/shared => expect}/operator.ts (97%) create mode 100644 src/server/checker/expect/types.ts create mode 100644 src/server/checker/expect/validate-operator.ts rename src/server/checker/runner/command/{runner.ts => execute.ts} (87%) create mode 100644 src/server/checker/runner/command/index.ts rename src/server/checker/runner/command/{contract.ts => schema.ts} (96%) rename src/server/checker/runner/{shared => command}/text.ts (68%) create mode 100644 src/server/checker/runner/command/types.ts rename src/server/checker/runner/{shared => http}/body.ts (95%) rename src/server/checker/runner/http/{runner.ts => execute.ts} (90%) create mode 100644 src/server/checker/runner/http/index.ts rename src/server/checker/runner/http/{contract.ts => schema.ts} (96%) create mode 100644 src/server/checker/runner/http/types.ts delete mode 100644 src/server/checker/runner/shared/validate.ts rename src/server/checker/{config-contract/schema.ts => schema/builder.ts} (100%) rename src/server/checker/{config-contract => schema}/export.ts (78%) rename src/server/checker/{config-contract => schema}/fragments.ts (100%) rename src/server/checker/{config-contract => schema}/issues.ts (100%) rename src/server/checker/{config-contract => schema}/types.ts (100%) rename src/server/checker/{config-contract => schema}/validate.ts (98%) rename src/server/checker/{size.ts => utils.ts} (58%) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 848461f..4628c36 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -34,33 +34,44 @@ src/ history.ts GET /api/targets/:id/history trend.ts GET /api/targets/:id/trend checker/ - types.ts 类型定义 + types.ts 基础类型定义(ResolvedTargetBase、RawTargetConfig、DefaultsConfig 等 base interface) config-loader.ts YAML 配置解析、契约校验、语义校验与运行期解析 - config-contract/ TypeBox + Ajv 配置契约、schema fragments、issue 渲染和 schema 导出入口 + schema/ TypeBox + Ajv 配置契约、schema fragments、issue 渲染和 schema 导出入口 + builder.ts 全量 JSON Schema 组装(遍历 registry 生成) + fragments.ts 共享 TypeBox schema 片段(duration、size、operator 等) + validate.ts Ajv 契约校验入口 + issues.ts 校验问题类型与渲染 + types.ts schema 层类型 + export.ts JSON Schema 文件导出 store.ts SQLite 数据存储 engine.ts 调度引擎(按 interval 分组的 es-toolkit groupBy + Semaphore 并发控制) - size.ts 大小单位解析 + utils.ts 共享工具函数(parseSize、parseDuration) + expect/ 共享 expect 断言基础设施(跨 checker 复用) + types.ts ExpectResult 等共享断言类型 + failure.ts 失败信息构造(errorFailure、mismatchFailure) + operator.ts 操作符系统(applyOperator、checkExpectValue、evaluateJsonPath) + duration.ts 耗时断言(checkDuration) + validate-operator.ts 操作符语义校验(validateOperatorObject、isJsonValue) runner/ Checker 统一抽象与注册机制 - types.ts CheckerDefinition、CheckerContext、ResolveContext + types.ts CheckerDefinition、CheckerContext、CheckerSchemas、ResolveContext registry.ts CheckerRegistry 注册中心 - index.ts 注册入口(registerCheckers) - shared/ 共享 expect 断言和启动期 validator(跨 checker 复用) - failure.ts 失败信息类型 - operator.ts 操作符系统(applyOperator、evaluateJsonPath) - duration.ts 耗时断言 - text.ts 文本规则断言 + index.ts 注册入口(显式数组 + 循环注册) + http/ HTTP Checker(自包含模块) + index.ts 模块入口(re-export HttpChecker) + types.ts HTTP 专属类型(ResolvedHttpTarget、HttpTargetConfig、HttpExpectConfig 等) + schema.ts HTTP TypeBox 契约(defaults、target.http、expect) + execute.ts HttpChecker 类(resolve/execute/serialize/validate) + expect.ts HTTP 专用断言(checkStatus、checkHeaders) + validate.ts HTTP 语义校验(URL、body rules、header operators 等) 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 专属启动期语义校验 - command/ Command Checker 子包 - contract.ts Command defaults、target.command、expect TypeBox 契约 - runner.ts CommandChecker(resolve/execute/serialize) - expect.ts Command 专用断言(exitCode) - validate.ts Command 专属启动期语义校验 + command/ Command Checker(自包含模块) + index.ts 模块入口(re-export CommandChecker) + types.ts Command 专属类型(ResolvedCommandTarget、CommandTargetConfig、CommandExpectConfig 等) + schema.ts Command TypeBox 契约(defaults、target.command、expect) + execute.ts CommandChecker 类(resolve/execute/serialize/validate) + expect.ts Command 专用断言(checkExitCode) + validate.ts Command 语义校验(text rules 等) + text.ts 文本规则断言(checkTextRules) shared/ api.ts 前后端共享 TypeScript 类型 web/ Vite + React 前端 Dashboard @@ -149,15 +160,19 @@ 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 类型分离 -- 配置类型按生命周期区分:YAML 解析后的 `RawProbeConfig`、已通过契约与语义校验的 `ValidatedProbeConfig`、运行期使用的 `ResolvedConfig`/`ResolvedTarget` +- **Checker 类型分层**: + - `checker/types.ts` 定义 base interface(`ResolvedTargetBase`、`RawTargetConfig`、`DefaultsConfig`),使用 index signature 支持扩展 + - 各 checker 在自己的 `types.ts` 中定义具体类型(如 `ResolvedHttpTarget`、`ResolvedCommandTarget`),满足 base interface 约束 + - 中间层(engine、store、config-loader)只依赖 base interface,不感知具体 checker 类型 + - Checker 内部通过 `as` 类型断言将 base 窄化为具体类型 ### 1.6 配置契约与校验 配置加载流程固定为:`unknown -> RawProbeConfig -> ValidatedProbeConfig -> ResolvedConfig`。 -`config-loader.ts` 只负责 YAML 解析、契约调度、公共语义校验和最终运行期解析;checker 专属规则必须下沉到对应 checker 的 `contract.ts` 和 `validate.ts`。 +`config-loader.ts` 只负责 YAML 解析、契约调度、公共语义校验和最终运行期解析;checker 专属规则必须下沉到对应 checker 的 `schema.ts` 和 `validate.ts`。 -契约层使用 `src/server/checker/config-contract/` 中的 TypeBox fragments 生成 JSON Schema,并用 Ajv 执行启动期校验。Ajv 必须保持严格拒绝模式:`allErrors: true`、不启用类型强制转换、不注入默认值、不自动删除未知字段。 +契约层使用 `src/server/checker/schema/` 中的 TypeBox fragments 生成 JSON Schema,并用 Ajv 执行启动期校验。Ajv 必须保持严格拒绝模式:`allErrors: true`、不启用类型强制转换、不注入默认值、不自动删除未知字段。 默认对象策略是 `additionalProperties: false`。只有明确声明的动态键值表可以开放任意键名,例如 `http.headers`、`defaults.http.headers`、`expect.headers`、`command.env`。 @@ -167,9 +182,7 @@ export function handleXxx(params, store: ProbeStore, method: string, mode: Runti ### 1.7 开发新 Checker -Checker 是本项目的核心扩展单元。得益于插件式注册架构,完成一个新 checker 并注册后,**配置契约组装、引擎调度、数据存储、API 层会自动走 registry 委托链路**,无需在这些中间层添加新的 type 分支。 - -当前 checker 执行链路已经注册化,但新增 checker 仍需更新中央类型定义、默认注册入口、前端展示常量、配置示例、用户/开发文档和测试。下文清单以这些必要更新为准。 +Checker 是本项目的核心扩展单元。架构设计目标是**完全内聚**:每个 checker 是 `src/server/checker/runner//` 下的自包含目录,包含该 checker 所需的全部类型、schema、校验、执行逻辑和断言。新增一个 checker 只需创建一个目录并在 `runner/index.ts` 中添加一行注册。 以下以新增 `tcp` 类型 checker 为例,说明完整的开发步骤。 @@ -178,108 +191,74 @@ Checker 是本项目的核心扩展单元。得益于插件式注册架构,完 ``` checkerRegistry(单例) │ - ├── registerCheckers() ← 注册入口,所有 checker 在此集中注册 - │ ├── HttpChecker - │ ├── CommandChecker - │ └── TcpChecker ← 新增 + ├── runner/index.ts ← 显式数组注册,新增 checker 只需一行 + │ ├── new HttpChecker() + │ ├── new CommandChecker() + │ └── new TcpChecker() ← 新增 │ - ├── config-contract/schema.ts ← 自动遍历 registry 生成全量 JSON Schema - ├── config-loader.ts ← 自动遍历 registry 调用 validate() + resolve() - ├── engine.ts ← 自动按 target.type 分发到 execute() - └── store.ts ← 自动按 target.type 分发到 serialize() + ├── schema/builder.ts ← 自动遍历 registry 生成全量 JSON Schema + ├── schema/validate.ts ← 自动遍历 registry 构建 Ajv 校验 + ├── config-loader.ts ← 自动遍历 registry 调用 validate() + resolve() + ├── engine.ts ← 自动按 target.type 分发到 execute() + └── store.ts ← 自动按 target.type 分发到 serialize() ``` -每个 checker 是 `src/server/checker/runner//` 下的自包含模块,包含四个文件: +每个 checker 目录的标准文件结构: | 文件 | 职责 | | ------------- | ------------------------------------------------------------------------------------- | -| `contract.ts` | TypeBox 契约 schema(config / defaults / expect 三部分) | +| `index.ts` | 模块入口,re-export Checker 类 | +| `types.ts` | Checker 专属类型(ResolvedXxxTarget、XxxTargetConfig、XxxExpectConfig 等) | +| `schema.ts` | TypeBox 契约 schema(config / defaults / expect 三部分) | | `validate.ts` | 启动期语义校验(JSON Schema 无法表达的规则) | -| `runner.ts` | Checker 类:resolve(默认值合并 + 解析)、execute(执行检查)、serialize(DB 持久化) | +| `execute.ts` | Checker 类:resolve(默认值合并 + 解析)、execute(执行检查)、serialize(DB 持久化) | | `expect.ts` | Checker 专用断言函数 | +| `*.ts` | 其他 checker 专属逻辑(如 http/body.ts、command/text.ts) | -#### 1.7.2 步骤一:定义类型 +#### 1.7.2 步骤一:创建 Checker 目录与类型 -在 `src/server/checker/types.ts` 中添加 checker 专属类型接口,并更新联合类型: +在 `src/server/checker/runner/tcp/types.ts` 中定义 checker 专属类型: ```typescript -// 1. 添加 TargetConfig(YAML 中 target.tcp 字段的原始类型) +import type { ResolvedTargetBase } from "../../types"; + export interface TcpTargetConfig { host: string; port: number; - timeout?: number; + connectTimeout?: number; } -// 2. 添加 ExpectConfig 扩展(如果 checker 有专属 expect 字段) export interface TcpExpectConfig { connected?: boolean; + maxDurationMs?: number; } -// 3. 添加 DefaultsConfig(defaults.tcp 字段) export interface TcpDefaultsConfig { - timeout?: number; + connectTimeout?: number; } -// 4. 添加 Resolved 变体(运行期已合并默认值、已解析路径) -export interface ResolvedTcpTarget { - type: "tcp"; - name: string; - group: string; - intervalMs: number; - timeoutMs: number; +export interface ResolvedTcpTarget extends ResolvedTargetBase { + expect?: TcpExpectConfig; tcp: { + connectTimeout: number; host: string; port: number; - connectTimeout: number; }; - expect?: TcpExpectConfig; + type: "tcp"; } ``` -然后更新以下联合类型: - -```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 专属(如果复用公共字段则不需要) -} -``` +**注意**:不需要修改顶层 `checker/types.ts`。base interface 使用 index signature(`[key: string]: unknown`),checker 专属类型通过 `extends ResolvedTargetBase` 自动兼容。 #### 1.7.3 步骤二:创建 TypeBox 契约 Schema -在 `src/server/checker/runner/tcp/contract.ts` 中定义三部分 schema: +在 `src/server/checker/runner/tcp/schema.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(), @@ -289,7 +268,6 @@ export const tcpCheckerSchemas: CheckerSchemas = { { additionalProperties: false }, ), - // defaults.tcp 字段的 schema defaults: Type.Object( { connectTimeout: Type.Optional(Type.Integer({ minimum: 100 })), @@ -297,7 +275,6 @@ export const tcpCheckerSchemas: CheckerSchemas = { { additionalProperties: false }, ), - // target.expect 中 tcp 专属字段的 schema(如果无专属字段则用 Type.Object({})) expect: Type.Object( { connected: Type.Optional(Type.Boolean()), @@ -307,12 +284,11 @@ export const tcpCheckerSchemas: CheckerSchemas = { }; ``` -**可复用的共享 fragments**(来自 `config-contract/fragments.ts`): +**可复用的共享 fragments**(来自 `schema/fragments.ts`): | Fragment | 用途 | | ---------------------------- | ---------------------------------------------- | | `durationSchema` | 时长字符串(`"30s"`、`"5m"`、`"500ms"`) | -| `httpMethodSchema` | HTTP 方法枚举 | | `sizeSchema` | 大小单位(字符串如 `"10MB"` 或整数) | | `statusCodePatternSchema` | 状态码(`100`-`599` 或 `"2xx"`) | | `stringMapSchema` | `Record`(用于 headers / env) | @@ -328,79 +304,64 @@ export const tcpCheckerSchemas: CheckerSchemas = { 在 `src/server/checker/runner/tcp/validate.ts` 中实现 JSON Schema 无法表达的语义规则: ```typescript -import type { ConfigValidationIssue } from "../../config-contract/issues"; +import type { ConfigValidationIssue } from "../../schema/issues"; import type { CheckerValidationInput } from "../types"; -import { issue } from "../../config-contract/issues"; +import { issue } from "../../schema/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; + const tcp = target["tcp"] as { host?: string } | undefined; - // 校验 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)); - } + if (tcp && 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`): +**共享校验工具**(`expect/validate-operator.ts`): -| 函数 | 用途 | -| --------------------------------------------------------- | --------------------------------- | -| `validateBodyRules(body, path, targetName)` | 校验 body 规则数组 | -| `validateTextRules(rules, path, targetName)` | 校验文本规则数组(stdout/stderr) | -| `validateOperatorObject(ops, path, targetName, options?)` | 校验操作符对象 | -| `validateJsonPath(path, rulePath, targetName)` | 校验 JSONPath 格式 | +| 函数 | 用途 | +| --------------------------------------------------------- | ---------------------- | +| `validateOperatorObject(ops, path, targetName, options?)` | 校验操作符对象 | +| `isJsonValue(value)` | 判断是否为合法 JSON 值 | #### 1.7.5 步骤四:实现 Checker 类 -在 `src/server/checker/runner/tcp/runner.ts` 中实现 `CheckerDefinition` 接口的全部成员: +在 `src/server/checker/runner/tcp/execute.ts` 中实现 `CheckerDefinition` 接口的全部成员: ```typescript -import type { CheckResult, ResolvedTarget, TargetConfig } from "../../types"; +import type { CheckResult, RawTargetConfig, ResolvedTargetBase } from "../../types"; import type { Checker, CheckerContext, CheckerValidationInput, ResolveContext } from "../types"; -import { tcpCheckerSchemas } from "./contract"; +import { checkDuration } from "../../expect/duration"; +import { errorFailure } from "../../expect/failure"; +import { tcpCheckerSchemas } from "./schema"; +import type { ResolvedTcpTarget, TcpExpectConfig, TcpTargetConfig } from "./types"; import { validateTcpConfig } from "./validate"; export class TcpChecker implements Checker { - readonly configKey = "tcp"; // YAML 中 target.tcp / defaults.tcp 的键名 - readonly type = "tcp"; // target.type 的判别值 + readonly configKey = "tcp"; + readonly type = "tcp"; readonly schemas = tcpCheckerSchemas; - // 启动期语义校验入口 - validate(input: CheckerValidationInput): ConfigValidationIssue[] { + validate(input: CheckerValidationInput) { return validateTcpConfig(input); } - // 将原始配置解析为运行期配置(合并默认值、解析路径和单位) - resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget { - const t = target as TargetConfig & { tcp: TcpTargetConfig; type: "tcp" }; - const defaults = context.defaults.tcp; + resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTargetBase { + const t = target as RawTargetConfig & { tcp: TcpTargetConfig; type: "tcp" }; + const defaults = context.defaults["tcp"] as { connectTimeout?: number } | undefined; return { - expect: target.expect, + expect: target.expect as TcpExpectConfig | undefined, group: target.group ?? "default", intervalMs: context.defaultIntervalMs, name: t.name, @@ -414,33 +375,33 @@ export class TcpChecker implements Checker { } satisfies ResolvedTcpTarget; } - // 执行实际检查,评估 expect,返回 CheckResult - async execute(target: ResolvedTarget, ctx: CheckerContext): Promise { + async execute(target: ResolvedTargetBase, ctx: CheckerContext): Promise { const t = target as ResolvedTcpTarget; const timestamp = new Date().toISOString(); const start = performance.now(); try { - // 执行检查逻辑(如 TCP 连接) - // ... - - // 评估 expect 规则 - // 首个失败即停止,返回 failure + // 执行 TCP 连接检查... const durationMs = Math.round(performance.now() - start); - return { - durationMs, - failure: null, - matched: true, - statusDetail: "TCP connected", - targetName: t.name, - timestamp, - }; + const durationResult = checkDuration(durationMs, t.expect?.maxDurationMs); + if (!durationResult.matched) { + return { + durationMs, + failure: durationResult.failure, + matched: false, + statusDetail: "TCP connected", + targetName: t.name, + timestamp, + }; + } + + 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)), + failure: errorFailure("connection", "connection", String(error)), matched: false, statusDetail: null, targetName: t.name, @@ -449,11 +410,10 @@ export class TcpChecker implements Checker { } } - // 序列化为 DB 存储格式 - serialize(target: ResolvedTarget): { config: string; target: string } { + serialize(target: ResolvedTargetBase): { 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 }), + config: JSON.stringify({ connectTimeout: t.tcp.connectTimeout, host: t.tcp.host, port: t.tcp.port }), target: `${t.tcp.host}:${t.tcp.port}`, }; } @@ -464,7 +424,7 @@ export class TcpChecker implements Checker { - 只做默认值合并、路径解析、单位转换,**不执行校验** - 返回 `satisfies ResolvedXxxTarget` 确保类型正确 -- 通过 `context.defaults[this.configKey]` 访问 checker 专属默认值 +- 通过 `context.defaults[this.configKey]` 访问 checker 专属默认值(需 `as` 断言为具体类型) **`execute()` 规范**: @@ -475,43 +435,59 @@ export class TcpChecker implements Checker { - 异常时使用 `errorFailure(phase, path, message)` 构造 failure - 不匹配时使用 `mismatchFailure(phase, path, expected, actual, message)` 构造 failure -**可用的共享断言工具**(`runner/shared/`): +**可用的共享断言工具**(`checker/expect/`): -| 模块 | 函数 | 用途 | -| ------------- | ----------------------------------------------------- | ---------------------- | -| `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 提取 | +| 模块 | 函数 | 用途 | +| ---------------------- | ----------------------------------------------------- | ---------------------- | +| `failure.ts` | `errorFailure(phase, path, msg)` | 构造错误类型 failure | +| `failure.ts` | `mismatchFailure(phase, path, expected, actual, msg)` | 构造不匹配类型 failure | +| `duration.ts` | `checkDuration(ms, maxMs?)` | 耗时断言 | +| `operator.ts` | `applyOperator(actual, operator)` | 执行单个操作符比较 | +| `operator.ts` | `evaluateJsonPath(json, path)` | JSONPath 提取 | +| `validate-operator.ts` | `validateOperatorObject(ops, path, name)` | 操作符语义校验 | -#### 1.7.6 步骤五:注册 Checker +**Checker 专属断言**(如需要)放在同目录的 `expect.ts` 中,参考 `http/expect.ts`(checkStatus、checkHeaders)和 `command/expect.ts`(checkExitCode)。 -在 `src/server/checker/runner/index.ts` 中注册: +#### 1.7.6 步骤五:创建模块入口并注册 + +创建 `src/server/checker/runner/tcp/index.ts`: ```typescript -import { TcpChecker } from "./tcp/runner"; // ← 新增导入 +export { TcpChecker } from "./execute"; +``` -export function registerCheckers(registry = checkerRegistry): void { - registry.register(new HttpChecker()); - registry.register(new CommandChecker()); - registry.register(new TcpChecker()); // ← 新增注册 +在 `src/server/checker/runner/index.ts` 中添加一行导入和一个数组元素: + +```typescript +import { CommandChecker } from "./command"; +import { HttpChecker } from "./http"; +import { TcpChecker } from "./tcp"; // ← 新增 +import { CheckerRegistry } from "./registry"; + +const checkers = [new HttpChecker(), new CommandChecker(), new TcpChecker()]; // ← 新增 + +export function createDefaultCheckerRegistry(): CheckerRegistry { + const registry = new CheckerRegistry(); + for (const checker of checkers) { + registry.register(checker); + } + return registry; } + +export const checkerRegistry = createDefaultCheckerRegistry(); ``` 注册后,以下管线会通过 registry 自动委托,**无需新增 type 分支**: -| 模块 | 自动行为 | -| ----------------------------- | ------------------------------------------------------------------------ | -| `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()` | +| 模块 | 自动行为 | +| -------------------- | ------------------------------------------------------------------------ | +| `schema/builder.ts` | 遍历 registry 生成全量 JSON Schema(defaults.tcp + target.tcp + expect) | +| `schema/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()` | -注意:自动适配指上述中间层不需要新增 `switch/case` 或类型分支;开发者仍需按后续步骤更新类型、注册、前端展示、示例、文档和测试。 +注意:自动适配指上述中间层不需要新增 `switch/case` 或类型分支;开发者仍需按后续步骤更新前端展示常量、配置示例、文档和测试。 #### 1.7.7 步骤六:更新前端展示 @@ -546,13 +522,14 @@ export function registerCheckers(registry = checkerRegistry): void { #### 1.7.10 完整检查清单 ``` -□ src/server/checker/types.ts — 新增类型接口 + 更新联合类型 -□ src/server/checker/runner/tcp/contract.ts — TypeBox schemas +□ src/server/checker/runner/tcp/types.ts — 专属类型(extends ResolvedTargetBase) +□ src/server/checker/runner/tcp/schema.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/server/checker/runner/tcp/execute.ts — Checker 类 +□ src/server/checker/runner/tcp/expect.ts — 专用断言(如需要) +□ src/server/checker/runner/tcp/index.ts — 模块入口(re-export) +□ 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 — 配置示例 @@ -597,7 +574,7 @@ export function registerCheckers(registry = checkerRegistry): void { ### 1.10 expect 断言系统 -两层模型:**观测值收集** → **规则校验**。 +两层模型:**观测值收集** → **规则校验**。共享断言基础设施位于 `checker/expect/`,checker 专属断言位于各自目录。 **HTTP 校验流程**: @@ -617,7 +594,7 @@ CommandChecker.execute → 收集观测(exitCode/stdout/stderr/durationMs) → 首个失败即停止 ``` -**Body 规则类型**: +**Body 规则类型**(`runner/http/body.ts`): - `contains`:文本包含匹配 - `regex`:正则表达式匹配(注意:body 正则字段为 `regex`,不是 `match`) @@ -625,7 +602,9 @@ CommandChecker.execute → 收集观测(exitCode/stdout/stderr/durationMs) - `css`:cheerio CSS 选择器 + 操作符比较 - `xpath`:XPath 节点提取 + 操作符比较 -**操作符**:`equals`(深度比较,`es-toolkit/isEqual`)、`contains`、`match`(正则)、`empty`(`isNil`+`isEmptyObject`)、`exists`、`gte`/`lte`/`gt`/`lt` +**文本规则**(`runner/command/text.ts`):stdout/stderr 文本匹配,支持 `contains`、`match`(正则)、操作符比较 + +**操作符**(`expect/operator.ts`):`equals`(深度比较,`es-toolkit/isEqual`)、`contains`、`match`(正则)、`empty`(`isNil`+`isEmptyObject`)、`exists`、`gte`/`lte`/`gt`/`lt` ### 1.11 错误模式 diff --git a/openspec/changes/refactor-checker-cohesion/tasks.md b/openspec/changes/refactor-checker-cohesion/tasks.md index 6f5b3de..054ebda 100644 --- a/openspec/changes/refactor-checker-cohesion/tasks.md +++ b/openspec/changes/refactor-checker-cohesion/tasks.md @@ -1,56 +1,56 @@ ## 1. 基础设施搭建 -- [ ] 1.1 创建 `src/server/checker/utils.ts`,将 `size.ts` 的 `parseSize` 和 `config-loader.ts` 的 `parseDuration` 及 `DURATION_REGEX` 迁入 -- [ ] 1.2 创建 `src/server/checker/expect/` 目录,创建 `expect/types.ts` 放置 `ExpectResult` 等共享类型 -- [ ] 1.3 将 `runner/shared/operator.ts` 迁入为 `expect/operator.ts` -- [ ] 1.4 将 `runner/shared/failure.ts` 迁入为 `expect/failure.ts` -- [ ] 1.5 将 `runner/shared/duration.ts` 迁入为 `expect/duration.ts`(`ExpectResult` 类型提取到 `expect/types.ts`) -- [ ] 1.6 从 `runner/shared/validate.ts` 中提取 `validateOperatorObject`、`isJsonValue`、`validateOperatorValue`、`isPlainRecord` 到 `expect/validate-operator.ts` +- [x] 1.1 创建 `src/server/checker/utils.ts`,将 `size.ts` 的 `parseSize` 和 `config-loader.ts` 的 `parseDuration` 及 `DURATION_REGEX` 迁入 +- [x] 1.2 创建 `src/server/checker/expect/` 目录,创建 `expect/types.ts` 放置 `ExpectResult` 等共享类型 +- [x] 1.3 将 `runner/shared/operator.ts` 迁入为 `expect/operator.ts` +- [x] 1.4 将 `runner/shared/failure.ts` 迁入为 `expect/failure.ts` +- [x] 1.5 将 `runner/shared/duration.ts` 迁入为 `expect/duration.ts`(`ExpectResult` 类型提取到 `expect/types.ts`) +- [x] 1.6 从 `runner/shared/validate.ts` 中提取 `validateOperatorObject`、`isJsonValue`、`validateOperatorValue`、`isPlainRecord` 到 `expect/validate-operator.ts` ## 2. Schema 目录重组 -- [ ] 2.1 将 `config-contract/` 目录重命名为 `schema/` -- [ ] 2.2 将 `schema/schema.ts` 重命名为 `schema/builder.ts` -- [ ] 2.3 更新 `schema/` 内部文件的相互引用路径 -- [ ] 2.4 更新外部对 `config-contract/` 的所有 import 路径(config-loader.ts、runner/shared/validate.ts 等) +- [x] 2.1 将 `config-contract/` 目录重命名为 `schema/` +- [x] 2.2 将 `schema/schema.ts` 重命名为 `schema/builder.ts` +- [x] 2.3 更新 `schema/` 内部文件的相互引用路径 +- [x] 2.4 更新外部对 `config-contract/` 的所有 import 路径(config-loader.ts、runner/shared/validate.ts 等) ## 3. 类型系统重构 -- [ ] 3.1 在顶层 `types.ts` 中创建 `ResolvedTargetBase` 和 `RawTargetConfig` base interface -- [ ] 3.2 将 HTTP 专属类型(`HttpTargetConfig`、`ResolvedHttpTarget`、`HttpExpectConfig`、`HttpDefaultsConfig`、`ResolvedHttpConfig`、`BodyRule`、`CssRule`、`JsonRule`、`XpathRule`、`HeaderExpect`)迁入 `runner/http/types.ts` -- [ ] 3.3 将 Command 专属类型(`CommandTargetConfig`、`ResolvedCommandTarget`、`CommandExpectConfig`、`CommandDefaultsConfig`、`ResolvedCommandConfig`)迁入 `runner/command/types.ts` -- [ ] 3.4 删除顶层 `types.ts` 中的 `ResolvedTarget` 联合类型和 `TargetConfig` 联合类型,将 `TextRule` 迁入 command/types.ts -- [ ] 3.5 将 `DefaultsConfig` 改为宽松 base 形式(仅保留 `interval?`、`timeout?` + index signature),将 `CommandDefaultsConfig` 迁入 command/types.ts,将 `HttpDefaultsConfig` 迁入 http/types.ts -- [ ] 3.6 更新 `runner/types.ts` 中 `CheckerDefinition` 接口签名,使用 `RawTargetConfig` 和 `ResolvedTargetBase` -- [ ] 3.7 更新 `engine.ts`、`store.ts`、`config-loader.ts` 的类型引用为 `ResolvedTargetBase` 和 `RawTargetConfig` +- [x] 3.1 在顶层 `types.ts` 中创建 `ResolvedTargetBase` 和 `RawTargetConfig` base interface +- [x] 3.2 将 HTTP 专属类型(`HttpTargetConfig`、`ResolvedHttpTarget`、`HttpExpectConfig`、`HttpDefaultsConfig`、`ResolvedHttpConfig`、`BodyRule`、`CssRule`、`JsonRule`、`XpathRule`、`HeaderExpect`)迁入 `runner/http/types.ts` +- [x] 3.3 将 Command 专属类型(`CommandTargetConfig`、`ResolvedCommandTarget`、`CommandExpectConfig`、`CommandDefaultsConfig`、`ResolvedCommandConfig`)迁入 `runner/command/types.ts` +- [x] 3.4 删除顶层 `types.ts` 中的 `ResolvedTarget` 联合类型和 `TargetConfig` 联合类型,将 `TextRule` 迁入 command/types.ts +- [x] 3.5 将 `DefaultsConfig` 改为宽松 base 形式(仅保留 `interval?`、`timeout?` + index signature),将 `CommandDefaultsConfig` 迁入 command/types.ts,将 `HttpDefaultsConfig` 迁入 http/types.ts +- [x] 3.6 更新 `runner/types.ts` 中 `CheckerDefinition` 接口签名,使用 `RawTargetConfig` 和 `ResolvedTargetBase` +- [x] 3.7 更新 `engine.ts`、`store.ts`、`config-loader.ts` 的类型引用为 `ResolvedTargetBase` 和 `RawTargetConfig` ## 4. HTTP Checker 内聚化 -- [ ] 4.1 将 `runner/http/runner.ts` 重命名为 `runner/http/execute.ts` -- [ ] 4.2 将 `runner/http/contract.ts` 重命名为 `runner/http/schema.ts` -- [ ] 4.3 将 `runner/shared/body.ts` 迁入 `runner/http/body.ts` -- [ ] 4.4 将 `runner/shared/validate.ts` 中的 `validateBodyRules`、`validateCssRule`、`validateJsonRule`、`validateXpathRule`、`validateRegexRule`、`validateSingleBodyRule`、`validateJsonPath` 合并到 `runner/http/validate.ts` -- [ ] 4.5 创建 `runner/http/index.ts`,re-export `HttpChecker` -- [ ] 4.6 更新 `runner/http/` 内所有文件的 import 路径 +- [x] 4.1 将 `runner/http/runner.ts` 重命名为 `runner/http/execute.ts` +- [x] 4.2 将 `runner/http/contract.ts` 重命名为 `runner/http/schema.ts` +- [x] 4.3 将 `runner/shared/body.ts` 迁入 `runner/http/body.ts` +- [x] 4.4 将 `runner/shared/validate.ts` 中的 `validateBodyRules`、`validateCssRule`、`validateJsonRule`、`validateXpathRule`、`validateRegexRule`、`validateSingleBodyRule`、`validateJsonPath` 合并到 `runner/http/validate.ts` +- [x] 4.5 创建 `runner/http/index.ts`,re-export `HttpChecker` +- [x] 4.6 更新 `runner/http/` 内所有文件的 import 路径 ## 5. Command Checker 内聚化 -- [ ] 5.1 将 `runner/command/runner.ts` 重命名为 `runner/command/execute.ts` -- [ ] 5.2 将 `runner/command/contract.ts` 重命名为 `runner/command/schema.ts` -- [ ] 5.3 将 `runner/shared/text.ts` 迁入 `runner/command/text.ts` -- [ ] 5.4 将 `runner/shared/validate.ts` 中的 `validateTextRules` 合并到 `runner/command/validate.ts` -- [ ] 5.5 创建 `runner/command/index.ts`,re-export `CommandChecker` -- [ ] 5.6 更新 `runner/command/` 内所有文件的 import 路径 +- [x] 5.1 将 `runner/command/runner.ts` 重命名为 `runner/command/execute.ts` +- [x] 5.2 将 `runner/command/contract.ts` 重命名为 `runner/command/schema.ts` +- [x] 5.3 将 `runner/shared/text.ts` 迁入 `runner/command/text.ts` +- [x] 5.4 将 `runner/shared/validate.ts` 中的 `validateTextRules` 合并到 `runner/command/validate.ts` +- [x] 5.5 创建 `runner/command/index.ts`,re-export `CommandChecker` +- [x] 5.6 更新 `runner/command/` 内所有文件的 import 路径 ## 6. 注册入口改造 -- [ ] 6.1 重写 `runner/index.ts` 为显式列表注册模式(import 列表 + checker 数组 + 循环注册) -- [ ] 6.2 删除 `runner/shared/` 目录(确认所有内容已迁移完毕) -- [ ] 6.3 删除 `src/server/checker/size.ts`(已迁入 utils.ts) +- [x] 6.1 重写 `runner/index.ts` 为显式列表注册模式(import 列表 + checker 数组 + 循环注册) +- [x] 6.2 删除 `runner/shared/` 目录(确认所有内容已迁移完毕) +- [x] 6.3 删除 `src/server/checker/size.ts`(已迁入 utils.ts) ## 7. 测试与质量保障 -- [ ] 7.1 更新所有测试文件的 import 路径 -- [ ] 7.2 执行完整测试套件,确保所有测试通过 -- [ ] 7.3 执行 lint 和格式检查,确保代码质量 -- [ ] 7.4 更新 README.md 和 DEVELOPMENT.md 中涉及 checker 模块结构的描述 +- [x] 7.1 更新所有测试文件的 import 路径 +- [x] 7.2 执行完整测试套件,确保所有测试通过 +- [x] 7.3 执行 lint 和格式检查,确保代码质量 +- [x] 7.4 确认新增 checker 只需一个目录 + 一行注册 diff --git a/scripts/generate-config-schema.ts b/scripts/generate-config-schema.ts index 34c2e67..4da8a50 100644 --- a/scripts/generate-config-schema.ts +++ b/scripts/generate-config-schema.ts @@ -1,5 +1,5 @@ -import { createProbeConfigJsonSchema } from "../src/server/checker/config-contract/export"; import { createDefaultCheckerRegistry } from "../src/server/checker/runner"; +import { createProbeConfigJsonSchema } from "../src/server/checker/schema/export"; const schemaPath = "probe-config.schema.json"; const schema = `${JSON.stringify(createProbeConfigJsonSchema(createDefaultCheckerRegistry()), null, 2)}\n`; diff --git a/src/server/checker/config-loader.ts b/src/server/checker/config-loader.ts index 485aa08..8f2517c 100644 --- a/src/server/checker/config-loader.ts +++ b/src/server/checker/config-loader.ts @@ -1,12 +1,13 @@ import { dirname, resolve } from "node:path"; -import type { ConfigValidationIssue } from "./config-contract/issues"; -import type { DefaultsConfig, EngineRuntimeConfig, ResolvedTarget, TargetConfig } from "./types"; +import type { ConfigValidationIssue } from "./schema/issues"; +import type { DefaultsConfig, EngineRuntimeConfig, RawTargetConfig, ResolvedTargetBase } 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"; +import { issue, throwConfigIssues } from "./schema/issues"; +import { asValidatedConfig, type RawProbeConfig } from "./schema/types"; +import { validateProbeConfigContract } from "./schema/validate"; +import { parseDuration } from "./utils"; const DEFAULT_HOST = "127.0.0.1"; const DEFAULT_PORT = 3000; @@ -21,7 +22,7 @@ export interface ResolvedConfig { host: string; maxConcurrentChecks: number; port: number; - targets: ResolvedTarget[]; + targets: ResolvedTargetBase[]; } export async function loadConfig(configPath: string): Promise { @@ -76,13 +77,35 @@ export async function loadConfig(configPath: string): Promise { const defaultIntervalMs = parseDuration(defaults.interval ?? DEFAULT_INTERVAL); const defaultTimeoutMs = parseDuration(defaults.timeout ?? DEFAULT_TIMEOUT); - const targets: ResolvedTarget[] = validated.targets.map((target) => + const targets: ResolvedTargetBase[] = validated.targets.map((target) => resolveTarget(target, defaults, defaultIntervalMs, defaultTimeoutMs, configDir), ); return { configDir, dataDir, host, maxConcurrentChecks, port, targets }; } +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); +} + +export { parseDuration } from "./utils"; + function resolveMaxConcurrentChecks(runtime: EngineRuntimeConfig): number { if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS; if ( @@ -95,12 +118,12 @@ function resolveMaxConcurrentChecks(runtime: EngineRuntimeConfig): number { } function resolveTarget( - target: TargetConfig, + target: RawTargetConfig, defaults: DefaultsConfig, defaultIntervalMs: number, defaultTimeoutMs: number, configDir: string, -): ResolvedTarget { +): ResolvedTargetBase { const intervalMs = parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL); const timeoutMs = parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT); @@ -192,44 +215,6 @@ function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] { return issues; } -const DURATION_REGEX = /^(\d+(?:\.\d+)?)(ms|s|m)$/; - -export function parseDuration(value: string): number { - const match = DURATION_REGEX.exec(value); - if (!match) { - throw new Error(`无效的时长格式: "${value}",支持格式如 "30s"、"5m"、"500ms"`); - } - - const num = parseFloat(match[1]!); - const unit = match[2]!; - - 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, diff --git a/src/server/checker/engine.ts b/src/server/checker/engine.ts index c79fb49..fee9336 100644 --- a/src/server/checker/engine.ts +++ b/src/server/checker/engine.ts @@ -1,7 +1,7 @@ import { groupBy, Semaphore } from "es-toolkit"; import type { ProbeStore } from "./store"; -import type { CheckResult, ResolvedTarget } from "./types"; +import type { CheckResult, ResolvedTargetBase } from "./types"; import { checkerRegistry } from "./runner"; @@ -9,10 +9,10 @@ export class ProbeEngine { private semaphore: Semaphore; private store: ProbeStore; private targetNameToId = new Map(); - private targets: ResolvedTarget[]; + private targets: ResolvedTargetBase[]; private timers: Array> = []; - constructor(store: ProbeStore, targets: ResolvedTarget[], maxConcurrentChecks?: number) { + constructor(store: ProbeStore, targets: ResolvedTargetBase[], maxConcurrentChecks?: number) { this.store = store; this.targets = targets; this.semaphore = new Semaphore(maxConcurrentChecks ?? 20); @@ -40,7 +40,7 @@ export class ProbeEngine { this.timers = []; } - private async probeGroup(targets: ResolvedTarget[]): Promise { + private async probeGroup(targets: ResolvedTargetBase[]): Promise { const results = await Promise.allSettled( targets.map(async (target) => { await this.semaphore.acquire(); @@ -68,7 +68,7 @@ export class ProbeEngine { } } - private async runCheck(target: ResolvedTarget): Promise { + private async runCheck(target: ResolvedTargetBase): Promise { const checker = checkerRegistry.get(target.type); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), target.timeoutMs); diff --git a/src/server/checker/runner/shared/duration.ts b/src/server/checker/expect/duration.ts similarity index 79% rename from src/server/checker/runner/shared/duration.ts rename to src/server/checker/expect/duration.ts index f1e860f..9b8cc96 100644 --- a/src/server/checker/runner/shared/duration.ts +++ b/src/server/checker/expect/duration.ts @@ -1,12 +1,7 @@ -import type { CheckFailure } from "../../types"; +import type { ExpectResult } from "./types"; import { mismatchFailure } from "./failure"; -export interface ExpectResult { - failure: CheckFailure | null; - matched: boolean; -} - export function checkDuration(durationMs: number, maxDurationMs?: number): ExpectResult { if (maxDurationMs === undefined) return { failure: null, matched: true }; if (durationMs > maxDurationMs) { diff --git a/src/server/checker/runner/shared/failure.ts b/src/server/checker/expect/failure.ts similarity index 93% rename from src/server/checker/runner/shared/failure.ts rename to src/server/checker/expect/failure.ts index 78052d2..70aa3b0 100644 --- a/src/server/checker/runner/shared/failure.ts +++ b/src/server/checker/expect/failure.ts @@ -1,4 +1,4 @@ -import type { CheckFailure } from "../../types"; +import type { CheckFailure } from "../types"; export function errorFailure(phase: CheckFailure["phase"], path: string, message: string): CheckFailure { return { diff --git a/src/server/checker/runner/shared/operator.ts b/src/server/checker/expect/operator.ts similarity index 97% rename from src/server/checker/runner/shared/operator.ts rename to src/server/checker/expect/operator.ts index 6613153..37d862a 100644 --- a/src/server/checker/runner/shared/operator.ts +++ b/src/server/checker/expect/operator.ts @@ -1,6 +1,6 @@ import { isEmptyObject, isEqual, isNil, isPlainObject } from "es-toolkit"; -import type { ExpectOperator, ExpectValue } from "../../types"; +import type { ExpectOperator, ExpectValue } from "../types"; const OPERATOR_KEYS = new Set(["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "match"]); diff --git a/src/server/checker/expect/types.ts b/src/server/checker/expect/types.ts new file mode 100644 index 0000000..1dfac9b --- /dev/null +++ b/src/server/checker/expect/types.ts @@ -0,0 +1,6 @@ +import type { CheckFailure } from "../types"; + +export interface ExpectResult { + failure: CheckFailure | null; + matched: boolean; +} diff --git a/src/server/checker/expect/validate-operator.ts b/src/server/checker/expect/validate-operator.ts new file mode 100644 index 0000000..4ee332e --- /dev/null +++ b/src/server/checker/expect/validate-operator.ts @@ -0,0 +1,80 @@ +import type { ConfigValidationIssue } from "../schema/issues"; +import type { JsonValue } from "../types"; + +import { OperatorKeys } from "../schema/fragments"; +import { issue, joinPath } from "../schema/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 isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +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 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)]; + } +} diff --git a/src/server/checker/runner/command/runner.ts b/src/server/checker/runner/command/execute.ts similarity index 87% rename from src/server/checker/runner/command/runner.ts rename to src/server/checker/runner/command/execute.ts index f7a9ba7..b8a6bfa 100644 --- a/src/server/checker/runner/command/runner.ts +++ b/src/server/checker/runner/command/execute.ts @@ -1,21 +1,16 @@ import { isError } from "es-toolkit"; import { resolve } from "node:path"; -import type { - CheckResult, - CommandTargetConfig, - ResolvedCommandTarget, - ResolvedTarget, - TargetConfig, -} from "../../types"; +import type { CheckResult, RawTargetConfig, ResolvedTargetBase } from "../../types"; import type { Checker, CheckerContext, CheckerValidationInput, ResolveContext } from "../types"; +import type { CommandExpectConfig, CommandTargetConfig, ResolvedCommandTarget } 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 { checkDuration } from "../../expect/duration"; +import { errorFailure } from "../../expect/failure"; +import { parseSize } from "../../utils"; import { checkExitCode } from "./expect"; +import { commandCheckerSchemas } from "./schema"; +import { checkTextRules } from "./text"; import { validateCommandConfig } from "./validate"; export class CommandChecker implements Checker { @@ -25,7 +20,7 @@ export class CommandChecker implements Checker { readonly type = "command"; - async execute(target: ResolvedTarget, ctx: CheckerContext): Promise { + async execute(target: ResolvedTargetBase, ctx: CheckerContext): Promise { const t = target as ResolvedCommandTarget; const timestamp = new Date().toISOString(); const start = performance.now(); @@ -174,9 +169,9 @@ export class CommandChecker implements Checker { }; } - resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget { - const t = target as TargetConfig & { command: CommandTargetConfig; type: "command" }; - const commandDefaults = context.defaults.command; + resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTargetBase { + const t = target as RawTargetConfig & { command: CommandTargetConfig; type: "command" }; + const commandDefaults = context.defaults["command"] as undefined | { cwd?: string; maxOutputBytes?: string }; const cwd = t.command.cwd ?? commandDefaults?.cwd ?? "."; const resolvedCwd = resolve(context.configDir, cwd); @@ -193,7 +188,7 @@ export class CommandChecker implements Checker { exec: t.command.exec, maxOutputBytes, }, - expect: target.expect, + expect: target.expect as CommandExpectConfig | undefined, group: target.group ?? "default", intervalMs: context.defaultIntervalMs, name: t.name, @@ -202,7 +197,7 @@ export class CommandChecker implements Checker { } satisfies ResolvedCommandTarget; } - serialize(target: ResolvedTarget): { config: string; target: string } { + serialize(target: ResolvedTargetBase): { config: string; target: string } { const t = target as ResolvedCommandTarget; const parts = [t.command.exec, ...t.command.args]; return { diff --git a/src/server/checker/runner/command/expect.ts b/src/server/checker/runner/command/expect.ts index 8391617..32adbcd 100644 --- a/src/server/checker/runner/command/expect.ts +++ b/src/server/checker/runner/command/expect.ts @@ -1,6 +1,6 @@ -import type { ExpectResult } from "../shared/duration"; +import type { ExpectResult } from "../../expect/types"; -import { mismatchFailure } from "../shared/failure"; +import { mismatchFailure } from "../../expect/failure"; export function checkExitCode(exitCode: number, allowed: number[]): ExpectResult { if (!allowed.includes(exitCode)) { diff --git a/src/server/checker/runner/command/index.ts b/src/server/checker/runner/command/index.ts new file mode 100644 index 0000000..6a68929 --- /dev/null +++ b/src/server/checker/runner/command/index.ts @@ -0,0 +1 @@ +export { CommandChecker } from "./execute"; diff --git a/src/server/checker/runner/command/contract.ts b/src/server/checker/runner/command/schema.ts similarity index 96% rename from src/server/checker/runner/command/contract.ts rename to src/server/checker/runner/command/schema.ts index 556816d..afa663c 100644 --- a/src/server/checker/runner/command/contract.ts +++ b/src/server/checker/runner/command/schema.ts @@ -2,7 +2,7 @@ import { Type } from "@sinclair/typebox"; import type { CheckerSchemas } from "../types"; -import { createTextRulesSchema, sizeSchema, stringMapSchema } from "../../config-contract/fragments"; +import { createTextRulesSchema, sizeSchema, stringMapSchema } from "../../schema/fragments"; export const commandCheckerSchemas: CheckerSchemas = { config: Type.Object( diff --git a/src/server/checker/runner/shared/text.ts b/src/server/checker/runner/command/text.ts similarity index 68% rename from src/server/checker/runner/shared/text.ts rename to src/server/checker/runner/command/text.ts index 6d7e218..c375dac 100644 --- a/src/server/checker/runner/shared/text.ts +++ b/src/server/checker/runner/command/text.ts @@ -1,8 +1,8 @@ -import type { TextRule } from "../../types"; -import type { ExpectResult } from "./duration"; +import type { ExpectResult } from "../../expect/types"; +import type { TextRule } from "./types"; -import { mismatchFailure } from "./failure"; -import { applyOperator } from "./operator"; +import { mismatchFailure } from "../../expect/failure"; +import { applyOperator } from "../../expect/operator"; export function checkTextRules(text: string, rules: TextRule[], phase: string): ExpectResult { for (let i = 0; i < rules.length; i++) { diff --git a/src/server/checker/runner/command/types.ts b/src/server/checker/runner/command/types.ts new file mode 100644 index 0000000..d09cd5d --- /dev/null +++ b/src/server/checker/runner/command/types.ts @@ -0,0 +1,41 @@ +import type { ExpectOperator, ResolvedTargetBase } from "../../types"; + +export interface CommandDefaultsConfig { + cwd?: string; + maxOutputBytes?: string; +} + +export interface CommandExpectConfig { + exitCode?: number[]; + maxDurationMs?: number; + stderr?: TextRule[]; + stdout?: TextRule[]; +} + +export interface CommandTargetConfig { + args?: string[]; + cwd?: string; + env?: Record; + exec: string; + maxOutputBytes?: string; +} + +export interface ResolvedCommandConfig { + args: string[]; + cwd: string; + env: Record; + exec: string; + maxOutputBytes: number; +} + +export interface ResolvedCommandTarget extends ResolvedTargetBase { + command: ResolvedCommandConfig; + expect?: CommandExpectConfig; + group: string; + intervalMs: number; + name: string; + timeoutMs: number; + type: "command"; +} + +export type TextRule = ExpectOperator; diff --git a/src/server/checker/runner/command/validate.ts b/src/server/checker/runner/command/validate.ts index 368aa6b..266e4c5 100644 --- a/src/server/checker/runner/command/validate.ts +++ b/src/server/checker/runner/command/validate.ts @@ -1,9 +1,9 @@ -import type { ConfigValidationIssue } from "../../config-contract/issues"; +import type { ConfigValidationIssue } from "../../schema/issues"; import type { CheckerValidationInput } from "../types"; -import { issue, joinPath } from "../../config-contract/issues"; -import { parseSize } from "../../size"; -import { validateTextRules } from "../shared/validate"; +import { validateOperatorObject } from "../../expect/validate-operator"; +import { issue, joinPath } from "../../schema/issues"; +import { parseSize } from "../../utils"; export function validateCommandConfig(input: CheckerValidationInput): ConfigValidationIssue[] { const issues: ConfigValidationIssue[] = []; @@ -91,3 +91,8 @@ function validateSizeValue(value: number | string, path: string, targetName?: st return [issue("invalid-size", path, error instanceof Error ? error.message : "size 格式不合法", targetName)]; } } + +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)); +} diff --git a/src/server/checker/runner/shared/body.ts b/src/server/checker/runner/http/body.ts similarity index 95% rename from src/server/checker/runner/shared/body.ts rename to src/server/checker/runner/http/body.ts index 967caee..5888856 100644 --- a/src/server/checker/runner/shared/body.ts +++ b/src/server/checker/runner/http/body.ts @@ -2,11 +2,11 @@ import { DOMParser } from "@xmldom/xmldom"; import * as cheerio from "cheerio"; import * as xpath from "xpath"; -import type { BodyRule, CssRule, JsonRule, XpathRule } from "../../types"; -import type { ExpectResult } from "./duration"; +import type { ExpectResult } from "../../expect/types"; +import type { BodyRule, CssRule, JsonRule, XpathRule } from "./types"; -import { errorFailure, mismatchFailure } from "./failure"; -import { applyOperator, evaluateJsonPath } from "./operator"; +import { errorFailure, mismatchFailure } from "../../expect/failure"; +import { applyOperator, evaluateJsonPath } from "../../expect/operator"; export function checkBodyExpect(body: string, rules?: BodyRule[]): ExpectResult { if (!rules || rules.length === 0) return { failure: null, matched: true }; diff --git a/src/server/checker/runner/http/runner.ts b/src/server/checker/runner/http/execute.ts similarity index 90% rename from src/server/checker/runner/http/runner.ts rename to src/server/checker/runner/http/execute.ts index 1214a7d..7642c84 100644 --- a/src/server/checker/runner/http/runner.ts +++ b/src/server/checker/runner/http/execute.ts @@ -1,14 +1,15 @@ import { isError } from "es-toolkit"; -import type { CheckResult, HttpTargetConfig, ResolvedHttpTarget, ResolvedTarget, TargetConfig } from "../../types"; +import type { CheckResult, RawTargetConfig, ResolvedTargetBase } from "../../types"; import type { Checker, CheckerContext, CheckerValidationInput, ResolveContext } from "../types"; +import type { HttpExpectConfig, HttpTargetConfig, ResolvedHttpTarget } 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 { checkDuration } from "../../expect/duration"; +import { errorFailure, mismatchFailure } from "../../expect/failure"; +import { parseSize } from "../../utils"; +import { checkBodyExpect } from "./body"; import { checkHeaders, checkStatus } from "./expect"; +import { httpCheckerSchemas } from "./schema"; import { validateHttpConfig } from "./validate"; const CHARSET_RE = /charset="?([^";\s]+)"?/i; @@ -22,7 +23,7 @@ export class HttpChecker implements Checker { readonly type = "http"; - async execute(target: ResolvedTarget, ctx: CheckerContext): Promise { + async execute(target: ResolvedTargetBase, ctx: CheckerContext): Promise { const t = target as ResolvedHttpTarget; const timestamp = new Date().toISOString(); const expect = t.expect; @@ -116,15 +117,17 @@ export class HttpChecker implements Checker { } } - resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget { - const t = target as TargetConfig & { http: HttpTargetConfig; type: "http" }; - const httpDefaults = context.defaults.http; + resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTargetBase { + const t = target as RawTargetConfig & { http: HttpTargetConfig; type: "http" }; + const httpDefaults = context.defaults["http"] as + | undefined + | { headers?: Record; maxBodyBytes?: string; method?: string }; const method = t.http.method ?? httpDefaults?.method ?? "GET"; const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB"); return { - expect: target.expect, + expect: target.expect as HttpExpectConfig | undefined, group: target.group ?? "default", http: { body: t.http.body, @@ -142,7 +145,7 @@ export class HttpChecker implements Checker { } satisfies ResolvedHttpTarget; } - serialize(target: ResolvedTarget): { config: string; target: string } { + serialize(target: ResolvedTargetBase): { config: string; target: string } { const t = target as ResolvedHttpTarget; return { config: JSON.stringify({ diff --git a/src/server/checker/runner/http/expect.ts b/src/server/checker/runner/http/expect.ts index 107b0ac..6ac39c0 100644 --- a/src/server/checker/runner/http/expect.ts +++ b/src/server/checker/runner/http/expect.ts @@ -1,8 +1,8 @@ -import type { HeaderExpect } from "../../types"; -import type { ExpectResult } from "../shared/duration"; +import type { ExpectResult } from "../../expect/types"; +import type { HeaderExpect } from "./types"; -import { mismatchFailure } from "../shared/failure"; -import { applyOperator } from "../shared/operator"; +import { mismatchFailure } from "../../expect/failure"; +import { applyOperator } from "../../expect/operator"; export function checkHeaders( headers: Record, diff --git a/src/server/checker/runner/http/index.ts b/src/server/checker/runner/http/index.ts new file mode 100644 index 0000000..7a925a6 --- /dev/null +++ b/src/server/checker/runner/http/index.ts @@ -0,0 +1 @@ +export { HttpChecker } from "./execute"; diff --git a/src/server/checker/runner/http/contract.ts b/src/server/checker/runner/http/schema.ts similarity index 96% rename from src/server/checker/runner/http/contract.ts rename to src/server/checker/runner/http/schema.ts index 7d54582..6f5ce50 100644 --- a/src/server/checker/runner/http/contract.ts +++ b/src/server/checker/runner/http/schema.ts @@ -9,7 +9,7 @@ import { sizeSchema, statusCodePatternSchema, stringMapSchema, -} from "../../config-contract/fragments"; +} from "../../schema/fragments"; export const httpCheckerSchemas: CheckerSchemas = { config: Type.Object( diff --git a/src/server/checker/runner/http/types.ts b/src/server/checker/runner/http/types.ts new file mode 100644 index 0000000..de6f757 --- /dev/null +++ b/src/server/checker/runner/http/types.ts @@ -0,0 +1,59 @@ +import type { ExpectOperator, ResolvedTargetBase } from "../../types"; + +export type BodyRule = + | { contains: string } + | { css: CssRule } + | { json: JsonRule } + | { regex: string } + | { xpath: XpathRule }; + +export type CssRule = ExpectOperator & { attr?: string; selector: string }; + +export type HeaderExpect = ExpectOperator | string; + +export interface HttpDefaultsConfig { + headers?: Record; + maxBodyBytes?: string; + method?: string; +} + +export interface HttpExpectConfig { + body?: BodyRule[]; + headers?: Record; + maxDurationMs?: number; + status?: Array; +} + +export interface HttpTargetConfig { + body?: string; + headers?: Record; + ignoreSSL?: boolean; + maxBodyBytes?: string; + maxRedirects?: number; + method?: string; + url: string; +} + +export type JsonRule = ExpectOperator & { path: string }; + +export interface ResolvedHttpConfig { + body?: string; + headers: Record; + ignoreSSL: boolean; + maxBodyBytes: number; + maxRedirects: number; + method: string; + url: string; +} + +export interface ResolvedHttpTarget extends ResolvedTargetBase { + expect?: HttpExpectConfig; + group: string; + http: ResolvedHttpConfig; + intervalMs: number; + name: string; + timeoutMs: number; + type: "http"; +} + +export type XpathRule = ExpectOperator & { path: string }; diff --git a/src/server/checker/runner/http/validate.ts b/src/server/checker/runner/http/validate.ts index acdd335..dac9662 100644 --- a/src/server/checker/runner/http/validate.ts +++ b/src/server/checker/runner/http/validate.ts @@ -1,15 +1,26 @@ -import type { ConfigValidationIssue } from "../../config-contract/issues"; +import { DOMParser } from "@xmldom/xmldom"; +import * as xpath from "xpath"; + +import type { ConfigValidationIssue } from "../../schema/issues"; import type { CheckerValidationInput } from "../types"; -import { issue, joinPath } from "../../config-contract/issues"; -import { parseSize } from "../../size"; -import { validateBodyRules, validateOperatorObject } from "../shared/validate"; +import { isPlainRecord, validateOperatorObject } from "../../expect/validate-operator"; +import { BodyRuleTypeKeys, OperatorKeys } from "../../schema/fragments"; +import { issue, joinPath } from "../../schema/issues"; +import { parseSize } from "../../utils"; const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]); +const OPERATOR_KEY_SET = new Set(OperatorKeys); + +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 validateHttpConfig(input: CheckerValidationInput): ConfigValidationIssue[] { const issues: ConfigValidationIssue[] = []; - const defaults = isRecord(input.defaults) && isRecord(input.defaults["http"]) ? input.defaults["http"] : undefined; + const defaults = + isPlainRecord(input.defaults) && isPlainRecord(input.defaults["http"]) ? input.defaults["http"] : undefined; if (isSizeInput(defaults?.["maxBodyBytes"])) { issues.push(...validateSizeValue(defaults["maxBodyBytes"], "defaults.http.maxBodyBytes")); @@ -17,7 +28,7 @@ export function validateHttpConfig(input: CheckerValidationInput): ConfigValidat for (let i = 0; i < input.targets.length; i++) { const target = input.targets[i] as unknown; - if (!isRecord(target)) continue; + if (!isPlainRecord(target)) continue; if (target["type"] !== "http") continue; issues.push(...validateHttpTarget(target, `targets[${i}]`)); } @@ -25,6 +36,43 @@ export function validateHttpConfig(input: CheckerValidationInput): ConfigValidat return issues; } +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; +} + +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 getTargetName(target: Record): string | undefined { return typeof target["name"] === "string" ? target["name"] : undefined; } @@ -33,22 +81,35 @@ 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 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 validateHttpExpect(target: Record, path: string): ConfigValidationIssue[] { const targetName = getTargetName(target); const expect = target["expect"]; - if (expect === undefined || expect === null || !isRecord(expect)) return []; + if (expect === undefined || expect === null || !isPlainRecord(expect)) return []; const issues: ConfigValidationIssue[] = []; const expectPath = joinPath(path, "expect"); - if (isRecord(expect["headers"])) { + if (isPlainRecord(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)); @@ -74,7 +135,7 @@ function validateHttpTarget(target: Record, path: string): Conf const issues: ConfigValidationIssue[] = []; const targetName = getTargetName(target); const http = target["http"]; - if (!isRecord(http)) { + if (!isPlainRecord(http)) { issues.push(issue("required", joinPath(path, "http"), "缺少 http.url 字段", targetName)); issues.push(...validateHttpExpect(target, path)); return issues; @@ -107,6 +168,61 @@ function validateHttpTarget(target: Record, path: string): Conf 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 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 validateSizeValue(value: number | string, path: string, targetName?: string): ConfigValidationIssue[] { try { parseSize(value); @@ -137,3 +253,24 @@ function validateStatusValues(values: unknown[], path: string, targetName?: stri } return issues; } + +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/index.ts b/src/server/checker/runner/index.ts index 61a9cef..d55ebbf 100644 --- a/src/server/checker/runner/index.ts +++ b/src/server/checker/runner/index.ts @@ -1,16 +1,15 @@ -import { CommandChecker } from "./command/runner"; -import { HttpChecker } from "./http/runner"; -import { CheckerRegistry, checkerRegistry } from "./registry"; +import { CommandChecker } from "./command"; +import { HttpChecker } from "./http"; +import { CheckerRegistry } from "./registry"; + +const checkers = [new HttpChecker(), new CommandChecker()]; export function createDefaultCheckerRegistry(): CheckerRegistry { const registry = new CheckerRegistry(); - registerCheckers(registry); + for (const checker of checkers) { + registry.register(checker); + } return registry; } -export function registerCheckers(registry = checkerRegistry): void { - registry.register(new HttpChecker()); - registry.register(new CommandChecker()); -} - -export { checkerRegistry } from "./registry"; +export const checkerRegistry = createDefaultCheckerRegistry(); diff --git a/src/server/checker/runner/registry.ts b/src/server/checker/runner/registry.ts index a892142..dee4f1e 100644 --- a/src/server/checker/runner/registry.ts +++ b/src/server/checker/runner/registry.ts @@ -30,5 +30,3 @@ export class CheckerRegistry { return this.checkers.get(type); } } - -export const checkerRegistry = new CheckerRegistry(); diff --git a/src/server/checker/runner/shared/validate.ts b/src/server/checker/runner/shared/validate.ts deleted file mode 100644 index 612503a..0000000 --- a/src/server/checker/runner/shared/validate.ts +++ /dev/null @@ -1,223 +0,0 @@ -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 2bdfef0..3e90b5a 100644 --- a/src/server/checker/runner/types.ts +++ b/src/server/checker/runner/types.ts @@ -1,7 +1,7 @@ import type { TSchema } from "@sinclair/typebox"; -import type { ConfigValidationIssue } from "../config-contract/issues"; -import type { CheckResult, DefaultsConfig, ResolvedTarget, TargetConfig } from "../types"; +import type { ConfigValidationIssue } from "../schema/issues"; +import type { CheckResult, DefaultsConfig, RawTargetConfig, ResolvedTargetBase } from "../types"; export type Checker = CheckerDefinition; @@ -11,10 +11,10 @@ export interface CheckerContext { export interface CheckerDefinition { readonly configKey: string; - execute(target: ResolvedTarget, ctx: CheckerContext): Promise; - resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget; + execute(target: ResolvedTargetBase, ctx: CheckerContext): Promise; + resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTargetBase; readonly schemas: CheckerSchemas; - serialize(target: ResolvedTarget): { config: string; target: string }; + serialize(target: ResolvedTargetBase): { config: string; target: string }; readonly type: string; validate(input: CheckerValidationInput): ConfigValidationIssue[]; } @@ -27,7 +27,7 @@ export interface CheckerSchemas { export interface CheckerValidationInput { defaults: DefaultsConfig; - targets: TargetConfig[]; + targets: RawTargetConfig[]; } export interface ResolveContext { diff --git a/src/server/checker/config-contract/schema.ts b/src/server/checker/schema/builder.ts similarity index 100% rename from src/server/checker/config-contract/schema.ts rename to src/server/checker/schema/builder.ts diff --git a/src/server/checker/config-contract/export.ts b/src/server/checker/schema/export.ts similarity index 78% rename from src/server/checker/config-contract/export.ts rename to src/server/checker/schema/export.ts index 80722ac..04eb696 100644 --- a/src/server/checker/config-contract/export.ts +++ b/src/server/checker/schema/export.ts @@ -1,6 +1,6 @@ import type { CheckerRegistry } from "../runner/registry"; -import { createExternalProbeConfigSchema } from "./schema"; +import { createExternalProbeConfigSchema } from "./builder"; export function createProbeConfigJsonSchema(registry: CheckerRegistry): Record { return createExternalProbeConfigSchema(registry.definitions); diff --git a/src/server/checker/config-contract/fragments.ts b/src/server/checker/schema/fragments.ts similarity index 100% rename from src/server/checker/config-contract/fragments.ts rename to src/server/checker/schema/fragments.ts diff --git a/src/server/checker/config-contract/issues.ts b/src/server/checker/schema/issues.ts similarity index 100% rename from src/server/checker/config-contract/issues.ts rename to src/server/checker/schema/issues.ts diff --git a/src/server/checker/config-contract/types.ts b/src/server/checker/schema/types.ts similarity index 100% rename from src/server/checker/config-contract/types.ts rename to src/server/checker/schema/types.ts diff --git a/src/server/checker/config-contract/validate.ts b/src/server/checker/schema/validate.ts similarity index 98% rename from src/server/checker/config-contract/validate.ts rename to src/server/checker/schema/validate.ts index 37a0aca..403a496 100644 --- a/src/server/checker/config-contract/validate.ts +++ b/src/server/checker/schema/validate.ts @@ -6,8 +6,8 @@ import type { CheckerRegistry } from "../runner/registry"; import type { ConfigValidationIssue } from "./issues"; import type { RawProbeConfig } from "./types"; +import { createProbeConfigSchema, createTargetSchema } from "./builder"; 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 }); diff --git a/src/server/checker/store.ts b/src/server/checker/store.ts index 83bf317..4ea0237 100644 --- a/src/server/checker/store.ts +++ b/src/server/checker/store.ts @@ -2,7 +2,7 @@ import { Database } from "bun:sqlite"; import { mkdirSync as fsMkdirSync } from "node:fs"; import { dirname } from "node:path"; -import type { CheckFailure, ResolvedTarget, StoredCheckResult, StoredTarget } from "./types"; +import type { CheckFailure, ResolvedTargetBase, StoredCheckResult, StoredTarget } from "./types"; import { checkerRegistry } from "./runner"; @@ -257,7 +257,7 @@ export class ProbeStore { ); } - syncTargets(targets: ResolvedTarget[]): void { + syncTargets(targets: ResolvedTargetBase[]): void { if (this.closed) return; const existingRows = this.db.query("SELECT id, name FROM targets").all() as Array<{ id: number; diff --git a/src/server/checker/types.ts b/src/server/checker/types.ts index 0d0eb20..1891933 100644 --- a/src/server/checker/types.ts +++ b/src/server/checker/types.ts @@ -1,41 +1,11 @@ import type { CheckResult as ApiCheckResult, CheckFailure } from "../../shared/api"; -export type BodyRule = - | { contains: string } - | { css: CssRule } - | { json: JsonRule } - | { regex: string } - | { xpath: XpathRule }; - export interface CheckResult extends ApiCheckResult { targetName: string; } -export interface CommandDefaultsConfig { - cwd?: string; - maxOutputBytes?: string; -} - -export interface CommandExpectConfig { - exitCode?: number[]; - maxDurationMs?: number; - stderr?: TextRule[]; - stdout?: TextRule[]; -} - -export interface CommandTargetConfig { - args?: string[]; - cwd?: string; - env?: Record; - exec: string; - maxOutputBytes?: string; -} - -export type CssRule = ExpectOperator & { attr?: string; selector: string }; - export interface DefaultsConfig { - command?: CommandDefaultsConfig; - http?: HttpDefaultsConfig; + [checkerKey: string]: unknown; interval?: string; timeout?: string; } @@ -44,8 +14,6 @@ export interface EngineRuntimeConfig { maxConcurrentChecks?: number; } -export type ExpectConfig = CommandExpectConfig | HttpExpectConfig; - export interface ExpectOperator { contains?: string; empty?: boolean; @@ -60,82 +28,35 @@ export interface ExpectOperator { export type ExpectValue = ExpectOperator | JsonValue; -export type HeaderExpect = ExpectOperator | string; - -export interface HttpDefaultsConfig { - headers?: Record; - maxBodyBytes?: string; - method?: string; -} - -export interface HttpExpectConfig { - body?: BodyRule[]; - headers?: Record; - maxDurationMs?: number; - status?: Array; -} - -export interface HttpTargetConfig { - body?: string; - headers?: Record; - ignoreSSL?: boolean; - maxBodyBytes?: string; - maxRedirects?: number; - method?: string; - url: string; -} - -export type JsonRule = ExpectOperator & { path: string }; - export type JsonValue = boolean | JsonValue[] | null | number | string | { [key: string]: JsonValue }; export interface ProbeConfig { defaults?: DefaultsConfig; runtime?: EngineRuntimeConfig; server?: ServerConfig; - targets: TargetConfig[]; + targets: RawTargetConfig[]; } -export interface ResolvedCommandConfig { - args: string[]; - cwd: string; - env: Record; - exec: string; - maxOutputBytes: number; +export interface RawTargetConfig { + [configKey: string]: unknown; + expect?: unknown; + group?: string; + interval?: string; + name: string; + timeout?: string; + type: string; } -export interface ResolvedCommandTarget { - command: ResolvedCommandConfig; - expect?: CommandExpectConfig; +export interface ResolvedTargetBase { + [key: string]: unknown; + expect?: unknown; group: string; intervalMs: number; name: string; timeoutMs: number; - type: "command"; + type: string; } -export interface ResolvedHttpConfig { - body?: string; - headers: Record; - ignoreSSL: boolean; - maxBodyBytes: number; - maxRedirects: number; - method: string; - url: string; -} - -export interface ResolvedHttpTarget { - expect?: HttpExpectConfig; - group: string; - http: ResolvedHttpConfig; - intervalMs: number; - name: string; - timeoutMs: number; - type: "http"; -} - -export type ResolvedTarget = ResolvedCommandTarget | ResolvedHttpTarget; - export interface ServerConfig { dataDir?: string; host?: string; @@ -161,22 +82,7 @@ export interface StoredTarget { name: string; target: string; timeout_ms: number; - type: TargetType; + type: string; } -export type TargetConfig = BaseTargetConfig & - ({ command: CommandTargetConfig; type: "command" } | { http: HttpTargetConfig; type: "http" }); - export type { CheckFailure }; -export type TargetType = "command" | "http"; -export type TextRule = ExpectOperator; - -export type XpathRule = ExpectOperator & { path: string }; - -interface BaseTargetConfig { - expect?: ExpectConfig; - group?: string; - interval?: string; - name: string; - timeout?: string; -} diff --git a/src/server/checker/size.ts b/src/server/checker/utils.ts similarity index 58% rename from src/server/checker/size.ts rename to src/server/checker/utils.ts index 363479c..b3eda7d 100644 --- a/src/server/checker/size.ts +++ b/src/server/checker/utils.ts @@ -1,5 +1,23 @@ +const DURATION_REGEX = /^(\d+(?:\.\d+)?)(ms|s|m)$/; + const SIZE_REGEX = /^(\d+(?:\.\d+)?)(B|KB|MB|GB)$/; +export function parseDuration(value: string): number { + const match = DURATION_REGEX.exec(value); + if (!match) { + throw new Error(`无效的时长格式: "${value}",支持格式如 "30s"、"5m"、"500ms"`); + } + + const num = parseFloat(match[1]!); + const unit = match[2]!; + + 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; +} + export function parseSize(value: number | string): number { if (typeof value === "number") { if (!Number.isInteger(value) || value < 0 || !Number.isSafeInteger(value)) { diff --git a/src/server/dev.ts b/src/server/dev.ts index 318f99a..1c40a8d 100644 --- a/src/server/dev.ts +++ b/src/server/dev.ts @@ -1,13 +1,10 @@ import { loadConfig } from "./checker/config-loader"; import { ProbeEngine } from "./checker/engine"; -import { registerCheckers } from "./checker/runner"; import { ProbeStore } from "./checker/store"; import { readRuntimeConfig } from "./config"; import { startServer } from "./server"; async function main() { - registerCheckers(); - const { configPath } = readRuntimeConfig(); const config = await loadConfig(configPath); diff --git a/tests/server/app.test.ts b/tests/server/app.test.ts index 09c089c..fc0ee4b 100644 --- a/tests/server/app.test.ts +++ b/tests/server/app.test.ts @@ -7,8 +7,8 @@ import type { HealthResponse, HistoryResponse, SummaryResponse, TargetStatus } f import { createFetchHandler, type StaticAssets } from "../../src/server/app"; import { checkerRegistry } from "../../src/server/checker/runner"; -import { CommandChecker } from "../../src/server/checker/runner/command/runner"; -import { HttpChecker } from "../../src/server/checker/runner/http/runner"; +import { CommandChecker } from "../../src/server/checker/runner/command/execute"; +import { HttpChecker } from "../../src/server/checker/runner/http/execute"; import { ProbeStore } from "../../src/server/checker/store"; import { rmRetry } from "../helpers"; diff --git a/tests/server/checker/config-contract/validate.test.ts b/tests/server/checker/config-contract/validate.test.ts index be0835c..99ebfdd 100644 --- a/tests/server/checker/config-contract/validate.test.ts +++ b/tests/server/checker/config-contract/validate.test.ts @@ -1,10 +1,10 @@ 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"; +import { createProbeConfigJsonSchema } from "../../../../src/server/checker/schema/export"; +import { formatConfigIssues, issue } from "../../../../src/server/checker/schema/issues"; +import { validateProbeConfigContract } from "../../../../src/server/checker/schema/validate"; describe("config contract", () => { test("导出的 probe-config.schema.json 与 fragments 生成结果一致", async () => { diff --git a/tests/server/checker/config-loader.test.ts b/tests/server/checker/config-loader.test.ts index 8c74fe9..6d28c12 100644 --- a/tests/server/checker/config-loader.test.ts +++ b/tests/server/checker/config-loader.test.ts @@ -3,10 +3,13 @@ import { mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import type { ResolvedCommandTarget } from "../../../src/server/checker/runner/command/types"; +import type { ResolvedHttpTarget } from "../../../src/server/checker/runner/http/types"; + import { loadConfig, parseDuration } from "../../../src/server/checker/config-loader"; import { checkerRegistry } from "../../../src/server/checker/runner"; -import { CommandChecker } from "../../../src/server/checker/runner/command/runner"; -import { HttpChecker } from "../../../src/server/checker/runner/http/runner"; +import { CommandChecker } from "../../../src/server/checker/runner/command/execute"; +import { HttpChecker } from "../../../src/server/checker/runner/http/execute"; import { readRuntimeConfig } from "../../../src/server/config"; function ensureRegistered() { @@ -106,19 +109,17 @@ describe("loadConfig", () => { expect(config.dataDir).toBe("./data"); expect(config.maxConcurrentChecks).toBe(20); expect(config.targets).toHaveLength(1); - const t = config.targets[0]!; + const t = config.targets[0]! as ResolvedHttpTarget; expect(t.type).toBe("http"); - if (t.type === "http") { - expect(t.name).toBe("test"); - expect(t.http.url).toBe("http://example.com"); - expect(t.http.method).toBe("GET"); - expect(t.http.headers).toEqual({}); - expect(t.http.ignoreSSL).toBe(false); - expect(t.http.maxBodyBytes).toBe(104857600); - expect(t.http.maxRedirects).toBe(0); - expect(t.intervalMs).toBe(30000); - expect(t.timeoutMs).toBe(10000); - } + expect(t.name).toBe("test"); + expect(t.http.url).toBe("http://example.com"); + expect(t.http.method).toBe("GET"); + expect(t.http.headers).toEqual({}); + expect(t.http.ignoreSSL).toBe(false); + expect(t.http.maxBodyBytes).toBe(104857600); + expect(t.http.maxRedirects).toBe(0); + expect(t.intervalMs).toBe(30000); + expect(t.timeoutMs).toBe(10000); }); test("解析最简 command 配置", async () => { @@ -138,16 +139,14 @@ describe("loadConfig", () => { const config = await loadConfig(configPath); expect(config.targets).toHaveLength(1); - const t = config.targets[0]!; + const t = config.targets[0]! as ResolvedCommandTarget; expect(t.type).toBe("command"); - if (t.type === "command") { - expect(t.name).toBe("check-nginx"); - expect(t.command.exec).toBe("pgrep"); - expect(t.command.args).toEqual(["nginx"]); - expect(t.command.cwd).toBe(subdir); - expect(t.command.maxOutputBytes).toBe(104857600); - expect(t.command.env["PATH"]).toBeDefined(); - } + expect(t.name).toBe("check-nginx"); + expect(t.command.exec).toBe("pgrep"); + expect(t.command.args).toEqual(["nginx"]); + expect(t.command.cwd).toBe(subdir); + expect(t.command.maxOutputBytes).toBe(104857600); + expect(t.command.env["PATH"]).toBeDefined(); }); test("解析完整配置", async () => { @@ -200,27 +199,23 @@ targets: expect(config.maxConcurrentChecks).toBe(5); expect(config.targets).toHaveLength(2); - const http = config.targets[0]!; + const http = config.targets[0]! as ResolvedHttpTarget; expect(http.type).toBe("http"); - if (http.type === "http") { - expect(http.http.url).toBe("http://example.com"); - expect(http.http.method).toBe("POST"); - expect(http.http.headers).toEqual({ Authorization: "Bearer token" }); - expect(http.http.ignoreSSL).toBe(true); - expect(http.http.maxBodyBytes).toBe(52428800); - expect(http.http.maxRedirects).toBe(5); - expect(http.expect?.status).toEqual(["2xx", 301]); - expect(http.intervalMs).toBe(60000); - expect(http.timeoutMs).toBe(5000); - } + expect(http.http.url).toBe("http://example.com"); + expect(http.http.method).toBe("POST"); + expect(http.http.headers).toEqual({ Authorization: "Bearer token" }); + expect(http.http.ignoreSSL).toBe(true); + expect(http.http.maxBodyBytes).toBe(52428800); + expect(http.http.maxRedirects).toBe(5); + expect(http.expect?.status).toEqual(["2xx", 301]); + expect(http.intervalMs).toBe(60000); + expect(http.timeoutMs).toBe(5000); - const cmd = config.targets[1]!; + const cmd = config.targets[1]! as ResolvedCommandTarget; expect(cmd.type).toBe("command"); - if (cmd.type === "command") { - expect(cmd.command.exec).toBe("ls"); - expect(cmd.command.args).toEqual(["/tmp"]); - expect(cmd.command.maxOutputBytes).toBe(10485760); - } + expect(cmd.command.exec).toBe("ls"); + expect(cmd.command.args).toEqual(["/tmp"]); + expect(cmd.command.maxOutputBytes).toBe(10485760); }); test("per-target 覆盖 defaults", async () => { @@ -246,13 +241,11 @@ targets: ); const config = await loadConfig(configPath); - const t = config.targets[0]!; - if (t.type === "http") { - expect(t.http.method).toBe("POST"); - expect(t.intervalMs).toBe(300000); - expect(t.timeoutMs).toBe(30000); - expect(t.http.maxBodyBytes).toBe(1048576); - } + const t = config.targets[0]! as ResolvedHttpTarget; + expect(t.http.method).toBe("POST"); + expect(t.intervalMs).toBe(300000); + expect(t.timeoutMs).toBe(30000); + expect(t.http.maxBodyBytes).toBe(1048576); }); test("配置文件不存在抛出错误", async () => { @@ -564,10 +557,8 @@ targets: ); const config = await loadConfig(configPath); - const t = config.targets[0]!; - if (t.type === "command") { - expect(t.command.cwd).toBe(join(subdir, "scripts")); - } + const t = config.targets[0] as ResolvedCommandTarget; + expect(t.command.cwd).toBe(join(subdir, "scripts")); }); test("command env 覆盖", async () => { @@ -586,12 +577,10 @@ targets: ); const config = await loadConfig(configPath); - const t = config.targets[0]!; - if (t.type === "command") { - expect(t.command.env["LANG"]).toBe("C"); - expect(t.command.env["CUSTOM_VAR"]).toBe("test"); - expect(t.command.env["PATH"]).toBeDefined(); - } + const t = config.targets[0] as ResolvedCommandTarget; + expect(t.command.env["LANG"]).toBe("C"); + expect(t.command.env["CUSTOM_VAR"]).toBe("test"); + expect(t.command.env["PATH"]).toBeDefined(); }); test("解析 group 字段", async () => { @@ -1049,9 +1038,9 @@ targets: `, ); const config = await loadConfig(configPath); - const target = config.targets[0]!; + const target = config.targets[0] as ResolvedHttpTarget; expect(target.type).toBe("http"); - if (target.type === "http") expect(target.http.method).toBe("POST"); + expect(target.http.method).toBe("POST"); }); test("动态 headers 和 env 允许任意键名", async () => { @@ -1082,15 +1071,13 @@ targets: `, ); const config = await loadConfig(configPath); - const http = config.targets[0]!; - const command = config.targets[1]!; + const http = config.targets[0] as ResolvedHttpTarget; + const command = config.targets[1] as ResolvedCommandTarget; 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"); + expect(http.http.headers["X-Default-Header"]).toBe("default"); + expect(http.http.headers["X-Custom-Header"]).toBe("custom"); + expect(command.command.env["CUSTOM_ENV_NAME"]).toBe("custom"); }); test("command args 类型非法", async () => { diff --git a/tests/server/checker/engine.test.ts b/tests/server/checker/engine.test.ts index e2655a7..cb5b0cf 100644 --- a/tests/server/checker/engine.test.ts +++ b/tests/server/checker/engine.test.ts @@ -1,12 +1,14 @@ import { describe, expect, test } from "bun:test"; +import type { ResolvedCommandTarget } from "../../../src/server/checker/runner/command/types"; +import type { ResolvedHttpTarget } from "../../../src/server/checker/runner/http/types"; import type { ProbeStore } from "../../../src/server/checker/store"; -import type { ResolvedCommandTarget, ResolvedHttpTarget, ResolvedTarget } from "../../../src/server/checker/types"; +import type { ResolvedTargetBase } from "../../../src/server/checker/types"; import { ProbeEngine } from "../../../src/server/checker/engine"; import { checkerRegistry } from "../../../src/server/checker/runner"; -import { CommandChecker } from "../../../src/server/checker/runner/command/runner"; -import { HttpChecker } from "../../../src/server/checker/runner/http/runner"; +import { CommandChecker } from "../../../src/server/checker/runner/command/execute"; +import { HttpChecker } from "../../../src/server/checker/runner/http/execute"; function createMockStore(targetNames: string[]) { let nextId = 1; @@ -63,7 +65,7 @@ describe("ProbeEngine", () => { test("start/stop 不抛错", () => { ensureRegistered(); const mockStore = createMockStore(["test"]) as unknown as ProbeStore; - const targets: ResolvedTarget[] = [makeCommandTarget("test")]; + const targets: ResolvedTargetBase[] = [makeCommandTarget("test")]; const engine = new ProbeEngine(mockStore, targets); engine.start(); engine.stop(); @@ -75,9 +77,9 @@ describe("ProbeEngine", () => { const mockStore = createMockStore(["cmd-echo"]) as unknown as ProbeStore; const engine = new ProbeEngine(mockStore, [target]); - const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise }).probeGroup.bind( - engine, - ); + const probeGroup = ( + engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise } + ).probeGroup.bind(engine); await probeGroup([target]); const results = (mockStore as unknown as { _results: Array> })._results; @@ -97,9 +99,9 @@ describe("ProbeEngine", () => { const mockStore = createMockStore(["echo-a", "echo-b"]) as unknown as ProbeStore; const engine = new ProbeEngine(mockStore, [targetA, targetB]); - const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise }).probeGroup.bind( - engine, - ); + const probeGroup = ( + engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise } + ).probeGroup.bind(engine); await probeGroup([targetA, targetB]); const results = (mockStore as unknown as { _results: Array> })._results; @@ -115,9 +117,9 @@ describe("ProbeEngine", () => { const mockStore = createMockStore(["bad-cmd", "good-cmd"]) as unknown as ProbeStore; const engine = new ProbeEngine(mockStore, [badTarget, goodTarget]); - const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise }).probeGroup.bind( - engine, - ); + const probeGroup = ( + engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise } + ).probeGroup.bind(engine); await probeGroup([badTarget, goodTarget]); const results = (mockStore as unknown as { _results: Array> })._results; @@ -139,9 +141,9 @@ describe("ProbeEngine", () => { const mockStore = createMockStore(targets.map((t) => t.name)) as unknown as ProbeStore; const engine = new ProbeEngine(mockStore, targets, 2); - const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise }).probeGroup.bind( - engine, - ); + const probeGroup = ( + engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise } + ).probeGroup.bind(engine); await probeGroup(targets); const results = (mockStore as unknown as { _results: Array> })._results; @@ -168,9 +170,9 @@ describe("ProbeEngine", () => { const mockStore = createMockStore(["other-name"]) as unknown as ProbeStore; const engine = new ProbeEngine(mockStore, [target]); - const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise }).probeGroup.bind( - engine, - ); + const probeGroup = ( + engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise } + ).probeGroup.bind(engine); await probeGroup([target]); const results = (mockStore as unknown as { _results: Array> })._results; @@ -205,9 +207,9 @@ describe("ProbeEngine", () => { const mockStore = createMockStore(["http-test"]) as unknown as ProbeStore; const engine = new ProbeEngine(mockStore, [httpTarget]); - const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise }).probeGroup.bind( - engine, - ); + const probeGroup = ( + engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise } + ).probeGroup.bind(engine); await probeGroup([httpTarget]); const results = (mockStore as unknown as { _results: Array> })._results; diff --git a/tests/server/checker/runner/command/runner.test.ts b/tests/server/checker/runner/command/runner.test.ts index 46dc691..9462a08 100644 --- a/tests/server/checker/runner/command/runner.test.ts +++ b/tests/server/checker/runner/command/runner.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test"; +import type { ResolvedCommandTarget } from "../../../../../src/server/checker/runner/command/types"; import type { CheckerContext } from "../../../../../src/server/checker/runner/types"; -import type { ResolvedCommandTarget } from "../../../../../src/server/checker/types"; -import { CommandChecker } from "../../../../../src/server/checker/runner/command/runner"; +import { CommandChecker } from "../../../../../src/server/checker/runner/command/execute"; const checker = new CommandChecker(); diff --git a/tests/server/checker/runner/http/runner.test.ts b/tests/server/checker/runner/http/runner.test.ts index cd19e05..809a323 100644 --- a/tests/server/checker/runner/http/runner.test.ts +++ b/tests/server/checker/runner/http/runner.test.ts @@ -1,11 +1,11 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import type { ResolvedHttpTarget } from "../../../../../src/server/checker/runner/http/types"; 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 { HttpChecker } from "../../../../../src/server/checker/runner/http/execute"; import { checkStatus } from "../../../../../src/server/checker/runner/http/expect"; -import { HttpChecker } from "../../../../../src/server/checker/runner/http/runner"; +import { formatConfigIssues } from "../../../../../src/server/checker/schema/issues"; const checker = new HttpChecker(); diff --git a/tests/server/checker/runner/registry.test.ts b/tests/server/checker/runner/registry.test.ts index b7d1d4f..d6a4bf5 100644 --- a/tests/server/checker/runner/registry.test.ts +++ b/tests/server/checker/runner/registry.test.ts @@ -2,7 +2,7 @@ import { Type } from "@sinclair/typebox"; import { describe, expect, test } from "bun:test"; import type { Checker } from "../../../../src/server/checker/runner/types"; -import type { CheckResult, ResolvedTarget } from "../../../../src/server/checker/types"; +import type { CheckResult, ResolvedTargetBase } from "../../../../src/server/checker/types"; import { createDefaultCheckerRegistry } from "../../../../src/server/checker/runner"; import { CheckerRegistry } from "../../../../src/server/checker/runner/registry"; @@ -11,7 +11,7 @@ function createChecker(type: string): Checker { return { configKey: type, execute: () => Promise.resolve({} as unknown as CheckResult), - resolve: () => ({}) as unknown as ResolvedTarget, + resolve: () => ({}) as unknown as ResolvedTargetBase, schemas: { config: Type.Object({}, { additionalProperties: false }), defaults: Type.Object({}, { additionalProperties: false }), diff --git a/tests/server/checker/runner/shared/body.test.ts b/tests/server/checker/runner/shared/body.test.ts index 1a192ef..34ae72b 100644 --- a/tests/server/checker/runner/shared/body.test.ts +++ b/tests/server/checker/runner/shared/body.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { checkBodyExpect } from "../../../../../src/server/checker/runner/shared/body"; +import { checkBodyExpect } from "../../../../../src/server/checker/runner/http/body"; describe("checkBodyExpect (BodyRule[])", () => { test("无规则返回匹配成功", () => { diff --git a/tests/server/checker/runner/shared/duration.test.ts b/tests/server/checker/runner/shared/duration.test.ts index 85b7e04..1b35fca 100644 --- a/tests/server/checker/runner/shared/duration.test.ts +++ b/tests/server/checker/runner/shared/duration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { checkDuration } from "../../../../../src/server/checker/runner/shared/duration"; +import { checkDuration } from "../../../../../src/server/checker/expect/duration"; describe("checkDuration", () => { test("未配置 maxDurationMs 返回匹配成功", () => { diff --git a/tests/server/checker/runner/shared/failure.test.ts b/tests/server/checker/runner/shared/failure.test.ts index 3423a90..aedaade 100644 --- a/tests/server/checker/runner/shared/failure.test.ts +++ b/tests/server/checker/runner/shared/failure.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { errorFailure, mismatchFailure, truncateActual } from "../../../../../src/server/checker/runner/shared/failure"; +import { errorFailure, mismatchFailure, truncateActual } from "../../../../../src/server/checker/expect/failure"; describe("truncateActual", () => { test("短字符串不截断", () => { diff --git a/tests/server/checker/runner/shared/operator.test.ts b/tests/server/checker/runner/shared/operator.test.ts index 7e7d774..580901d 100644 --- a/tests/server/checker/runner/shared/operator.test.ts +++ b/tests/server/checker/runner/shared/operator.test.ts @@ -1,10 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { - applyOperator, - checkExpectValue, - evaluateJsonPath, -} from "../../../../../src/server/checker/runner/shared/operator"; +import { applyOperator, checkExpectValue, evaluateJsonPath } from "../../../../../src/server/checker/expect/operator"; describe("evaluateJsonPath", () => { const obj = { diff --git a/tests/server/checker/runner/shared/text.test.ts b/tests/server/checker/runner/shared/text.test.ts index 0edd624..07fb1c6 100644 --- a/tests/server/checker/runner/shared/text.test.ts +++ b/tests/server/checker/runner/shared/text.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { checkTextRules } from "../../../../../src/server/checker/runner/shared/text"; +import { checkTextRules } from "../../../../../src/server/checker/runner/command/text"; describe("checkTextRules", () => { test("无规则返回匹配成功", () => { diff --git a/tests/server/checker/size.test.ts b/tests/server/checker/size.test.ts index 9fcf8e9..e58b844 100644 --- a/tests/server/checker/size.test.ts +++ b/tests/server/checker/size.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { parseSize } from "../../../src/server/checker/size"; +import { parseSize } from "../../../src/server/checker/utils"; describe("parseSize", () => { test("解析 B", () => { diff --git a/tests/server/checker/store.test.ts b/tests/server/checker/store.test.ts index 67a2b26..4f4fac9 100644 --- a/tests/server/checker/store.test.ts +++ b/tests/server/checker/store.test.ts @@ -3,11 +3,13 @@ import { mkdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { CheckFailure, ResolvedTarget } from "../../../src/server/checker/types"; +import type { ResolvedCommandTarget } from "../../../src/server/checker/runner/command/types"; +import type { ResolvedHttpTarget } from "../../../src/server/checker/runner/http/types"; +import type { CheckFailure } from "../../../src/server/checker/types"; import { checkerRegistry } from "../../../src/server/checker/runner"; -import { CommandChecker } from "../../../src/server/checker/runner/command/runner"; -import { HttpChecker } from "../../../src/server/checker/runner/http/runner"; +import { CommandChecker } from "../../../src/server/checker/runner/command/execute"; +import { HttpChecker } from "../../../src/server/checker/runner/http/execute"; import { ProbeStore } from "../../../src/server/checker/store"; import { rmRetry } from "../../helpers"; @@ -22,7 +24,7 @@ beforeAll(() => { ensureRegistered(); }); -const httpTarget: ResolvedTarget = { +const httpTarget: ResolvedHttpTarget = { expect: { maxDurationMs: 3000, status: [200] }, group: "default", http: { @@ -39,7 +41,7 @@ const httpTarget: ResolvedTarget = { type: "http", }; -const commandTarget: ResolvedTarget = { +const commandTarget: ResolvedCommandTarget = { command: { args: ["-c", "1", "localhost"], cwd: "/tmp", @@ -119,7 +121,7 @@ describe("ProbeStore", () => { }); test("同步更新已有 target", () => { - const updated: ResolvedTarget = { + const updated: ResolvedHttpTarget = { ...httpTarget, http: { ...httpTarget.http, url: "https://example.com/v2" }, }; @@ -287,7 +289,7 @@ describe("ProbeStore", () => { test("删除 target 级联删除 check_results", () => { const cascadeStore = new ProbeStore(join(tempDir, "cascade.db")); - const cascadeTarget: ResolvedTarget = { + const cascadeTarget: ResolvedHttpTarget = { group: "default", http: { headers: {},