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

View File

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

View File

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

View File

@@ -1,12 +1,13 @@
import { dirname, resolve } from "node:path";
import type { ConfigValidationIssue } from "./config-contract/issues";
import type { DefaultsConfig, EngineRuntimeConfig, ResolvedTarget, TargetConfig } from "./types";
import type { ConfigValidationIssue } from "./schema/issues";
import type { DefaultsConfig, EngineRuntimeConfig, RawTargetConfig, ResolvedTargetBase } from "./types";
import { issue, throwConfigIssues } from "./config-contract/issues";
import { asValidatedConfig, type RawProbeConfig } from "./config-contract/types";
import { validateProbeConfigContract } from "./config-contract/validate";
import { checkerRegistry } from "./runner";
import { issue, throwConfigIssues } from "./schema/issues";
import { asValidatedConfig, type RawProbeConfig } from "./schema/types";
import { validateProbeConfigContract } from "./schema/validate";
import { parseDuration } from "./utils";
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 3000;
@@ -21,7 +22,7 @@ export interface ResolvedConfig {
host: string;
maxConcurrentChecks: number;
port: number;
targets: ResolvedTarget[];
targets: ResolvedTargetBase[];
}
export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
@@ -76,13 +77,35 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
const defaultIntervalMs = parseDuration(defaults.interval ?? DEFAULT_INTERVAL);
const defaultTimeoutMs = parseDuration(defaults.timeout ?? DEFAULT_TIMEOUT);
const targets: ResolvedTarget[] = validated.targets.map((target) =>
const targets: ResolvedTargetBase[] = validated.targets.map((target) =>
resolveTarget(target, defaults, defaultIntervalMs, defaultTimeoutMs, configDir),
);
return { configDir, dataDir, host, maxConcurrentChecks, port, targets };
}
function canRunSemanticValidation(value: unknown): boolean {
return typeof value === "object" && value !== null;
}
function dedupeIssues(issues: ConfigValidationIssue[]): ConfigValidationIssue[] {
const seen = new Set<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 {
if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS;
if (
@@ -95,12 +118,12 @@ function resolveMaxConcurrentChecks(runtime: EngineRuntimeConfig): number {
}
function resolveTarget(
target: TargetConfig,
target: RawTargetConfig,
defaults: DefaultsConfig,
defaultIntervalMs: number,
defaultTimeoutMs: number,
configDir: string,
): ResolvedTarget {
): ResolvedTargetBase {
const intervalMs = parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL);
const timeoutMs = parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT);
@@ -192,44 +215,6 @@ function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
return issues;
}
const DURATION_REGEX = /^(\d+(?:\.\d+)?)(ms|s|m)$/;
export function parseDuration(value: string): number {
const match = DURATION_REGEX.exec(value);
if (!match) {
throw new Error(`无效的时长格式: "${value}",支持格式如 "30s"、"5m"、"500ms"`);
}
const num = parseFloat(match[1]!);
const unit = match[2]!;
const durationMs = unit === "ms" ? num : unit === "s" ? num * 1000 : num * 60 * 1000;
if (!Number.isInteger(durationMs) || durationMs <= 0 || !Number.isFinite(durationMs)) {
throw new Error(`无效的时长格式: "${value}",解析结果必须为正整数毫秒`);
}
return durationMs;
}
function canRunSemanticValidation(value: unknown): boolean {
return typeof value === "object" && value !== null;
}
function dedupeIssues(issues: ConfigValidationIssue[]): ConfigValidationIssue[] {
const seen = new Set<string>();
const result: ConfigValidationIssue[] = [];
for (const item of issues) {
const key = `${item.code}:${item.path}:${item.message}:${item.targetName ?? ""}`;
if (seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function validateDurationValue(
value: string | undefined,
path: string,

View File

@@ -1,7 +1,7 @@
import { groupBy, Semaphore } from "es-toolkit";
import type { ProbeStore } from "./store";
import type { CheckResult, ResolvedTarget } from "./types";
import type { CheckResult, ResolvedTargetBase } from "./types";
import { checkerRegistry } from "./runner";
@@ -9,10 +9,10 @@ export class ProbeEngine {
private semaphore: Semaphore;
private store: ProbeStore;
private targetNameToId = new Map<string, number>();
private targets: ResolvedTarget[];
private targets: ResolvedTargetBase[];
private timers: Array<ReturnType<typeof setInterval>> = [];
constructor(store: ProbeStore, targets: ResolvedTarget[], maxConcurrentChecks?: number) {
constructor(store: ProbeStore, targets: ResolvedTargetBase[], maxConcurrentChecks?: number) {
this.store = store;
this.targets = targets;
this.semaphore = new Semaphore(maxConcurrentChecks ?? 20);
@@ -40,7 +40,7 @@ export class ProbeEngine {
this.timers = [];
}
private async probeGroup(targets: ResolvedTarget[]): Promise<void> {
private async probeGroup(targets: ResolvedTargetBase[]): Promise<void> {
const results = await Promise.allSettled(
targets.map(async (target) => {
await this.semaphore.acquire();
@@ -68,7 +68,7 @@ export class ProbeEngine {
}
}
private async runCheck(target: ResolvedTarget): Promise<CheckResult> {
private async runCheck(target: ResolvedTargetBase): Promise<CheckResult> {
const checker = checkerRegistry.get(target.type);
const controller = new AbortController();
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";
export interface ExpectResult {
failure: CheckFailure | null;
matched: boolean;
}
export function checkDuration(durationMs: number, maxDurationMs?: number): ExpectResult {
if (maxDurationMs === undefined) return { failure: null, matched: true };
if (durationMs > maxDurationMs) {

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 {
return {

View File

@@ -1,6 +1,6 @@
import { isEmptyObject, isEqual, isNil, isPlainObject } from "es-toolkit";
import type { ExpectOperator, ExpectValue } from "../../types";
import type { ExpectOperator, ExpectValue } from "../types";
const OPERATOR_KEYS = new Set(["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "match"]);

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

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 {
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 { createTextRulesSchema, sizeSchema, stringMapSchema } from "../../config-contract/fragments";
import { createTextRulesSchema, sizeSchema, stringMapSchema } from "../../schema/fragments";
export const commandCheckerSchemas: CheckerSchemas = {
config: Type.Object(

View File

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

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 { issue, joinPath } from "../../config-contract/issues";
import { parseSize } from "../../size";
import { validateTextRules } from "../shared/validate";
import { validateOperatorObject } from "../../expect/validate-operator";
import { issue, joinPath } from "../../schema/issues";
import { parseSize } from "../../utils";
export function validateCommandConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
@@ -91,3 +91,8 @@ function validateSizeValue(value: number | string, path: string, targetName?: st
return [issue("invalid-size", path, error instanceof Error ? error.message : "size 格式不合法", targetName)];
}
}
function validateTextRules(rules: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!Array.isArray(rules)) return [issue("invalid-type", path, "必须为数组", targetName)];
return rules.flatMap((rule, index) => validateOperatorObject(rule, `${path}[${index}]`, targetName));
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ import {
sizeSchema,
statusCodePatternSchema,
stringMapSchema,
} from "../../config-contract/fragments";
} from "../../schema/fragments";
export const httpCheckerSchemas: CheckerSchemas = {
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 { issue, joinPath } from "../../config-contract/issues";
import { parseSize } from "../../size";
import { validateBodyRules, validateOperatorObject } from "../shared/validate";
import { isPlainRecord, validateOperatorObject } from "../../expect/validate-operator";
import { BodyRuleTypeKeys, OperatorKeys } from "../../schema/fragments";
import { issue, joinPath } from "../../schema/issues";
import { parseSize } from "../../utils";
const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
const OPERATOR_KEY_SET = new Set<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[] {
const issues: ConfigValidationIssue[] = [];
const defaults = isRecord(input.defaults) && isRecord(input.defaults["http"]) ? input.defaults["http"] : undefined;
const defaults =
isPlainRecord(input.defaults) && isPlainRecord(input.defaults["http"]) ? input.defaults["http"] : undefined;
if (isSizeInput(defaults?.["maxBodyBytes"])) {
issues.push(...validateSizeValue(defaults["maxBodyBytes"], "defaults.http.maxBodyBytes"));
@@ -17,7 +28,7 @@ export function validateHttpConfig(input: CheckerValidationInput): ConfigValidat
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isRecord(target)) continue;
if (!isPlainRecord(target)) continue;
if (target["type"] !== "http") continue;
issues.push(...validateHttpTarget(target, `targets[${i}]`));
}
@@ -25,6 +36,43 @@ export function validateHttpConfig(input: CheckerValidationInput): ConfigValidat
return issues;
}
export function validateJsonPath(path: string, rulePath: string, targetName?: string): ConfigValidationIssue[] {
if (!path.startsWith("$.") || path.length <= 2) {
return [issue("invalid-jsonpath", joinPath(rulePath, "path"), '必须为以 "$." 开头的有效 JSONPath', targetName)];
}
const issues: ConfigValidationIssue[] = [];
const segments = path.slice(2).split(".");
for (const seg of segments) {
if (seg === "") {
issues.push(issue("invalid-jsonpath", joinPath(rulePath, "path"), "包含空段", targetName));
}
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
if (bracketMatch?.[1]!.trim() === "") {
issues.push(issue("invalid-jsonpath", joinPath(rulePath, "path"), "数组访问缺少属性名", targetName));
}
}
return issues;
}
function collectOperatorObject(
object: Record<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 {
return typeof target["name"] === "string" ? target["name"] : undefined;
}
@@ -33,22 +81,35 @@ function isNonNegativeFiniteNumber(value: unknown): boolean {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isSizeInput(value: unknown): value is number | string {
return typeof value === "number" || typeof value === "string";
}
function 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[] {
const targetName = getTargetName(target);
const expect = target["expect"];
if (expect === undefined || expect === null || !isRecord(expect)) return [];
if (expect === undefined || expect === null || !isPlainRecord(expect)) return [];
const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect");
if (isRecord(expect["headers"])) {
if (isPlainRecord(expect["headers"])) {
for (const [key, value] of Object.entries(expect["headers"])) {
if (typeof value === "string") continue;
issues.push(...validateOperatorObject(value, joinPath(joinPath(expectPath, "headers"), key), targetName));
@@ -74,7 +135,7 @@ function validateHttpTarget(target: Record<string, unknown>, path: string): Conf
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const http = target["http"];
if (!isRecord(http)) {
if (!isPlainRecord(http)) {
issues.push(issue("required", joinPath(path, "http"), "缺少 http.url 字段", targetName));
issues.push(...validateHttpExpect(target, path));
return issues;
@@ -107,6 +168,61 @@ function validateHttpTarget(target: Record<string, unknown>, path: string): Conf
return issues;
}
function validateJsonRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (typeof rule["path"] !== "string") {
issues.push(issue("invalid-type", joinPath(path, "path"), "必须为字符串", targetName));
} else {
issues.push(...validateJsonPath(rule["path"], path, targetName));
}
const result = collectOperatorObject(rule, new Set(["path"]), path, targetName);
issues.push(
...result.issues,
...validateOperatorObject(result.operators, path, targetName, { requireAtLeastOne: false }),
);
return issues;
}
function validateRegexRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (typeof rule !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)];
try {
new RegExp(rule);
return [];
} catch {
return [issue("invalid-regex", path, "正则不合法", targetName)];
}
}
function validateSingleBodyRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const found = BodyRuleTypeKeys.filter((type) => type in rule);
if (found.length === 0) return [issue("missing-body-rule", path, "缺少支持的规则类型", targetName)];
if (found.length > 1) return [issue("multiple-body-rules", path, "只能配置一种规则类型", targetName)];
const ruleType = found[0]!;
const issues: ConfigValidationIssue[] = [];
for (const key of Object.keys(rule)) {
if (key !== ruleType) issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName));
}
if (issues.length > 0) return issues;
switch (ruleType) {
case "contains":
return typeof rule["contains"] === "string"
? []
: [issue("invalid-type", joinPath(path, "contains"), "必须为字符串", targetName)];
case "css":
return validateCssRule(rule["css"], joinPath(path, "css"), targetName);
case "json":
return validateJsonRule(rule["json"], joinPath(path, "json"), targetName);
case "regex":
return validateRegexRule(rule["regex"], joinPath(path, "regex"), targetName);
case "xpath":
return validateXpathRule(rule["xpath"], joinPath(path, "xpath"), targetName);
}
}
function validateSizeValue(value: number | string, path: string, targetName?: string): ConfigValidationIssue[] {
try {
parseSize(value);
@@ -137,3 +253,24 @@ function validateStatusValues(values: unknown[], path: string, targetName?: stri
}
return issues;
}
function validateXpathRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (typeof rule["path"] !== "string" || rule["path"].trim() === "") {
issues.push(issue("invalid-type", joinPath(path, "path"), "必须为非空字符串", targetName));
} else {
try {
const doc = new DOMParser().parseFromString("<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 { HttpChecker } from "./http/runner";
import { CheckerRegistry, checkerRegistry } from "./registry";
import { CommandChecker } from "./command";
import { HttpChecker } from "./http";
import { CheckerRegistry } from "./registry";
const checkers = [new HttpChecker(), new CommandChecker()];
export function createDefaultCheckerRegistry(): CheckerRegistry {
const registry = new CheckerRegistry();
registerCheckers(registry);
for (const checker of checkers) {
registry.register(checker);
}
return registry;
}
export function registerCheckers(registry = checkerRegistry): void {
registry.register(new HttpChecker());
registry.register(new CommandChecker());
}
export { checkerRegistry } from "./registry";
export const checkerRegistry = createDefaultCheckerRegistry();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,23 @@
const DURATION_REGEX = /^(\d+(?:\.\d+)?)(ms|s|m)$/;
const SIZE_REGEX = /^(\d+(?:\.\d+)?)(B|KB|MB|GB)$/;
export function parseDuration(value: string): number {
const match = DURATION_REGEX.exec(value);
if (!match) {
throw new Error(`无效的时长格式: "${value}",支持格式如 "30s"、"5m"、"500ms"`);
}
const num = parseFloat(match[1]!);
const unit = match[2]!;
const durationMs = unit === "ms" ? num : unit === "s" ? num * 1000 : num * 60 * 1000;
if (!Number.isInteger(durationMs) || durationMs <= 0 || !Number.isFinite(durationMs)) {
throw new Error(`无效的时长格式: "${value}",解析结果必须为正整数毫秒`);
}
return durationMs;
}
export function parseSize(value: number | string): number {
if (typeof value === "number") {
if (!Number.isInteger(value) || value < 0 || !Number.isSafeInteger(value)) {

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
import Ajv from "ajv";
import { describe, expect, test } from "bun:test";
import { createProbeConfigJsonSchema } from "../../../../src/server/checker/config-contract/export";
import { formatConfigIssues, issue } from "../../../../src/server/checker/config-contract/issues";
import { validateProbeConfigContract } from "../../../../src/server/checker/config-contract/validate";
import { createDefaultCheckerRegistry } from "../../../../src/server/checker/runner";
import { createProbeConfigJsonSchema } from "../../../../src/server/checker/schema/export";
import { formatConfigIssues, issue } from "../../../../src/server/checker/schema/issues";
import { validateProbeConfigContract } from "../../../../src/server/checker/schema/validate";
describe("config contract", () => {
test("导出的 probe-config.schema.json 与 fragments 生成结果一致", async () => {

View File

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

View File

@@ -1,12 +1,14 @@
import { describe, expect, test } from "bun:test";
import type { ResolvedCommandTarget } from "../../../src/server/checker/runner/command/types";
import type { ResolvedHttpTarget } from "../../../src/server/checker/runner/http/types";
import type { ProbeStore } from "../../../src/server/checker/store";
import type { ResolvedCommandTarget, ResolvedHttpTarget, ResolvedTarget } from "../../../src/server/checker/types";
import type { ResolvedTargetBase } from "../../../src/server/checker/types";
import { ProbeEngine } from "../../../src/server/checker/engine";
import { checkerRegistry } from "../../../src/server/checker/runner";
import { CommandChecker } from "../../../src/server/checker/runner/command/runner";
import { HttpChecker } from "../../../src/server/checker/runner/http/runner";
import { CommandChecker } from "../../../src/server/checker/runner/command/execute";
import { HttpChecker } from "../../../src/server/checker/runner/http/execute";
function createMockStore(targetNames: string[]) {
let nextId = 1;
@@ -63,7 +65,7 @@ describe("ProbeEngine", () => {
test("start/stop 不抛错", () => {
ensureRegistered();
const mockStore = createMockStore(["test"]) as unknown as ProbeStore;
const targets: ResolvedTarget[] = [makeCommandTarget("test")];
const targets: ResolvedTargetBase[] = [makeCommandTarget("test")];
const engine = new ProbeEngine(mockStore, targets);
engine.start();
engine.stop();
@@ -75,9 +77,9 @@ describe("ProbeEngine", () => {
const mockStore = createMockStore(["cmd-echo"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [target]);
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(
engine,
);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([target]);
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 engine = new ProbeEngine(mockStore, [targetA, targetB]);
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(
engine,
);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([targetA, targetB]);
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 engine = new ProbeEngine(mockStore, [badTarget, goodTarget]);
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(
engine,
);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([badTarget, goodTarget]);
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 engine = new ProbeEngine(mockStore, targets, 2);
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(
engine,
);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup(targets);
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 engine = new ProbeEngine(mockStore, [target]);
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(
engine,
);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([target]);
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 engine = new ProbeEngine(mockStore, [httpTarget]);
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(
engine,
);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([httpTarget]);
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 type { ResolvedCommandTarget } from "../../../../../src/server/checker/runner/command/types";
import type { CheckerContext } from "../../../../../src/server/checker/runner/types";
import type { ResolvedCommandTarget } from "../../../../../src/server/checker/types";
import { CommandChecker } from "../../../../../src/server/checker/runner/command/runner";
import { CommandChecker } from "../../../../../src/server/checker/runner/command/execute";
const checker = new CommandChecker();

View File

@@ -1,11 +1,11 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { ResolvedHttpTarget } from "../../../../../src/server/checker/runner/http/types";
import type { CheckerContext, ResolveContext } from "../../../../../src/server/checker/runner/types";
import type { ResolvedHttpTarget } from "../../../../../src/server/checker/types";
import { formatConfigIssues } from "../../../../../src/server/checker/config-contract/issues";
import { HttpChecker } from "../../../../../src/server/checker/runner/http/execute";
import { checkStatus } from "../../../../../src/server/checker/runner/http/expect";
import { HttpChecker } from "../../../../../src/server/checker/runner/http/runner";
import { formatConfigIssues } from "../../../../../src/server/checker/schema/issues";
const checker = new HttpChecker();

View File

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

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { checkBodyExpect } from "../../../../../src/server/checker/runner/shared/body";
import { checkBodyExpect } from "../../../../../src/server/checker/runner/http/body";
describe("checkBodyExpect (BodyRule[])", () => {
test("无规则返回匹配成功", () => {

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { checkDuration } from "../../../../../src/server/checker/runner/shared/duration";
import { checkDuration } from "../../../../../src/server/checker/expect/duration";
describe("checkDuration", () => {
test("未配置 maxDurationMs 返回匹配成功", () => {

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { errorFailure, mismatchFailure, truncateActual } from "../../../../../src/server/checker/runner/shared/failure";
import { errorFailure, mismatchFailure, truncateActual } from "../../../../../src/server/checker/expect/failure";
describe("truncateActual", () => {
test("短字符串不截断", () => {

View File

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

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { checkTextRules } from "../../../../../src/server/checker/runner/shared/text";
import { checkTextRules } from "../../../../../src/server/checker/runner/command/text";
describe("checkTextRules", () => {
test("无规则返回匹配成功", () => {

View File

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

View File

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