1
0

refactor: checker 模块内聚化 — 每个 checker 自包含于独立目录

将 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 指南
This commit is contained in:
2026-05-13 14:38:21 +08:00
parent c396c29402
commit bb6b2bc20b
52 changed files with 789 additions and 820 deletions

View File

@@ -34,33 +34,44 @@ src/
history.ts GET /api/targets/:id/history history.ts GET /api/targets/:id/history
trend.ts GET /api/targets/:id/trend trend.ts GET /api/targets/:id/trend
checker/ checker/
types.ts 类型定义 types.ts 基础类型定义ResolvedTargetBase、RawTargetConfig、DefaultsConfig 等 base interface
config-loader.ts YAML 配置解析、契约校验、语义校验与运行期解析 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 数据存储 store.ts SQLite 数据存储
engine.ts 调度引擎(按 interval 分组的 es-toolkit groupBy + Semaphore 并发控制) 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 统一抽象与注册机制 runner/ Checker 统一抽象与注册机制
types.ts CheckerDefinition、CheckerContext、ResolveContext types.ts CheckerDefinition、CheckerContext、CheckerSchemas、ResolveContext
registry.ts CheckerRegistry 注册中心 registry.ts CheckerRegistry 注册中心
index.ts 注册入口(registerCheckers index.ts 注册入口(显式数组 + 循环注册
shared/ 共享 expect 断言和启动期 validator跨 checker 复用 http/ HTTP Checker自包含模块
failure.ts 失败信息类型 index.ts 模块入口re-export HttpChecker
operator.ts 操作符系统applyOperator、evaluateJsonPath types.ts HTTP 专属类型ResolvedHttpTarget、HttpTargetConfig、HttpExpectConfig 等
duration.ts 耗时断言 schema.ts HTTP TypeBox 契约defaults、target.http、expect
text.ts 文本规则断言 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 body.ts Body 规则断言JSONPath/XPath/CSS/contains/regex
validate.ts 共享 operator/text/body 语义校验 command/ Command Checker自包含模块
http/ HTTP Checker 子包 index.ts 模块入口re-export CommandChecker
contract.ts HTTP defaults、target.http、expect TypeBox 契约 types.ts Command 专属类型ResolvedCommandTarget、CommandTargetConfig、CommandExpectConfig 等)
runner.ts HttpCheckerresolve/execute/serialize schema.ts Command TypeBox 契约defaults、target.command、expect
expect.ts HTTP 专用断言status/headers execute.ts CommandChecker 类resolve/execute/serialize/validate
validate.ts HTTP 专属启动期语义校验 expect.ts Command 专用断言checkExitCode
command/ Command Checker 子包 validate.ts Command 语义校验text rules 等)
contract.ts Command defaults、target.command、expect TypeBox 契约 text.ts 文本规则断言checkTextRules
runner.ts CommandCheckerresolve/execute/serialize
expect.ts Command 专用断言exitCode
validate.ts Command 专属启动期语义校验
shared/ shared/
api.ts 前后端共享 TypeScript 类型 api.ts 前后端共享 TypeScript 类型
web/ Vite + React 前端 Dashboard web/ Vite + React 前端 Dashboard
@@ -149,15 +160,19 @@ export function handleXxx(params, store: ProbeStore, method: string, mode: Runti
- **严格联合类型**优先于宽类型:如 `phase: "status" | "duration" | ...` 而非 `phase: string` - **严格联合类型**优先于宽类型:如 `phase: "status" | "duration" | ...` 而非 `phase: string`
- **后端内部扩展**`checker/types.ts``CheckResult` 通过 `extends` 共享版本的 `ApiCheckResult` 增加 `targetName` 等内部字段 - **后端内部扩展**`checker/types.ts``CheckResult` 通过 `extends` 共享版本的 `ApiCheckResult` 增加 `targetName` 等内部字段
- 存储层类型(`StoredTarget``StoredCheckResult`)独立定义,与 API 类型分离 - 存储层类型(`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 配置契约与校验 ### 1.6 配置契约与校验
配置加载流程固定为:`unknown -> RawProbeConfig -> ValidatedProbeConfig -> ResolvedConfig` 配置加载流程固定为:`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` 默认对象策略是 `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 ### 1.7 开发新 Checker
Checker 是本项目的核心扩展单元。得益于插件式注册架构,完成一个新 checker 并注册后,**配置契约组装、引擎调度、数据存储、API 层会自动走 registry 委托链路**,无需在这些中间层添加新的 type 分支 Checker 是本项目的核心扩展单元。架构设计目标是**完全内聚**:每个 checker 是 `src/server/checker/runner/<type>/` 下的自包含目录,包含该 checker 所需的全部类型、schema、校验、执行逻辑和断言。新增一个 checker 只需创建一个目录并在 `runner/index.ts` 中添加一行注册
当前 checker 执行链路已经注册化,但新增 checker 仍需更新中央类型定义、默认注册入口、前端展示常量、配置示例、用户/开发文档和测试。下文清单以这些必要更新为准。
以下以新增 `tcp` 类型 checker 为例,说明完整的开发步骤。 以下以新增 `tcp` 类型 checker 为例,说明完整的开发步骤。
@@ -178,108 +191,74 @@ Checker 是本项目的核心扩展单元。得益于插件式注册架构,完
``` ```
checkerRegistry单例 checkerRegistry单例
├── registerCheckers() ← 注册入口,所有 checker 在此集中注册 ├── runner/index.ts ← 显式数组注册,新增 checker 只需一行
│ ├── HttpChecker │ ├── new HttpChecker()
│ ├── CommandChecker │ ├── new CommandChecker()
│ └── TcpChecker ← 新增 │ └── new TcpChecker() ← 新增
├── config-contract/schema.ts ← 自动遍历 registry 生成全量 JSON Schema ├── schema/builder.ts ← 自动遍历 registry 生成全量 JSON Schema
├── schema/validate.ts ← 自动遍历 registry 构建 Ajv 校验
├── config-loader.ts ← 自动遍历 registry 调用 validate() + resolve() ├── config-loader.ts ← 自动遍历 registry 调用 validate() + resolve()
├── engine.ts ← 自动按 target.type 分发到 execute() ├── engine.ts ← 自动按 target.type 分发到 execute()
└── store.ts ← 自动按 target.type 分发到 serialize() └── store.ts ← 自动按 target.type 分发到 serialize()
``` ```
每个 checker `src/server/checker/runner/<type>/` 下的自包含模块,包含四个文件 每个 checker 目录的标准文件结构
| 文件 | 职责 | | 文件 | 职责 |
| ------------- | ------------------------------------------------------------------------------------- | | ------------- | ------------------------------------------------------------------------------------- |
| `contract.ts` | TypeBox 契约 schemaconfig / defaults / expect 三部分) | | `index.ts` | 模块入口re-export Checker 类 |
| `types.ts` | Checker 专属类型ResolvedXxxTarget、XxxTargetConfig、XxxExpectConfig 等) |
| `schema.ts` | TypeBox 契约 schemaconfig / defaults / expect 三部分) |
| `validate.ts` | 启动期语义校验JSON Schema 无法表达的规则) | | `validate.ts` | 启动期语义校验JSON Schema 无法表达的规则) |
| `runner.ts` | Checker 类resolve默认值合并 + 解析、execute执行检查、serializeDB 持久化) | | `execute.ts` | Checker 类resolve默认值合并 + 解析、execute执行检查、serializeDB 持久化) |
| `expect.ts` | Checker 专用断言函数 | | `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 ```typescript
// 1. 添加 TargetConfigYAML 中 target.tcp 字段的原始类型) import type { ResolvedTargetBase } from "../../types";
export interface TcpTargetConfig { export interface TcpTargetConfig {
host: string; host: string;
port: number; port: number;
timeout?: number; connectTimeout?: number;
} }
// 2. 添加 ExpectConfig 扩展(如果 checker 有专属 expect 字段)
export interface TcpExpectConfig { export interface TcpExpectConfig {
connected?: boolean; connected?: boolean;
maxDurationMs?: number;
} }
// 3. 添加 DefaultsConfigdefaults.tcp 字段)
export interface TcpDefaultsConfig { export interface TcpDefaultsConfig {
timeout?: number; connectTimeout?: number;
} }
// 4. 添加 Resolved 变体(运行期已合并默认值、已解析路径) export interface ResolvedTcpTarget extends ResolvedTargetBase {
export interface ResolvedTcpTarget { expect?: TcpExpectConfig;
type: "tcp";
name: string;
group: string;
intervalMs: number;
timeoutMs: number;
tcp: { tcp: {
connectTimeout: number;
host: string; host: string;
port: number; port: number;
connectTimeout: number;
}; };
expect?: TcpExpectConfig; type: "tcp";
} }
``` ```
然后更新以下联合类型: **注意**:不需要修改顶层 `checker/types.ts`。base interface 使用 index signature`[key: string]: unknown`checker 专属类型通过 `extends ResolvedTargetBase` 自动兼容。
```typescript
// TargetConfig 联合 — 新增一个分支
export type TargetConfig = BaseTargetConfig &
(
| { http: HttpTargetConfig; type: "http" }
| { command: CommandTargetConfig; type: "command" }
| { tcp: TcpTargetConfig; type: "tcp" } // ← 新增
);
// ResolvedTarget 联合
export type ResolvedTarget = ResolvedHttpTarget | ResolvedCommandTarget | ResolvedTcpTarget; // ← 新增
// DefaultsConfig — 新增可选字段
export interface DefaultsConfig {
interval?: string;
timeout?: string;
http?: HttpDefaultsConfig;
command?: CommandDefaultsConfig;
tcp?: TcpDefaultsConfig; // ← 新增
}
// TargetType 联合
export type TargetType = "command" | "http" | "tcp"; // ← 新增
// ExpectConfig — 如有专属字段则扩展
export interface ExpectConfig {
// ... 现有字段
connected?: boolean; // ← TcpChecker 专属(如果复用公共字段则不需要)
}
```
#### 1.7.3 步骤二:创建 TypeBox 契约 Schema #### 1.7.3 步骤二:创建 TypeBox 契约 Schema
`src/server/checker/runner/tcp/contract.ts` 中定义三部分 schema `src/server/checker/runner/tcp/schema.ts` 中定义三部分 schema
```typescript ```typescript
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types"; import type { CheckerSchemas } from "../types";
import { sizeSchema } from "../../config-contract/fragments"; // 复用共享 fragments
export const tcpCheckerSchemas: CheckerSchemas = { export const tcpCheckerSchemas: CheckerSchemas = {
// target.tcp 字段的 schema
config: Type.Object( config: Type.Object(
{ {
host: Type.String(), host: Type.String(),
@@ -289,7 +268,6 @@ export const tcpCheckerSchemas: CheckerSchemas = {
{ additionalProperties: false }, { additionalProperties: false },
), ),
// defaults.tcp 字段的 schema
defaults: Type.Object( defaults: Type.Object(
{ {
connectTimeout: Type.Optional(Type.Integer({ minimum: 100 })), connectTimeout: Type.Optional(Type.Integer({ minimum: 100 })),
@@ -297,7 +275,6 @@ export const tcpCheckerSchemas: CheckerSchemas = {
{ additionalProperties: false }, { additionalProperties: false },
), ),
// target.expect 中 tcp 专属字段的 schema如果无专属字段则用 Type.Object({})
expect: Type.Object( expect: Type.Object(
{ {
connected: Type.Optional(Type.Boolean()), connected: Type.Optional(Type.Boolean()),
@@ -307,12 +284,11 @@ export const tcpCheckerSchemas: CheckerSchemas = {
}; };
``` ```
**可复用的共享 fragments**(来自 `config-contract/fragments.ts` **可复用的共享 fragments**(来自 `schema/fragments.ts`
| Fragment | 用途 | | Fragment | 用途 |
| ---------------------------- | ---------------------------------------------- | | ---------------------------- | ---------------------------------------------- |
| `durationSchema` | 时长字符串(`"30s"``"5m"``"500ms"` | | `durationSchema` | 时长字符串(`"30s"``"5m"``"500ms"` |
| `httpMethodSchema` | HTTP 方法枚举 |
| `sizeSchema` | 大小单位(字符串如 `"10MB"` 或整数) | | `sizeSchema` | 大小单位(字符串如 `"10MB"` 或整数) |
| `statusCodePatternSchema` | 状态码(`100`-`599``"2xx"` | | `statusCodePatternSchema` | 状态码(`100`-`599``"2xx"` |
| `stringMapSchema` | `Record<string, string>`(用于 headers / env | | `stringMapSchema` | `Record<string, string>`(用于 headers / env |
@@ -328,79 +304,64 @@ export const tcpCheckerSchemas: CheckerSchemas = {
`src/server/checker/runner/tcp/validate.ts` 中实现 JSON Schema 无法表达的语义规则: `src/server/checker/runner/tcp/validate.ts` 中实现 JSON Schema 无法表达的语义规则:
```typescript ```typescript
import type { ConfigValidationIssue } from "../../config-contract/issues"; import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types"; import type { CheckerValidationInput } from "../types";
import { issue } from "../../config-contract/issues"; import { issue } from "../../schema/issues";
export function validateTcpConfig(input: CheckerValidationInput): ConfigValidationIssue[] { export function validateTcpConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
// 1. 校验 defaults.tcp如需要
const defaults = input.defaults.tcp;
if (defaults) {
// 语义校验示例connectTimeout 不能超过某个上限
}
// 2. 遍历所有 tcp 类型的 target
for (const target of input.targets) { for (const target of input.targets) {
if (target.type !== "tcp") continue; if (target.type !== "tcp") continue;
const name = target.name; const name = target.name;
const tcp = target["tcp"] as { host?: string } | undefined;
// 校验 target.tcp 中的语义规则 if (tcp && typeof tcp.host === "string" && tcp.host.trim() === "") {
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)); issues.push(issue("invalid-value", "tcp.host", "host 不能为空字符串", name));
} }
} }
// 校验 expect如有公共部分可使用 shared/validate.ts 的工具函数)
// validateBodyRules、validateTextRules、validateOperatorObject 等
}
return issues; 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?)` | 校验操作符对象 | | `validateOperatorObject(ops, path, targetName, options?)` | 校验操作符对象 |
| `validateJsonPath(path, rulePath, targetName)` | 校验 JSONPath 格式 | | `isJsonValue(value)` | 判断是否为合法 JSON 值 |
#### 1.7.5 步骤四:实现 Checker 类 #### 1.7.5 步骤四:实现 Checker 类
`src/server/checker/runner/tcp/runner.ts` 中实现 `CheckerDefinition` 接口的全部成员: `src/server/checker/runner/tcp/execute.ts` 中实现 `CheckerDefinition` 接口的全部成员:
```typescript ```typescript
import type { CheckResult, ResolvedTarget, TargetConfig } from "../../types"; import type { CheckResult, RawTargetConfig, ResolvedTargetBase } from "../../types";
import type { Checker, CheckerContext, CheckerValidationInput, ResolveContext } 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"; import { validateTcpConfig } from "./validate";
export class TcpChecker implements Checker { export class TcpChecker implements Checker {
readonly configKey = "tcp"; // YAML 中 target.tcp / defaults.tcp 的键名 readonly configKey = "tcp";
readonly type = "tcp"; // target.type 的判别值 readonly type = "tcp";
readonly schemas = tcpCheckerSchemas; readonly schemas = tcpCheckerSchemas;
// 启动期语义校验入口 validate(input: CheckerValidationInput) {
validate(input: CheckerValidationInput): ConfigValidationIssue[] {
return validateTcpConfig(input); return validateTcpConfig(input);
} }
// 将原始配置解析为运行期配置(合并默认值、解析路径和单位) resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTargetBase {
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget { const t = target as RawTargetConfig & { tcp: TcpTargetConfig; type: "tcp" };
const t = target as TargetConfig & { tcp: TcpTargetConfig; type: "tcp" }; const defaults = context.defaults["tcp"] as { connectTimeout?: number } | undefined;
const defaults = context.defaults.tcp;
return { return {
expect: target.expect, expect: target.expect as TcpExpectConfig | undefined,
group: target.group ?? "default", group: target.group ?? "default",
intervalMs: context.defaultIntervalMs, intervalMs: context.defaultIntervalMs,
name: t.name, name: t.name,
@@ -414,33 +375,33 @@ export class TcpChecker implements Checker {
} satisfies ResolvedTcpTarget; } satisfies ResolvedTcpTarget;
} }
// 执行实际检查,评估 expect返回 CheckResult async execute(target: ResolvedTargetBase, ctx: CheckerContext): Promise<CheckResult> {
async execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult> {
const t = target as ResolvedTcpTarget; const t = target as ResolvedTcpTarget;
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const start = performance.now(); const start = performance.now();
try { try {
// 执行检查逻辑(如 TCP 连接 // 执行 TCP 连接检查...
// ...
// 评估 expect 规则
// 首个失败即停止,返回 failure
const durationMs = Math.round(performance.now() - start); const durationMs = Math.round(performance.now() - start);
const durationResult = checkDuration(durationMs, t.expect?.maxDurationMs);
if (!durationResult.matched) {
return { return {
durationMs, durationMs,
failure: null, failure: durationResult.failure,
matched: true, matched: false,
statusDetail: "TCP connected", statusDetail: "TCP connected",
targetName: t.name, targetName: t.name,
timestamp, timestamp,
}; };
}
return { durationMs, failure: null, matched: true, statusDetail: "TCP connected", targetName: t.name, timestamp };
} catch (error) { } catch (error) {
const durationMs = Math.round(performance.now() - start); const durationMs = Math.round(performance.now() - start);
return { return {
durationMs, durationMs,
failure: errorFailure("connection", "connection", isError(error) ? error.message : String(error)), failure: errorFailure("connection", "connection", String(error)),
matched: false, matched: false,
statusDetail: null, statusDetail: null,
targetName: t.name, targetName: t.name,
@@ -449,11 +410,10 @@ export class TcpChecker implements Checker {
} }
} }
// 序列化为 DB 存储格式 serialize(target: ResolvedTargetBase): { config: string; target: string } {
serialize(target: ResolvedTarget): { config: string; target: string } {
const t = target as ResolvedTcpTarget; const t = target as ResolvedTcpTarget;
return { 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}`, target: `${t.tcp.host}:${t.tcp.port}`,
}; };
} }
@@ -464,7 +424,7 @@ export class TcpChecker implements Checker {
- 只做默认值合并、路径解析、单位转换,**不执行校验** - 只做默认值合并、路径解析、单位转换,**不执行校验**
- 返回 `satisfies ResolvedXxxTarget` 确保类型正确 - 返回 `satisfies ResolvedXxxTarget` 确保类型正确
- 通过 `context.defaults[this.configKey]` 访问 checker 专属默认值 - 通过 `context.defaults[this.configKey]` 访问 checker 专属默认值(需 `as` 断言为具体类型)
**`execute()` 规范** **`execute()` 规范**
@@ -475,43 +435,59 @@ export class TcpChecker implements Checker {
- 异常时使用 `errorFailure(phase, path, message)` 构造 failure - 异常时使用 `errorFailure(phase, path, message)` 构造 failure
- 不匹配时使用 `mismatchFailure(phase, path, expected, actual, message)` 构造 failure - 不匹配时使用 `mismatchFailure(phase, path, expected, actual, message)` 构造 failure
**可用的共享断言工具**`runner/shared/` **可用的共享断言工具**`checker/expect/`
| 模块 | 函数 | 用途 | | 模块 | 函数 | 用途 |
| ------------- | ----------------------------------------------------- | ---------------------- | | ---------------------- | ----------------------------------------------------- | ---------------------- |
| `failure.ts` | `errorFailure(phase, path, msg)` | 构造错误类型 failure | | `failure.ts` | `errorFailure(phase, path, msg)` | 构造错误类型 failure |
| `failure.ts` | `mismatchFailure(phase, path, expected, actual, msg)` | 构造不匹配类型 failure | | `failure.ts` | `mismatchFailure(phase, path, expected, actual, msg)` | 构造不匹配类型 failure |
| `duration.ts` | `checkDuration(ms, maxMs?)` | 耗时断言 | | `duration.ts` | `checkDuration(ms, maxMs?)` | 耗时断言 |
| `body.ts` | `checkBodyExpect(body, rules)` | Body 规则断言 |
| `text.ts` | `checkTextRules(text, rules, phase)` | 文本规则断言 |
| `operator.ts` | `applyOperator(actual, operator)` | 执行单个操作符比较 | | `operator.ts` | `applyOperator(actual, operator)` | 执行单个操作符比较 |
| `operator.ts` | `evaluateJsonPath(json, path)` | JSONPath 提取 | | `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 ```typescript
import { TcpChecker } from "./tcp/runner"; // ← 新增导入 export { TcpChecker } from "./execute";
```
export function registerCheckers(registry = checkerRegistry): void { `src/server/checker/runner/index.ts` 中添加一行导入和一个数组元素:
registry.register(new HttpChecker());
registry.register(new CommandChecker()); ```typescript
registry.register(new TcpChecker()); // ← 新增注册 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 分支** 注册后,以下管线会通过 registry 自动委托,**无需新增 type 分支**
| 模块 | 自动行为 | | 模块 | 自动行为 |
| ----------------------------- | ------------------------------------------------------------------------ | | -------------------- | ------------------------------------------------------------------------ |
| `config-contract/schema.ts` | 遍历 registry 生成全量 JSON Schemadefaults.tcp + target.tcp + expect | | `schema/builder.ts` | 遍历 registry 生成全量 JSON Schemadefaults.tcp + target.tcp + expect |
| `config-contract/validate.ts` | 按注册 checker 构建 Ajv 校验,自动识别 `type: tcp` | | `schema/validate.ts` | 按注册 checker 构建 Ajv 校验,自动识别 `type: tcp` |
| `config-loader.ts` | 遍历 registry 调用每个 checker 的 `validate()` + `resolve()` | | `config-loader.ts` | 遍历 registry 调用每个 checker 的 `validate()` + `resolve()` |
| `engine.ts` | 按 `target.type` 从 registry 取对应 checker 执行 `execute()` | | `engine.ts` | 按 `target.type` 从 registry 取对应 checker 执行 `execute()` |
| `store.ts` | 按 `target.type` 从 registry 取对应 checker 执行 `serialize()` | | `store.ts` | 按 `target.type` 从 registry 取对应 checker 执行 `serialize()` |
注意:自动适配指上述中间层不需要新增 `switch/case` 或类型分支;开发者仍需按后续步骤更新类型、注册、前端展示、示例、文档和测试。 注意:自动适配指上述中间层不需要新增 `switch/case` 或类型分支;开发者仍需按后续步骤更新前端展示常量、配置示例、文档和测试。
#### 1.7.7 步骤六:更新前端展示 #### 1.7.7 步骤六:更新前端展示
@@ -546,12 +522,13 @@ export function registerCheckers(registry = checkerRegistry): void {
#### 1.7.10 完整检查清单 #### 1.7.10 完整检查清单
``` ```
□ src/server/checker/types.ts — 新增类型接口 + 更新联合类型 □ src/server/checker/runner/tcp/types.ts — 专属类型extends ResolvedTargetBase
□ src/server/checker/runner/tcp/contract.ts — TypeBox schemas □ src/server/checker/runner/tcp/schema.ts — TypeBox schemas
□ src/server/checker/runner/tcp/validate.ts — 语义校验 □ src/server/checker/runner/tcp/validate.ts — 语义校验
□ src/server/checker/runner/tcp/runner.ts — Checker 类 □ src/server/checker/runner/tcp/execute.ts — Checker 类
□ src/server/checker/runner/tcp/expect.ts — 专用断言(如需要) □ src/server/checker/runner/tcp/expect.ts — 专用断言(如需要)
□ src/server/checker/runner/index.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-type-display.ts — 前端类型标签
□ src/web/constants/target-table-filters.ts — 前端类型筛选 □ src/web/constants/target-table-filters.ts — 前端类型筛选
□ tests/ — 契约 + 校验 + resolve + execute + 注册 测试 □ tests/ — 契约 + 校验 + resolve + execute + 注册 测试
@@ -597,7 +574,7 @@ export function registerCheckers(registry = checkerRegistry): void {
### 1.10 expect 断言系统 ### 1.10 expect 断言系统
两层模型:**观测值收集** → **规则校验** 两层模型:**观测值收集** → **规则校验**共享断言基础设施位于 `checker/expect/`checker 专属断言位于各自目录。
**HTTP 校验流程** **HTTP 校验流程**
@@ -617,7 +594,7 @@ CommandChecker.execute → 收集观测(exitCode/stdout/stderr/durationMs)
→ 首个失败即停止 → 首个失败即停止
``` ```
**Body 规则类型** **Body 规则类型**`runner/http/body.ts`
- `contains`:文本包含匹配 - `contains`:文本包含匹配
- `regex`正则表达式匹配注意body 正则字段为 `regex`,不是 `match` - `regex`正则表达式匹配注意body 正则字段为 `regex`,不是 `match`
@@ -625,7 +602,9 @@ CommandChecker.execute → 收集观测(exitCode/stdout/stderr/durationMs)
- `css`cheerio CSS 选择器 + 操作符比较 - `css`cheerio CSS 选择器 + 操作符比较
- `xpath`XPath 节点提取 + 操作符比较 - `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 错误模式 ### 1.11 错误模式

View File

@@ -1,56 +1,56 @@
## 1. 基础设施搭建 ## 1. 基础设施搭建
- [ ] 1.1 创建 `src/server/checker/utils.ts`,将 `size.ts``parseSize``config-loader.ts``parseDuration``DURATION_REGEX` 迁入 - [x] 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` 等共享类型 - [x] 1.2 创建 `src/server/checker/expect/` 目录,创建 `expect/types.ts` 放置 `ExpectResult` 等共享类型
- [ ] 1.3 将 `runner/shared/operator.ts` 迁入为 `expect/operator.ts` - [x] 1.3 将 `runner/shared/operator.ts` 迁入为 `expect/operator.ts`
- [ ] 1.4 将 `runner/shared/failure.ts` 迁入为 `expect/failure.ts` - [x] 1.4 将 `runner/shared/failure.ts` 迁入为 `expect/failure.ts`
- [ ] 1.5 将 `runner/shared/duration.ts` 迁入为 `expect/duration.ts``ExpectResult` 类型提取到 `expect/types.ts` - [x] 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.6 从 `runner/shared/validate.ts` 中提取 `validateOperatorObject``isJsonValue``validateOperatorValue``isPlainRecord``expect/validate-operator.ts`
## 2. Schema 目录重组 ## 2. Schema 目录重组
- [ ] 2.1 将 `config-contract/` 目录重命名为 `schema/` - [x] 2.1 将 `config-contract/` 目录重命名为 `schema/`
- [ ] 2.2 将 `schema/schema.ts` 重命名为 `schema/builder.ts` - [x] 2.2 将 `schema/schema.ts` 重命名为 `schema/builder.ts`
- [ ] 2.3 更新 `schema/` 内部文件的相互引用路径 - [x] 2.3 更新 `schema/` 内部文件的相互引用路径
- [ ] 2.4 更新外部对 `config-contract/` 的所有 import 路径config-loader.ts、runner/shared/validate.ts 等) - [x] 2.4 更新外部对 `config-contract/` 的所有 import 路径config-loader.ts、runner/shared/validate.ts 等)
## 3. 类型系统重构 ## 3. 类型系统重构
- [ ] 3.1 在顶层 `types.ts` 中创建 `ResolvedTargetBase``RawTargetConfig` base interface - [x] 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` - [x] 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` - [x] 3.3 将 Command 专属类型(`CommandTargetConfig``ResolvedCommandTarget``CommandExpectConfig``CommandDefaultsConfig``ResolvedCommandConfig`)迁入 `runner/command/types.ts`
- [ ] 3.4 删除顶层 `types.ts` 中的 `ResolvedTarget` 联合类型和 `TargetConfig` 联合类型,将 `TextRule` 迁入 command/types.ts - [x] 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 - [x] 3.5 将 `DefaultsConfig` 改为宽松 base 形式(仅保留 `interval?``timeout?` + index signature`CommandDefaultsConfig` 迁入 command/types.ts`HttpDefaultsConfig` 迁入 http/types.ts
- [ ] 3.6 更新 `runner/types.ts``CheckerDefinition` 接口签名,使用 `RawTargetConfig``ResolvedTargetBase` - [x] 3.6 更新 `runner/types.ts``CheckerDefinition` 接口签名,使用 `RawTargetConfig``ResolvedTargetBase`
- [ ] 3.7 更新 `engine.ts``store.ts``config-loader.ts` 的类型引用为 `ResolvedTargetBase``RawTargetConfig` - [x] 3.7 更新 `engine.ts``store.ts``config-loader.ts` 的类型引用为 `ResolvedTargetBase``RawTargetConfig`
## 4. HTTP Checker 内聚化 ## 4. HTTP Checker 内聚化
- [ ] 4.1 将 `runner/http/runner.ts` 重命名为 `runner/http/execute.ts` - [x] 4.1 将 `runner/http/runner.ts` 重命名为 `runner/http/execute.ts`
- [ ] 4.2 将 `runner/http/contract.ts` 重命名为 `runner/http/schema.ts` - [x] 4.2 将 `runner/http/contract.ts` 重命名为 `runner/http/schema.ts`
- [ ] 4.3 将 `runner/shared/body.ts` 迁入 `runner/http/body.ts` - [x] 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` - [x] 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` - [x] 4.5 创建 `runner/http/index.ts`re-export `HttpChecker`
- [ ] 4.6 更新 `runner/http/` 内所有文件的 import 路径 - [x] 4.6 更新 `runner/http/` 内所有文件的 import 路径
## 5. Command Checker 内聚化 ## 5. Command Checker 内聚化
- [ ] 5.1 将 `runner/command/runner.ts` 重命名为 `runner/command/execute.ts` - [x] 5.1 将 `runner/command/runner.ts` 重命名为 `runner/command/execute.ts`
- [ ] 5.2 将 `runner/command/contract.ts` 重命名为 `runner/command/schema.ts` - [x] 5.2 将 `runner/command/contract.ts` 重命名为 `runner/command/schema.ts`
- [ ] 5.3 将 `runner/shared/text.ts` 迁入 `runner/command/text.ts` - [x] 5.3 将 `runner/shared/text.ts` 迁入 `runner/command/text.ts`
- [ ] 5.4 将 `runner/shared/validate.ts` 中的 `validateTextRules` 合并到 `runner/command/validate.ts` - [x] 5.4 将 `runner/shared/validate.ts` 中的 `validateTextRules` 合并到 `runner/command/validate.ts`
- [ ] 5.5 创建 `runner/command/index.ts`re-export `CommandChecker` - [x] 5.5 创建 `runner/command/index.ts`re-export `CommandChecker`
- [ ] 5.6 更新 `runner/command/` 内所有文件的 import 路径 - [x] 5.6 更新 `runner/command/` 内所有文件的 import 路径
## 6. 注册入口改造 ## 6. 注册入口改造
- [ ] 6.1 重写 `runner/index.ts` 为显式列表注册模式import 列表 + checker 数组 + 循环注册) - [x] 6.1 重写 `runner/index.ts` 为显式列表注册模式import 列表 + checker 数组 + 循环注册)
- [ ] 6.2 删除 `runner/shared/` 目录(确认所有内容已迁移完毕) - [x] 6.2 删除 `runner/shared/` 目录(确认所有内容已迁移完毕)
- [ ] 6.3 删除 `src/server/checker/size.ts`(已迁入 utils.ts - [x] 6.3 删除 `src/server/checker/size.ts`(已迁入 utils.ts
## 7. 测试与质量保障 ## 7. 测试与质量保障
- [ ] 7.1 更新所有测试文件的 import 路径 - [x] 7.1 更新所有测试文件的 import 路径
- [ ] 7.2 执行完整测试套件,确保所有测试通过 - [x] 7.2 执行完整测试套件,确保所有测试通过
- [ ] 7.3 执行 lint 和格式检查,确保代码质量 - [x] 7.3 执行 lint 和格式检查,确保代码质量
- [ ] 7.4 更新 README.md 和 DEVELOPMENT.md 中涉及 checker 模块结构的描述 - [x] 7.4 确认新增 checker 只需一个目录 + 一行注册

View File

@@ -1,5 +1,5 @@
import { createProbeConfigJsonSchema } from "../src/server/checker/config-contract/export";
import { createDefaultCheckerRegistry } from "../src/server/checker/runner"; import { createDefaultCheckerRegistry } from "../src/server/checker/runner";
import { createProbeConfigJsonSchema } from "../src/server/checker/schema/export";
const schemaPath = "probe-config.schema.json"; const schemaPath = "probe-config.schema.json";
const schema = `${JSON.stringify(createProbeConfigJsonSchema(createDefaultCheckerRegistry()), null, 2)}\n`; const schema = `${JSON.stringify(createProbeConfigJsonSchema(createDefaultCheckerRegistry()), null, 2)}\n`;

View File

@@ -1,12 +1,13 @@
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path";
import type { ConfigValidationIssue } from "./config-contract/issues"; import type { ConfigValidationIssue } from "./schema/issues";
import type { DefaultsConfig, EngineRuntimeConfig, ResolvedTarget, TargetConfig } from "./types"; 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 { 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_HOST = "127.0.0.1";
const DEFAULT_PORT = 3000; const DEFAULT_PORT = 3000;
@@ -21,7 +22,7 @@ export interface ResolvedConfig {
host: string; host: string;
maxConcurrentChecks: number; maxConcurrentChecks: number;
port: number; port: number;
targets: ResolvedTarget[]; targets: ResolvedTargetBase[];
} }
export async function loadConfig(configPath: string): Promise<ResolvedConfig> { export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
@@ -76,13 +77,35 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
const defaultIntervalMs = parseDuration(defaults.interval ?? DEFAULT_INTERVAL); const defaultIntervalMs = parseDuration(defaults.interval ?? DEFAULT_INTERVAL);
const defaultTimeoutMs = parseDuration(defaults.timeout ?? DEFAULT_TIMEOUT); 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), resolveTarget(target, defaults, defaultIntervalMs, defaultTimeoutMs, configDir),
); );
return { configDir, dataDir, host, maxConcurrentChecks, port, targets }; 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<string>();
const result: ConfigValidationIssue[] = [];
for (const item of issues) {
const key = `${item.code}:${item.path}:${item.message}:${item.targetName ?? ""}`;
if (seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export { parseDuration } from "./utils";
function resolveMaxConcurrentChecks(runtime: EngineRuntimeConfig): number { function resolveMaxConcurrentChecks(runtime: EngineRuntimeConfig): number {
if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS; if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS;
if ( if (
@@ -95,12 +118,12 @@ function resolveMaxConcurrentChecks(runtime: EngineRuntimeConfig): number {
} }
function resolveTarget( function resolveTarget(
target: TargetConfig, target: RawTargetConfig,
defaults: DefaultsConfig, defaults: DefaultsConfig,
defaultIntervalMs: number, defaultIntervalMs: number,
defaultTimeoutMs: number, defaultTimeoutMs: number,
configDir: string, configDir: string,
): ResolvedTarget { ): ResolvedTargetBase {
const intervalMs = parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL); const intervalMs = parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL);
const timeoutMs = parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT); const timeoutMs = parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT);
@@ -192,44 +215,6 @@ function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
return issues; 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<string>();
const result: ConfigValidationIssue[] = [];
for (const item of issues) {
const key = `${item.code}:${item.path}:${item.message}:${item.targetName ?? ""}`;
if (seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function validateDurationValue( function validateDurationValue(
value: string | undefined, value: string | undefined,
path: string, path: string,

View File

@@ -1,7 +1,7 @@
import { groupBy, Semaphore } from "es-toolkit"; import { groupBy, Semaphore } from "es-toolkit";
import type { ProbeStore } from "./store"; import type { ProbeStore } from "./store";
import type { CheckResult, ResolvedTarget } from "./types"; import type { CheckResult, ResolvedTargetBase } from "./types";
import { checkerRegistry } from "./runner"; import { checkerRegistry } from "./runner";
@@ -9,10 +9,10 @@ export class ProbeEngine {
private semaphore: Semaphore; private semaphore: Semaphore;
private store: ProbeStore; private store: ProbeStore;
private targetNameToId = new Map<string, number>(); private targetNameToId = new Map<string, number>();
private targets: ResolvedTarget[]; private targets: ResolvedTargetBase[];
private timers: Array<ReturnType<typeof setInterval>> = []; private timers: Array<ReturnType<typeof setInterval>> = [];
constructor(store: ProbeStore, targets: ResolvedTarget[], maxConcurrentChecks?: number) { constructor(store: ProbeStore, targets: ResolvedTargetBase[], maxConcurrentChecks?: number) {
this.store = store; this.store = store;
this.targets = targets; this.targets = targets;
this.semaphore = new Semaphore(maxConcurrentChecks ?? 20); this.semaphore = new Semaphore(maxConcurrentChecks ?? 20);
@@ -40,7 +40,7 @@ export class ProbeEngine {
this.timers = []; this.timers = [];
} }
private async probeGroup(targets: ResolvedTarget[]): Promise<void> { private async probeGroup(targets: ResolvedTargetBase[]): Promise<void> {
const results = await Promise.allSettled( const results = await Promise.allSettled(
targets.map(async (target) => { targets.map(async (target) => {
await this.semaphore.acquire(); await this.semaphore.acquire();
@@ -68,7 +68,7 @@ export class ProbeEngine {
} }
} }
private async runCheck(target: ResolvedTarget): Promise<CheckResult> { private async runCheck(target: ResolvedTargetBase): Promise<CheckResult> {
const checker = checkerRegistry.get(target.type); const checker = checkerRegistry.get(target.type);
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), target.timeoutMs); const timeoutId = setTimeout(() => controller.abort(), target.timeoutMs);

View File

@@ -1,12 +1,7 @@
import type { CheckFailure } from "../../types"; import type { ExpectResult } from "./types";
import { mismatchFailure } from "./failure"; import { mismatchFailure } from "./failure";
export interface ExpectResult {
failure: CheckFailure | null;
matched: boolean;
}
export function checkDuration(durationMs: number, maxDurationMs?: number): ExpectResult { export function checkDuration(durationMs: number, maxDurationMs?: number): ExpectResult {
if (maxDurationMs === undefined) return { failure: null, matched: true }; if (maxDurationMs === undefined) return { failure: null, matched: true };
if (durationMs > maxDurationMs) { if (durationMs > maxDurationMs) {

View File

@@ -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 { export function errorFailure(phase: CheckFailure["phase"], path: string, message: string): CheckFailure {
return { return {

View File

@@ -1,6 +1,6 @@
import { isEmptyObject, isEqual, isNil, isPlainObject } from "es-toolkit"; 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"]); const OPERATOR_KEYS = new Set(["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "match"]);

View File

@@ -0,0 +1,6 @@
import type { CheckFailure } from "../types";
export interface ExpectResult {
failure: CheckFailure | null;
matched: boolean;
}

View File

@@ -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<string>(OperatorKeys);
export function isJsonValue(value: unknown): value is JsonValue {
if (value === null) return true;
if (typeof value === "string" || typeof value === "boolean") return true;
if (typeof value === "number") return Number.isFinite(value);
if (Array.isArray(value)) return value.every(isJsonValue);
if (typeof value === "object") {
return Object.values(value as Record<string, unknown>).every(isJsonValue);
}
return false;
}
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
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)];
}
}

View File

@@ -1,21 +1,16 @@
import { isError } from "es-toolkit"; import { isError } from "es-toolkit";
import { resolve } from "node:path"; import { resolve } from "node:path";
import type { import type { CheckResult, RawTargetConfig, ResolvedTargetBase } from "../../types";
CheckResult,
CommandTargetConfig,
ResolvedCommandTarget,
ResolvedTarget,
TargetConfig,
} from "../../types";
import type { Checker, CheckerContext, CheckerValidationInput, ResolveContext } from "../types"; import type { Checker, CheckerContext, CheckerValidationInput, ResolveContext } from "../types";
import type { CommandExpectConfig, CommandTargetConfig, ResolvedCommandTarget } from "./types";
import { parseSize } from "../../size"; import { checkDuration } from "../../expect/duration";
import { checkDuration } from "../shared/duration"; import { errorFailure } from "../../expect/failure";
import { errorFailure } from "../shared/failure"; import { parseSize } from "../../utils";
import { checkTextRules } from "../shared/text";
import { commandCheckerSchemas } from "./contract";
import { checkExitCode } from "./expect"; import { checkExitCode } from "./expect";
import { commandCheckerSchemas } from "./schema";
import { checkTextRules } from "./text";
import { validateCommandConfig } from "./validate"; import { validateCommandConfig } from "./validate";
export class CommandChecker implements Checker { export class CommandChecker implements Checker {
@@ -25,7 +20,7 @@ export class CommandChecker implements Checker {
readonly type = "command"; readonly type = "command";
async execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult> { async execute(target: ResolvedTargetBase, ctx: CheckerContext): Promise<CheckResult> {
const t = target as ResolvedCommandTarget; const t = target as ResolvedCommandTarget;
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const start = performance.now(); const start = performance.now();
@@ -174,9 +169,9 @@ export class CommandChecker implements Checker {
}; };
} }
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget { resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTargetBase {
const t = target as TargetConfig & { command: CommandTargetConfig; type: "command" }; const t = target as RawTargetConfig & { command: CommandTargetConfig; type: "command" };
const commandDefaults = context.defaults.command; const commandDefaults = context.defaults["command"] as undefined | { cwd?: string; maxOutputBytes?: string };
const cwd = t.command.cwd ?? commandDefaults?.cwd ?? "."; const cwd = t.command.cwd ?? commandDefaults?.cwd ?? ".";
const resolvedCwd = resolve(context.configDir, cwd); const resolvedCwd = resolve(context.configDir, cwd);
@@ -193,7 +188,7 @@ export class CommandChecker implements Checker {
exec: t.command.exec, exec: t.command.exec,
maxOutputBytes, maxOutputBytes,
}, },
expect: target.expect, expect: target.expect as CommandExpectConfig | undefined,
group: target.group ?? "default", group: target.group ?? "default",
intervalMs: context.defaultIntervalMs, intervalMs: context.defaultIntervalMs,
name: t.name, name: t.name,
@@ -202,7 +197,7 @@ export class CommandChecker implements Checker {
} satisfies ResolvedCommandTarget; } satisfies ResolvedCommandTarget;
} }
serialize(target: ResolvedTarget): { config: string; target: string } { serialize(target: ResolvedTargetBase): { config: string; target: string } {
const t = target as ResolvedCommandTarget; const t = target as ResolvedCommandTarget;
const parts = [t.command.exec, ...t.command.args]; const parts = [t.command.exec, ...t.command.args];
return { return {

View File

@@ -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 { export function checkExitCode(exitCode: number, allowed: number[]): ExpectResult {
if (!allowed.includes(exitCode)) { if (!allowed.includes(exitCode)) {

View File

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

View File

@@ -2,7 +2,7 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types"; import type { CheckerSchemas } from "../types";
import { createTextRulesSchema, sizeSchema, stringMapSchema } from "../../config-contract/fragments"; import { createTextRulesSchema, sizeSchema, stringMapSchema } from "../../schema/fragments";
export const commandCheckerSchemas: CheckerSchemas = { export const commandCheckerSchemas: CheckerSchemas = {
config: Type.Object( config: Type.Object(

View File

@@ -1,8 +1,8 @@
import type { TextRule } from "../../types"; import type { ExpectResult } from "../../expect/types";
import type { ExpectResult } from "./duration"; import type { TextRule } from "./types";
import { mismatchFailure } from "./failure"; import { mismatchFailure } from "../../expect/failure";
import { applyOperator } from "./operator"; import { applyOperator } from "../../expect/operator";
export function checkTextRules(text: string, rules: TextRule[], phase: string): ExpectResult { export function checkTextRules(text: string, rules: TextRule[], phase: string): ExpectResult {
for (let i = 0; i < rules.length; i++) { for (let i = 0; i < rules.length; i++) {

View File

@@ -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<string, string>;
exec: string;
maxOutputBytes?: string;
}
export interface ResolvedCommandConfig {
args: string[];
cwd: string;
env: Record<string, string>;
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;

View File

@@ -1,9 +1,9 @@
import type { ConfigValidationIssue } from "../../config-contract/issues"; import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types"; import type { CheckerValidationInput } from "../types";
import { issue, joinPath } from "../../config-contract/issues"; import { validateOperatorObject } from "../../expect/validate-operator";
import { parseSize } from "../../size"; import { issue, joinPath } from "../../schema/issues";
import { validateTextRules } from "../shared/validate"; import { parseSize } from "../../utils";
export function validateCommandConfig(input: CheckerValidationInput): ConfigValidationIssue[] { export function validateCommandConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: 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)]; 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));
}

View File

@@ -2,11 +2,11 @@ import { DOMParser } from "@xmldom/xmldom";
import * as cheerio from "cheerio"; import * as cheerio from "cheerio";
import * as xpath from "xpath"; import * as xpath from "xpath";
import type { BodyRule, CssRule, JsonRule, XpathRule } from "../../types"; import type { ExpectResult } from "../../expect/types";
import type { ExpectResult } from "./duration"; import type { BodyRule, CssRule, JsonRule, XpathRule } from "./types";
import { errorFailure, mismatchFailure } from "./failure"; import { errorFailure, mismatchFailure } from "../../expect/failure";
import { applyOperator, evaluateJsonPath } from "./operator"; import { applyOperator, evaluateJsonPath } from "../../expect/operator";
export function checkBodyExpect(body: string, rules?: BodyRule[]): ExpectResult { export function checkBodyExpect(body: string, rules?: BodyRule[]): ExpectResult {
if (!rules || rules.length === 0) return { failure: null, matched: true }; if (!rules || rules.length === 0) return { failure: null, matched: true };

View File

@@ -1,14 +1,15 @@
import { isError } from "es-toolkit"; 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 { Checker, CheckerContext, CheckerValidationInput, ResolveContext } from "../types";
import type { HttpExpectConfig, HttpTargetConfig, ResolvedHttpTarget } from "./types";
import { parseSize } from "../../size"; import { checkDuration } from "../../expect/duration";
import { checkBodyExpect } from "../shared/body"; import { errorFailure, mismatchFailure } from "../../expect/failure";
import { checkDuration } from "../shared/duration"; import { parseSize } from "../../utils";
import { errorFailure, mismatchFailure } from "../shared/failure"; import { checkBodyExpect } from "./body";
import { httpCheckerSchemas } from "./contract";
import { checkHeaders, checkStatus } from "./expect"; import { checkHeaders, checkStatus } from "./expect";
import { httpCheckerSchemas } from "./schema";
import { validateHttpConfig } from "./validate"; import { validateHttpConfig } from "./validate";
const CHARSET_RE = /charset="?([^";\s]+)"?/i; const CHARSET_RE = /charset="?([^";\s]+)"?/i;
@@ -22,7 +23,7 @@ export class HttpChecker implements Checker {
readonly type = "http"; readonly type = "http";
async execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult> { async execute(target: ResolvedTargetBase, ctx: CheckerContext): Promise<CheckResult> {
const t = target as ResolvedHttpTarget; const t = target as ResolvedHttpTarget;
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const expect = t.expect; const expect = t.expect;
@@ -116,15 +117,17 @@ export class HttpChecker implements Checker {
} }
} }
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget { resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTargetBase {
const t = target as TargetConfig & { http: HttpTargetConfig; type: "http" }; const t = target as RawTargetConfig & { http: HttpTargetConfig; type: "http" };
const httpDefaults = context.defaults.http; const httpDefaults = context.defaults["http"] as
| undefined
| { headers?: Record<string, string>; maxBodyBytes?: string; method?: string };
const method = t.http.method ?? httpDefaults?.method ?? "GET"; const method = t.http.method ?? httpDefaults?.method ?? "GET";
const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB"); const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB");
return { return {
expect: target.expect, expect: target.expect as HttpExpectConfig | undefined,
group: target.group ?? "default", group: target.group ?? "default",
http: { http: {
body: t.http.body, body: t.http.body,
@@ -142,7 +145,7 @@ export class HttpChecker implements Checker {
} satisfies ResolvedHttpTarget; } satisfies ResolvedHttpTarget;
} }
serialize(target: ResolvedTarget): { config: string; target: string } { serialize(target: ResolvedTargetBase): { config: string; target: string } {
const t = target as ResolvedHttpTarget; const t = target as ResolvedHttpTarget;
return { return {
config: JSON.stringify({ config: JSON.stringify({

View File

@@ -1,8 +1,8 @@
import type { HeaderExpect } from "../../types"; import type { ExpectResult } from "../../expect/types";
import type { ExpectResult } from "../shared/duration"; import type { HeaderExpect } from "./types";
import { mismatchFailure } from "../shared/failure"; import { mismatchFailure } from "../../expect/failure";
import { applyOperator } from "../shared/operator"; import { applyOperator } from "../../expect/operator";
export function checkHeaders( export function checkHeaders(
headers: Record<string, string>, headers: Record<string, string>,

View File

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

View File

@@ -9,7 +9,7 @@ import {
sizeSchema, sizeSchema,
statusCodePatternSchema, statusCodePatternSchema,
stringMapSchema, stringMapSchema,
} from "../../config-contract/fragments"; } from "../../schema/fragments";
export const httpCheckerSchemas: CheckerSchemas = { export const httpCheckerSchemas: CheckerSchemas = {
config: Type.Object( config: Type.Object(

View File

@@ -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<string, string>;
maxBodyBytes?: string;
method?: string;
}
export interface HttpExpectConfig {
body?: BodyRule[];
headers?: Record<string, HeaderExpect>;
maxDurationMs?: number;
status?: Array<number | string>;
}
export interface HttpTargetConfig {
body?: string;
headers?: Record<string, string>;
ignoreSSL?: boolean;
maxBodyBytes?: string;
maxRedirects?: number;
method?: string;
url: string;
}
export type JsonRule = ExpectOperator & { path: string };
export interface ResolvedHttpConfig {
body?: string;
headers: Record<string, string>;
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 };

View File

@@ -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 type { CheckerValidationInput } from "../types";
import { issue, joinPath } from "../../config-contract/issues"; import { isPlainRecord, validateOperatorObject } from "../../expect/validate-operator";
import { parseSize } from "../../size"; import { BodyRuleTypeKeys, OperatorKeys } from "../../schema/fragments";
import { validateBodyRules, validateOperatorObject } from "../shared/validate"; import { issue, joinPath } from "../../schema/issues";
import { parseSize } from "../../utils";
const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]); const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
const OPERATOR_KEY_SET = new Set<string>(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[] { export function validateHttpConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: 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"])) { if (isSizeInput(defaults?.["maxBodyBytes"])) {
issues.push(...validateSizeValue(defaults["maxBodyBytes"], "defaults.http.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++) { for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown; const target = input.targets[i] as unknown;
if (!isRecord(target)) continue; if (!isPlainRecord(target)) continue;
if (target["type"] !== "http") continue; if (target["type"] !== "http") continue;
issues.push(...validateHttpTarget(target, `targets[${i}]`)); issues.push(...validateHttpTarget(target, `targets[${i}]`));
} }
@@ -25,6 +36,43 @@ export function validateHttpConfig(input: CheckerValidationInput): ConfigValidat
return issues; 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<string, unknown>,
allowedKeys: Set<string>,
path: string,
targetName?: string,
): { issues: ConfigValidationIssue[]; operators: Record<string, unknown> } {
const issues: ConfigValidationIssue[] = [];
const operators: Record<string, unknown> = {};
for (const [key, value] of Object.entries(object)) {
if (allowedKeys.has(key)) continue;
if (OPERATOR_KEY_SET.has(key)) {
operators[key] = value;
} else {
issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName));
}
}
return { issues, operators };
}
function getTargetName(target: Record<string, unknown>): string | undefined { function getTargetName(target: Record<string, unknown>): string | undefined {
return typeof target["name"] === "string" ? target["name"] : 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; return typeof value === "number" && Number.isFinite(value) && value >= 0;
} }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isSizeInput(value: unknown): value is number | string { function isSizeInput(value: unknown): value is number | string {
return typeof value === "number" || typeof value === "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<string, unknown>, path: string): ConfigValidationIssue[] { function validateHttpExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const targetName = getTargetName(target); const targetName = getTargetName(target);
const expect = target["expect"]; const expect = target["expect"];
if (expect === undefined || expect === null || !isRecord(expect)) return []; if (expect === undefined || expect === null || !isPlainRecord(expect)) return [];
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect"); const expectPath = joinPath(path, "expect");
if (isRecord(expect["headers"])) { if (isPlainRecord(expect["headers"])) {
for (const [key, value] of Object.entries(expect["headers"])) { for (const [key, value] of Object.entries(expect["headers"])) {
if (typeof value === "string") continue; if (typeof value === "string") continue;
issues.push(...validateOperatorObject(value, joinPath(joinPath(expectPath, "headers"), key), targetName)); issues.push(...validateOperatorObject(value, joinPath(joinPath(expectPath, "headers"), key), targetName));
@@ -74,7 +135,7 @@ function validateHttpTarget(target: Record<string, unknown>, path: string): Conf
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target); const targetName = getTargetName(target);
const http = target["http"]; const http = target["http"];
if (!isRecord(http)) { if (!isPlainRecord(http)) {
issues.push(issue("required", joinPath(path, "http"), "缺少 http.url 字段", targetName)); issues.push(issue("required", joinPath(path, "http"), "缺少 http.url 字段", targetName));
issues.push(...validateHttpExpect(target, path)); issues.push(...validateHttpExpect(target, path));
return issues; return issues;
@@ -107,6 +168,61 @@ function validateHttpTarget(target: Record<string, unknown>, path: string): Conf
return issues; 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[] { function validateSizeValue(value: number | string, path: string, targetName?: string): ConfigValidationIssue[] {
try { try {
parseSize(value); parseSize(value);
@@ -137,3 +253,24 @@ function validateStatusValues(values: unknown[], path: string, targetName?: stri
} }
return issues; 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("<x/>", "text/xml");
xpath.select(rule["path"], doc as unknown as Node);
} catch {
issues.push(issue("invalid-xpath", joinPath(path, "path"), "xpath 不合法", targetName));
}
}
const result = collectOperatorObject(rule, new Set(["path"]), path, targetName);
issues.push(
...result.issues,
...validateOperatorObject(result.operators, path, targetName, { requireAtLeastOne: false }),
);
return issues;
}

View File

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

View File

@@ -30,5 +30,3 @@ export class CheckerRegistry {
return this.checkers.get(type); return this.checkers.get(type);
} }
} }
export const checkerRegistry = new CheckerRegistry();

View File

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

View File

@@ -1,7 +1,7 @@
import type { TSchema } from "@sinclair/typebox"; import type { TSchema } from "@sinclair/typebox";
import type { ConfigValidationIssue } from "../config-contract/issues"; import type { ConfigValidationIssue } from "../schema/issues";
import type { CheckResult, DefaultsConfig, ResolvedTarget, TargetConfig } from "../types"; import type { CheckResult, DefaultsConfig, RawTargetConfig, ResolvedTargetBase } from "../types";
export type Checker = CheckerDefinition; export type Checker = CheckerDefinition;
@@ -11,10 +11,10 @@ export interface CheckerContext {
export interface CheckerDefinition { export interface CheckerDefinition {
readonly configKey: string; readonly configKey: string;
execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult>; execute(target: ResolvedTargetBase, ctx: CheckerContext): Promise<CheckResult>;
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget; resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTargetBase;
readonly schemas: CheckerSchemas; readonly schemas: CheckerSchemas;
serialize(target: ResolvedTarget): { config: string; target: string }; serialize(target: ResolvedTargetBase): { config: string; target: string };
readonly type: string; readonly type: string;
validate(input: CheckerValidationInput): ConfigValidationIssue[]; validate(input: CheckerValidationInput): ConfigValidationIssue[];
} }
@@ -27,7 +27,7 @@ export interface CheckerSchemas {
export interface CheckerValidationInput { export interface CheckerValidationInput {
defaults: DefaultsConfig; defaults: DefaultsConfig;
targets: TargetConfig[]; targets: RawTargetConfig[];
} }
export interface ResolveContext { export interface ResolveContext {

View File

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

View File

@@ -6,8 +6,8 @@ import type { CheckerRegistry } from "../runner/registry";
import type { ConfigValidationIssue } from "./issues"; import type { ConfigValidationIssue } from "./issues";
import type { RawProbeConfig } from "./types"; import type { RawProbeConfig } from "./types";
import { createProbeConfigSchema, createTargetSchema } from "./builder";
import { issue } from "./issues"; import { issue } from "./issues";
import { createProbeConfigSchema, createTargetSchema } from "./schema";
export function createConfigAjv(): Ajv { export function createConfigAjv(): Ajv {
return new Ajv({ allErrors: true, coerceTypes: false, removeAdditional: false, strict: true, useDefaults: false }); return new Ajv({ allErrors: true, coerceTypes: false, removeAdditional: false, strict: true, useDefaults: false });

View File

@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
import { mkdirSync as fsMkdirSync } from "node:fs"; import { mkdirSync as fsMkdirSync } from "node:fs";
import { dirname } from "node:path"; 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"; import { checkerRegistry } from "./runner";
@@ -257,7 +257,7 @@ export class ProbeStore {
); );
} }
syncTargets(targets: ResolvedTarget[]): void { syncTargets(targets: ResolvedTargetBase[]): void {
if (this.closed) return; if (this.closed) return;
const existingRows = this.db.query("SELECT id, name FROM targets").all() as Array<{ const existingRows = this.db.query("SELECT id, name FROM targets").all() as Array<{
id: number; id: number;

View File

@@ -1,41 +1,11 @@
import type { CheckResult as ApiCheckResult, CheckFailure } from "../../shared/api"; 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 { export interface CheckResult extends ApiCheckResult {
targetName: string; 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<string, string>;
exec: string;
maxOutputBytes?: string;
}
export type CssRule = ExpectOperator & { attr?: string; selector: string };
export interface DefaultsConfig { export interface DefaultsConfig {
command?: CommandDefaultsConfig; [checkerKey: string]: unknown;
http?: HttpDefaultsConfig;
interval?: string; interval?: string;
timeout?: string; timeout?: string;
} }
@@ -44,8 +14,6 @@ export interface EngineRuntimeConfig {
maxConcurrentChecks?: number; maxConcurrentChecks?: number;
} }
export type ExpectConfig = CommandExpectConfig | HttpExpectConfig;
export interface ExpectOperator { export interface ExpectOperator {
contains?: string; contains?: string;
empty?: boolean; empty?: boolean;
@@ -60,82 +28,35 @@ export interface ExpectOperator {
export type ExpectValue = ExpectOperator | JsonValue; export type ExpectValue = ExpectOperator | JsonValue;
export type HeaderExpect = ExpectOperator | string;
export interface HttpDefaultsConfig {
headers?: Record<string, string>;
maxBodyBytes?: string;
method?: string;
}
export interface HttpExpectConfig {
body?: BodyRule[];
headers?: Record<string, HeaderExpect>;
maxDurationMs?: number;
status?: Array<number | string>;
}
export interface HttpTargetConfig {
body?: string;
headers?: Record<string, string>;
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 type JsonValue = boolean | JsonValue[] | null | number | string | { [key: string]: JsonValue };
export interface ProbeConfig { export interface ProbeConfig {
defaults?: DefaultsConfig; defaults?: DefaultsConfig;
runtime?: EngineRuntimeConfig; runtime?: EngineRuntimeConfig;
server?: ServerConfig; server?: ServerConfig;
targets: TargetConfig[]; targets: RawTargetConfig[];
} }
export interface ResolvedCommandConfig { export interface RawTargetConfig {
args: string[]; [configKey: string]: unknown;
cwd: string; expect?: unknown;
env: Record<string, string>; group?: string;
exec: string; interval?: string;
maxOutputBytes: number; name: string;
timeout?: string;
type: string;
} }
export interface ResolvedCommandTarget { export interface ResolvedTargetBase {
command: ResolvedCommandConfig; [key: string]: unknown;
expect?: CommandExpectConfig; expect?: unknown;
group: string; group: string;
intervalMs: number; intervalMs: number;
name: string; name: string;
timeoutMs: number; timeoutMs: number;
type: "command"; type: string;
} }
export interface ResolvedHttpConfig {
body?: string;
headers: Record<string, string>;
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 { export interface ServerConfig {
dataDir?: string; dataDir?: string;
host?: string; host?: string;
@@ -161,22 +82,7 @@ export interface StoredTarget {
name: string; name: string;
target: string; target: string;
timeout_ms: number; timeout_ms: number;
type: TargetType; type: string;
} }
export type TargetConfig = BaseTargetConfig &
({ command: CommandTargetConfig; type: "command" } | { http: HttpTargetConfig; type: "http" });
export type { CheckFailure }; 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;
}

View File

@@ -1,5 +1,23 @@
const DURATION_REGEX = /^(\d+(?:\.\d+)?)(ms|s|m)$/;
const SIZE_REGEX = /^(\d+(?:\.\d+)?)(B|KB|MB|GB)$/; 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 { export function parseSize(value: number | string): number {
if (typeof value === "number") { if (typeof value === "number") {
if (!Number.isInteger(value) || value < 0 || !Number.isSafeInteger(value)) { if (!Number.isInteger(value) || value < 0 || !Number.isSafeInteger(value)) {

View File

@@ -1,13 +1,10 @@
import { loadConfig } from "./checker/config-loader"; import { loadConfig } from "./checker/config-loader";
import { ProbeEngine } from "./checker/engine"; import { ProbeEngine } from "./checker/engine";
import { registerCheckers } from "./checker/runner";
import { ProbeStore } from "./checker/store"; import { ProbeStore } from "./checker/store";
import { readRuntimeConfig } from "./config"; import { readRuntimeConfig } from "./config";
import { startServer } from "./server"; import { startServer } from "./server";
async function main() { async function main() {
registerCheckers();
const { configPath } = readRuntimeConfig(); const { configPath } = readRuntimeConfig();
const config = await loadConfig(configPath); const config = await loadConfig(configPath);

View File

@@ -7,8 +7,8 @@ import type { HealthResponse, HistoryResponse, SummaryResponse, TargetStatus } f
import { createFetchHandler, type StaticAssets } from "../../src/server/app"; import { createFetchHandler, type StaticAssets } from "../../src/server/app";
import { checkerRegistry } from "../../src/server/checker/runner"; import { checkerRegistry } from "../../src/server/checker/runner";
import { CommandChecker } from "../../src/server/checker/runner/command/runner"; import { CommandChecker } from "../../src/server/checker/runner/command/execute";
import { HttpChecker } from "../../src/server/checker/runner/http/runner"; import { HttpChecker } from "../../src/server/checker/runner/http/execute";
import { ProbeStore } from "../../src/server/checker/store"; import { ProbeStore } from "../../src/server/checker/store";
import { rmRetry } from "../helpers"; import { rmRetry } from "../helpers";

View File

@@ -1,10 +1,10 @@
import Ajv from "ajv"; import Ajv from "ajv";
import { describe, expect, test } from "bun:test"; 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 { 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", () => { describe("config contract", () => {
test("导出的 probe-config.schema.json 与 fragments 生成结果一致", async () => { test("导出的 probe-config.schema.json 与 fragments 生成结果一致", async () => {

View File

@@ -3,10 +3,13 @@ import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; 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 { loadConfig, parseDuration } from "../../../src/server/checker/config-loader";
import { checkerRegistry } from "../../../src/server/checker/runner"; import { checkerRegistry } from "../../../src/server/checker/runner";
import { CommandChecker } from "../../../src/server/checker/runner/command/runner"; import { CommandChecker } from "../../../src/server/checker/runner/command/execute";
import { HttpChecker } from "../../../src/server/checker/runner/http/runner"; import { HttpChecker } from "../../../src/server/checker/runner/http/execute";
import { readRuntimeConfig } from "../../../src/server/config"; import { readRuntimeConfig } from "../../../src/server/config";
function ensureRegistered() { function ensureRegistered() {
@@ -106,9 +109,8 @@ describe("loadConfig", () => {
expect(config.dataDir).toBe("./data"); expect(config.dataDir).toBe("./data");
expect(config.maxConcurrentChecks).toBe(20); expect(config.maxConcurrentChecks).toBe(20);
expect(config.targets).toHaveLength(1); expect(config.targets).toHaveLength(1);
const t = config.targets[0]!; const t = config.targets[0]! as ResolvedHttpTarget;
expect(t.type).toBe("http"); expect(t.type).toBe("http");
if (t.type === "http") {
expect(t.name).toBe("test"); expect(t.name).toBe("test");
expect(t.http.url).toBe("http://example.com"); expect(t.http.url).toBe("http://example.com");
expect(t.http.method).toBe("GET"); expect(t.http.method).toBe("GET");
@@ -118,7 +120,6 @@ describe("loadConfig", () => {
expect(t.http.maxRedirects).toBe(0); expect(t.http.maxRedirects).toBe(0);
expect(t.intervalMs).toBe(30000); expect(t.intervalMs).toBe(30000);
expect(t.timeoutMs).toBe(10000); expect(t.timeoutMs).toBe(10000);
}
}); });
test("解析最简 command 配置", async () => { test("解析最简 command 配置", async () => {
@@ -138,16 +139,14 @@ describe("loadConfig", () => {
const config = await loadConfig(configPath); const config = await loadConfig(configPath);
expect(config.targets).toHaveLength(1); expect(config.targets).toHaveLength(1);
const t = config.targets[0]!; const t = config.targets[0]! as ResolvedCommandTarget;
expect(t.type).toBe("command"); expect(t.type).toBe("command");
if (t.type === "command") {
expect(t.name).toBe("check-nginx"); expect(t.name).toBe("check-nginx");
expect(t.command.exec).toBe("pgrep"); expect(t.command.exec).toBe("pgrep");
expect(t.command.args).toEqual(["nginx"]); expect(t.command.args).toEqual(["nginx"]);
expect(t.command.cwd).toBe(subdir); expect(t.command.cwd).toBe(subdir);
expect(t.command.maxOutputBytes).toBe(104857600); expect(t.command.maxOutputBytes).toBe(104857600);
expect(t.command.env["PATH"]).toBeDefined(); expect(t.command.env["PATH"]).toBeDefined();
}
}); });
test("解析完整配置", async () => { test("解析完整配置", async () => {
@@ -200,9 +199,8 @@ targets:
expect(config.maxConcurrentChecks).toBe(5); expect(config.maxConcurrentChecks).toBe(5);
expect(config.targets).toHaveLength(2); expect(config.targets).toHaveLength(2);
const http = config.targets[0]!; const http = config.targets[0]! as ResolvedHttpTarget;
expect(http.type).toBe("http"); expect(http.type).toBe("http");
if (http.type === "http") {
expect(http.http.url).toBe("http://example.com"); expect(http.http.url).toBe("http://example.com");
expect(http.http.method).toBe("POST"); expect(http.http.method).toBe("POST");
expect(http.http.headers).toEqual({ Authorization: "Bearer token" }); expect(http.http.headers).toEqual({ Authorization: "Bearer token" });
@@ -212,15 +210,12 @@ targets:
expect(http.expect?.status).toEqual(["2xx", 301]); expect(http.expect?.status).toEqual(["2xx", 301]);
expect(http.intervalMs).toBe(60000); expect(http.intervalMs).toBe(60000);
expect(http.timeoutMs).toBe(5000); expect(http.timeoutMs).toBe(5000);
}
const cmd = config.targets[1]!; const cmd = config.targets[1]! as ResolvedCommandTarget;
expect(cmd.type).toBe("command"); expect(cmd.type).toBe("command");
if (cmd.type === "command") {
expect(cmd.command.exec).toBe("ls"); expect(cmd.command.exec).toBe("ls");
expect(cmd.command.args).toEqual(["/tmp"]); expect(cmd.command.args).toEqual(["/tmp"]);
expect(cmd.command.maxOutputBytes).toBe(10485760); expect(cmd.command.maxOutputBytes).toBe(10485760);
}
}); });
test("per-target 覆盖 defaults", async () => { test("per-target 覆盖 defaults", async () => {
@@ -246,13 +241,11 @@ targets:
); );
const config = await loadConfig(configPath); const config = await loadConfig(configPath);
const t = config.targets[0]!; const t = config.targets[0]! as ResolvedHttpTarget;
if (t.type === "http") {
expect(t.http.method).toBe("POST"); expect(t.http.method).toBe("POST");
expect(t.intervalMs).toBe(300000); expect(t.intervalMs).toBe(300000);
expect(t.timeoutMs).toBe(30000); expect(t.timeoutMs).toBe(30000);
expect(t.http.maxBodyBytes).toBe(1048576); expect(t.http.maxBodyBytes).toBe(1048576);
}
}); });
test("配置文件不存在抛出错误", async () => { test("配置文件不存在抛出错误", async () => {
@@ -564,10 +557,8 @@ targets:
); );
const config = await loadConfig(configPath); const config = await loadConfig(configPath);
const t = config.targets[0]!; const t = config.targets[0] as ResolvedCommandTarget;
if (t.type === "command") {
expect(t.command.cwd).toBe(join(subdir, "scripts")); expect(t.command.cwd).toBe(join(subdir, "scripts"));
}
}); });
test("command env 覆盖", async () => { test("command env 覆盖", async () => {
@@ -586,12 +577,10 @@ targets:
); );
const config = await loadConfig(configPath); const config = await loadConfig(configPath);
const t = config.targets[0]!; const t = config.targets[0] as ResolvedCommandTarget;
if (t.type === "command") {
expect(t.command.env["LANG"]).toBe("C"); expect(t.command.env["LANG"]).toBe("C");
expect(t.command.env["CUSTOM_VAR"]).toBe("test"); expect(t.command.env["CUSTOM_VAR"]).toBe("test");
expect(t.command.env["PATH"]).toBeDefined(); expect(t.command.env["PATH"]).toBeDefined();
}
}); });
test("解析 group 字段", async () => { test("解析 group 字段", async () => {
@@ -1049,9 +1038,9 @@ targets:
`, `,
); );
const config = await loadConfig(configPath); const config = await loadConfig(configPath);
const target = config.targets[0]!; const target = config.targets[0] as ResolvedHttpTarget;
expect(target.type).toBe("http"); 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 () => { test("动态 headers 和 env 允许任意键名", async () => {
@@ -1082,15 +1071,13 @@ targets:
`, `,
); );
const config = await loadConfig(configPath); const config = await loadConfig(configPath);
const http = config.targets[0]!; const http = config.targets[0] as ResolvedHttpTarget;
const command = config.targets[1]!; const command = config.targets[1] as ResolvedCommandTarget;
expect(http.type).toBe("http"); expect(http.type).toBe("http");
expect(command.type).toBe("command"); expect(command.type).toBe("command");
if (http.type === "http") {
expect(http.http.headers["X-Default-Header"]).toBe("default"); expect(http.http.headers["X-Default-Header"]).toBe("default");
expect(http.http.headers["X-Custom-Header"]).toBe("custom"); expect(http.http.headers["X-Custom-Header"]).toBe("custom");
} expect(command.command.env["CUSTOM_ENV_NAME"]).toBe("custom");
if (command.type === "command") expect(command.command.env["CUSTOM_ENV_NAME"]).toBe("custom");
}); });
test("command args 类型非法", async () => { test("command args 类型非法", async () => {

View File

@@ -1,12 +1,14 @@
import { describe, expect, test } from "bun:test"; 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 { 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 { ProbeEngine } from "../../../src/server/checker/engine";
import { checkerRegistry } from "../../../src/server/checker/runner"; import { checkerRegistry } from "../../../src/server/checker/runner";
import { CommandChecker } from "../../../src/server/checker/runner/command/runner"; import { CommandChecker } from "../../../src/server/checker/runner/command/execute";
import { HttpChecker } from "../../../src/server/checker/runner/http/runner"; import { HttpChecker } from "../../../src/server/checker/runner/http/execute";
function createMockStore(targetNames: string[]) { function createMockStore(targetNames: string[]) {
let nextId = 1; let nextId = 1;
@@ -63,7 +65,7 @@ describe("ProbeEngine", () => {
test("start/stop 不抛错", () => { test("start/stop 不抛错", () => {
ensureRegistered(); ensureRegistered();
const mockStore = createMockStore(["test"]) as unknown as ProbeStore; const mockStore = createMockStore(["test"]) as unknown as ProbeStore;
const targets: ResolvedTarget[] = [makeCommandTarget("test")]; const targets: ResolvedTargetBase[] = [makeCommandTarget("test")];
const engine = new ProbeEngine(mockStore, targets); const engine = new ProbeEngine(mockStore, targets);
engine.start(); engine.start();
engine.stop(); engine.stop();
@@ -75,9 +77,9 @@ describe("ProbeEngine", () => {
const mockStore = createMockStore(["cmd-echo"]) as unknown as ProbeStore; const mockStore = createMockStore(["cmd-echo"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [target]); const engine = new ProbeEngine(mockStore, [target]);
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind( const probeGroup = (
engine, engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
); ).probeGroup.bind(engine);
await probeGroup([target]); await probeGroup([target]);
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results; const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
@@ -97,9 +99,9 @@ describe("ProbeEngine", () => {
const mockStore = createMockStore(["echo-a", "echo-b"]) as unknown as ProbeStore; const mockStore = createMockStore(["echo-a", "echo-b"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [targetA, targetB]); const engine = new ProbeEngine(mockStore, [targetA, targetB]);
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind( const probeGroup = (
engine, engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
); ).probeGroup.bind(engine);
await probeGroup([targetA, targetB]); await probeGroup([targetA, targetB]);
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results; const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
@@ -115,9 +117,9 @@ describe("ProbeEngine", () => {
const mockStore = createMockStore(["bad-cmd", "good-cmd"]) as unknown as ProbeStore; const mockStore = createMockStore(["bad-cmd", "good-cmd"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [badTarget, goodTarget]); const engine = new ProbeEngine(mockStore, [badTarget, goodTarget]);
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind( const probeGroup = (
engine, engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
); ).probeGroup.bind(engine);
await probeGroup([badTarget, goodTarget]); await probeGroup([badTarget, goodTarget]);
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results; const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
@@ -139,9 +141,9 @@ describe("ProbeEngine", () => {
const mockStore = createMockStore(targets.map((t) => t.name)) as unknown as ProbeStore; const mockStore = createMockStore(targets.map((t) => t.name)) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, targets, 2); const engine = new ProbeEngine(mockStore, targets, 2);
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind( const probeGroup = (
engine, engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
); ).probeGroup.bind(engine);
await probeGroup(targets); await probeGroup(targets);
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results; const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
@@ -168,9 +170,9 @@ describe("ProbeEngine", () => {
const mockStore = createMockStore(["other-name"]) as unknown as ProbeStore; const mockStore = createMockStore(["other-name"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [target]); const engine = new ProbeEngine(mockStore, [target]);
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind( const probeGroup = (
engine, engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
); ).probeGroup.bind(engine);
await probeGroup([target]); await probeGroup([target]);
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results; const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
@@ -205,9 +207,9 @@ describe("ProbeEngine", () => {
const mockStore = createMockStore(["http-test"]) as unknown as ProbeStore; const mockStore = createMockStore(["http-test"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [httpTarget]); const engine = new ProbeEngine(mockStore, [httpTarget]);
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind( const probeGroup = (
engine, engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
); ).probeGroup.bind(engine);
await probeGroup([httpTarget]); await probeGroup([httpTarget]);
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results; const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;

View File

@@ -1,9 +1,9 @@
import { describe, expect, test } from "bun:test"; 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 { 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(); const checker = new CommandChecker();

View File

@@ -1,11 +1,11 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test"; 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 { 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 { 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(); const checker = new HttpChecker();

View File

@@ -2,7 +2,7 @@ import { Type } from "@sinclair/typebox";
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import type { Checker } from "../../../../src/server/checker/runner/types"; 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 { createDefaultCheckerRegistry } from "../../../../src/server/checker/runner";
import { CheckerRegistry } from "../../../../src/server/checker/runner/registry"; import { CheckerRegistry } from "../../../../src/server/checker/runner/registry";
@@ -11,7 +11,7 @@ function createChecker(type: string): Checker {
return { return {
configKey: type, configKey: type,
execute: () => Promise.resolve<CheckResult>({} as unknown as CheckResult), execute: () => Promise.resolve<CheckResult>({} as unknown as CheckResult),
resolve: () => ({}) as unknown as ResolvedTarget, resolve: () => ({}) as unknown as ResolvedTargetBase,
schemas: { schemas: {
config: Type.Object({}, { additionalProperties: false }), config: Type.Object({}, { additionalProperties: false }),
defaults: Type.Object({}, { additionalProperties: false }), defaults: Type.Object({}, { additionalProperties: false }),

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"; 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[])", () => { describe("checkBodyExpect (BodyRule[])", () => {
test("无规则返回匹配成功", () => { test("无规则返回匹配成功", () => {

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"; 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", () => { describe("checkDuration", () => {
test("未配置 maxDurationMs 返回匹配成功", () => { test("未配置 maxDurationMs 返回匹配成功", () => {

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"; 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", () => { describe("truncateActual", () => {
test("短字符串不截断", () => { test("短字符串不截断", () => {

View File

@@ -1,10 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { import { applyOperator, checkExpectValue, evaluateJsonPath } from "../../../../../src/server/checker/expect/operator";
applyOperator,
checkExpectValue,
evaluateJsonPath,
} from "../../../../../src/server/checker/runner/shared/operator";
describe("evaluateJsonPath", () => { describe("evaluateJsonPath", () => {
const obj = { const obj = {

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"; 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", () => { describe("checkTextRules", () => {
test("无规则返回匹配成功", () => { test("无规则返回匹配成功", () => {

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { parseSize } from "../../../src/server/checker/size"; import { parseSize } from "../../../src/server/checker/utils";
describe("parseSize", () => { describe("parseSize", () => {
test("解析 B", () => { test("解析 B", () => {

View File

@@ -3,11 +3,13 @@ import { mkdir } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; 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 { checkerRegistry } from "../../../src/server/checker/runner";
import { CommandChecker } from "../../../src/server/checker/runner/command/runner"; import { CommandChecker } from "../../../src/server/checker/runner/command/execute";
import { HttpChecker } from "../../../src/server/checker/runner/http/runner"; import { HttpChecker } from "../../../src/server/checker/runner/http/execute";
import { ProbeStore } from "../../../src/server/checker/store"; import { ProbeStore } from "../../../src/server/checker/store";
import { rmRetry } from "../../helpers"; import { rmRetry } from "../../helpers";
@@ -22,7 +24,7 @@ beforeAll(() => {
ensureRegistered(); ensureRegistered();
}); });
const httpTarget: ResolvedTarget = { const httpTarget: ResolvedHttpTarget = {
expect: { maxDurationMs: 3000, status: [200] }, expect: { maxDurationMs: 3000, status: [200] },
group: "default", group: "default",
http: { http: {
@@ -39,7 +41,7 @@ const httpTarget: ResolvedTarget = {
type: "http", type: "http",
}; };
const commandTarget: ResolvedTarget = { const commandTarget: ResolvedCommandTarget = {
command: { command: {
args: ["-c", "1", "localhost"], args: ["-c", "1", "localhost"],
cwd: "/tmp", cwd: "/tmp",
@@ -119,7 +121,7 @@ describe("ProbeStore", () => {
}); });
test("同步更新已有 target", () => { test("同步更新已有 target", () => {
const updated: ResolvedTarget = { const updated: ResolvedHttpTarget = {
...httpTarget, ...httpTarget,
http: { ...httpTarget.http, url: "https://example.com/v2" }, http: { ...httpTarget.http, url: "https://example.com/v2" },
}; };
@@ -287,7 +289,7 @@ describe("ProbeStore", () => {
test("删除 target 级联删除 check_results", () => { test("删除 target 级联删除 check_results", () => {
const cascadeStore = new ProbeStore(join(tempDir, "cascade.db")); const cascadeStore = new ProbeStore(join(tempDir, "cascade.db"));
const cascadeTarget: ResolvedTarget = { const cascadeTarget: ResolvedHttpTarget = {
group: "default", group: "default",
http: { http: {
headers: {}, headers: {},