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:
301
DEVELOPMENT.md
301
DEVELOPMENT.md
@@ -34,33 +34,44 @@ src/
|
||||
history.ts GET /api/targets/:id/history
|
||||
trend.ts GET /api/targets/:id/trend
|
||||
checker/
|
||||
types.ts 类型定义
|
||||
types.ts 基础类型定义(ResolvedTargetBase、RawTargetConfig、DefaultsConfig 等 base interface)
|
||||
config-loader.ts YAML 配置解析、契约校验、语义校验与运行期解析
|
||||
config-contract/ TypeBox + Ajv 配置契约、schema fragments、issue 渲染和 schema 导出入口
|
||||
schema/ TypeBox + Ajv 配置契约、schema fragments、issue 渲染和 schema 导出入口
|
||||
builder.ts 全量 JSON Schema 组装(遍历 registry 生成)
|
||||
fragments.ts 共享 TypeBox schema 片段(duration、size、operator 等)
|
||||
validate.ts Ajv 契约校验入口
|
||||
issues.ts 校验问题类型与渲染
|
||||
types.ts schema 层类型
|
||||
export.ts JSON Schema 文件导出
|
||||
store.ts SQLite 数据存储
|
||||
engine.ts 调度引擎(按 interval 分组的 es-toolkit groupBy + Semaphore 并发控制)
|
||||
size.ts 大小单位解析
|
||||
utils.ts 共享工具函数(parseSize、parseDuration)
|
||||
expect/ 共享 expect 断言基础设施(跨 checker 复用)
|
||||
types.ts ExpectResult 等共享断言类型
|
||||
failure.ts 失败信息构造(errorFailure、mismatchFailure)
|
||||
operator.ts 操作符系统(applyOperator、checkExpectValue、evaluateJsonPath)
|
||||
duration.ts 耗时断言(checkDuration)
|
||||
validate-operator.ts 操作符语义校验(validateOperatorObject、isJsonValue)
|
||||
runner/ Checker 统一抽象与注册机制
|
||||
types.ts CheckerDefinition、CheckerContext、ResolveContext
|
||||
types.ts CheckerDefinition、CheckerContext、CheckerSchemas、ResolveContext
|
||||
registry.ts CheckerRegistry 注册中心
|
||||
index.ts 注册入口(registerCheckers)
|
||||
shared/ 共享 expect 断言和启动期 validator(跨 checker 复用)
|
||||
failure.ts 失败信息类型
|
||||
operator.ts 操作符系统(applyOperator、evaluateJsonPath)
|
||||
duration.ts 耗时断言
|
||||
text.ts 文本规则断言
|
||||
index.ts 注册入口(显式数组 + 循环注册)
|
||||
http/ HTTP Checker(自包含模块)
|
||||
index.ts 模块入口(re-export HttpChecker)
|
||||
types.ts HTTP 专属类型(ResolvedHttpTarget、HttpTargetConfig、HttpExpectConfig 等)
|
||||
schema.ts HTTP TypeBox 契约(defaults、target.http、expect)
|
||||
execute.ts HttpChecker 类(resolve/execute/serialize/validate)
|
||||
expect.ts HTTP 专用断言(checkStatus、checkHeaders)
|
||||
validate.ts HTTP 语义校验(URL、body rules、header operators 等)
|
||||
body.ts Body 规则断言(JSONPath/XPath/CSS/contains/regex)
|
||||
validate.ts 共享 operator/text/body 语义校验
|
||||
http/ HTTP Checker 子包
|
||||
contract.ts HTTP defaults、target.http、expect TypeBox 契约
|
||||
runner.ts HttpChecker(resolve/execute/serialize)
|
||||
expect.ts HTTP 专用断言(status/headers)
|
||||
validate.ts HTTP 专属启动期语义校验
|
||||
command/ Command Checker 子包
|
||||
contract.ts Command defaults、target.command、expect TypeBox 契约
|
||||
runner.ts CommandChecker(resolve/execute/serialize)
|
||||
expect.ts Command 专用断言(exitCode)
|
||||
validate.ts Command 专属启动期语义校验
|
||||
command/ Command Checker(自包含模块)
|
||||
index.ts 模块入口(re-export CommandChecker)
|
||||
types.ts Command 专属类型(ResolvedCommandTarget、CommandTargetConfig、CommandExpectConfig 等)
|
||||
schema.ts Command TypeBox 契约(defaults、target.command、expect)
|
||||
execute.ts CommandChecker 类(resolve/execute/serialize/validate)
|
||||
expect.ts Command 专用断言(checkExitCode)
|
||||
validate.ts Command 语义校验(text rules 等)
|
||||
text.ts 文本规则断言(checkTextRules)
|
||||
shared/
|
||||
api.ts 前后端共享 TypeScript 类型
|
||||
web/ Vite + React 前端 Dashboard
|
||||
@@ -149,15 +160,19 @@ export function handleXxx(params, store: ProbeStore, method: string, mode: Runti
|
||||
- **严格联合类型**优先于宽类型:如 `phase: "status" | "duration" | ...` 而非 `phase: string`
|
||||
- **后端内部扩展**:`checker/types.ts` 中 `CheckResult` 通过 `extends` 共享版本的 `ApiCheckResult` 增加 `targetName` 等内部字段
|
||||
- 存储层类型(`StoredTarget`、`StoredCheckResult`)独立定义,与 API 类型分离
|
||||
- 配置类型按生命周期区分:YAML 解析后的 `RawProbeConfig`、已通过契约与语义校验的 `ValidatedProbeConfig`、运行期使用的 `ResolvedConfig`/`ResolvedTarget`
|
||||
- **Checker 类型分层**:
|
||||
- `checker/types.ts` 定义 base interface(`ResolvedTargetBase`、`RawTargetConfig`、`DefaultsConfig`),使用 index signature 支持扩展
|
||||
- 各 checker 在自己的 `types.ts` 中定义具体类型(如 `ResolvedHttpTarget`、`ResolvedCommandTarget`),满足 base interface 约束
|
||||
- 中间层(engine、store、config-loader)只依赖 base interface,不感知具体 checker 类型
|
||||
- Checker 内部通过 `as` 类型断言将 base 窄化为具体类型
|
||||
|
||||
### 1.6 配置契约与校验
|
||||
|
||||
配置加载流程固定为:`unknown -> RawProbeConfig -> ValidatedProbeConfig -> ResolvedConfig`。
|
||||
|
||||
`config-loader.ts` 只负责 YAML 解析、契约调度、公共语义校验和最终运行期解析;checker 专属规则必须下沉到对应 checker 的 `contract.ts` 和 `validate.ts`。
|
||||
`config-loader.ts` 只负责 YAML 解析、契约调度、公共语义校验和最终运行期解析;checker 专属规则必须下沉到对应 checker 的 `schema.ts` 和 `validate.ts`。
|
||||
|
||||
契约层使用 `src/server/checker/config-contract/` 中的 TypeBox fragments 生成 JSON Schema,并用 Ajv 执行启动期校验。Ajv 必须保持严格拒绝模式:`allErrors: true`、不启用类型强制转换、不注入默认值、不自动删除未知字段。
|
||||
契约层使用 `src/server/checker/schema/` 中的 TypeBox fragments 生成 JSON Schema,并用 Ajv 执行启动期校验。Ajv 必须保持严格拒绝模式:`allErrors: true`、不启用类型强制转换、不注入默认值、不自动删除未知字段。
|
||||
|
||||
默认对象策略是 `additionalProperties: false`。只有明确声明的动态键值表可以开放任意键名,例如 `http.headers`、`defaults.http.headers`、`expect.headers`、`command.env`。
|
||||
|
||||
@@ -167,9 +182,7 @@ export function handleXxx(params, store: ProbeStore, method: string, mode: Runti
|
||||
|
||||
### 1.7 开发新 Checker
|
||||
|
||||
Checker 是本项目的核心扩展单元。得益于插件式注册架构,完成一个新 checker 并注册后,**配置契约组装、引擎调度、数据存储、API 层会自动走 registry 委托链路**,无需在这些中间层添加新的 type 分支。
|
||||
|
||||
当前 checker 执行链路已经注册化,但新增 checker 仍需更新中央类型定义、默认注册入口、前端展示常量、配置示例、用户/开发文档和测试。下文清单以这些必要更新为准。
|
||||
Checker 是本项目的核心扩展单元。架构设计目标是**完全内聚**:每个 checker 是 `src/server/checker/runner/<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
|
||||
├── 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 契约 schema(config / defaults / expect 三部分) |
|
||||
| `index.ts` | 模块入口,re-export Checker 类 |
|
||||
| `types.ts` | Checker 专属类型(ResolvedXxxTarget、XxxTargetConfig、XxxExpectConfig 等) |
|
||||
| `schema.ts` | TypeBox 契约 schema(config / defaults / expect 三部分) |
|
||||
| `validate.ts` | 启动期语义校验(JSON Schema 无法表达的规则) |
|
||||
| `runner.ts` | Checker 类:resolve(默认值合并 + 解析)、execute(执行检查)、serialize(DB 持久化) |
|
||||
| `execute.ts` | Checker 类:resolve(默认值合并 + 解析)、execute(执行检查)、serialize(DB 持久化) |
|
||||
| `expect.ts` | Checker 专用断言函数 |
|
||||
| `*.ts` | 其他 checker 专属逻辑(如 http/body.ts、command/text.ts) |
|
||||
|
||||
#### 1.7.2 步骤一:定义类型
|
||||
#### 1.7.2 步骤一:创建 Checker 目录与类型
|
||||
|
||||
在 `src/server/checker/types.ts` 中添加 checker 专属类型接口,并更新联合类型:
|
||||
在 `src/server/checker/runner/tcp/types.ts` 中定义 checker 专属类型:
|
||||
|
||||
```typescript
|
||||
// 1. 添加 TargetConfig(YAML 中 target.tcp 字段的原始类型)
|
||||
import type { ResolvedTargetBase } from "../../types";
|
||||
|
||||
export interface TcpTargetConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
timeout?: number;
|
||||
connectTimeout?: number;
|
||||
}
|
||||
|
||||
// 2. 添加 ExpectConfig 扩展(如果 checker 有专属 expect 字段)
|
||||
export interface TcpExpectConfig {
|
||||
connected?: boolean;
|
||||
maxDurationMs?: number;
|
||||
}
|
||||
|
||||
// 3. 添加 DefaultsConfig(defaults.tcp 字段)
|
||||
export interface TcpDefaultsConfig {
|
||||
timeout?: number;
|
||||
connectTimeout?: number;
|
||||
}
|
||||
|
||||
// 4. 添加 Resolved 变体(运行期已合并默认值、已解析路径)
|
||||
export interface ResolvedTcpTarget {
|
||||
type: "tcp";
|
||||
name: string;
|
||||
group: string;
|
||||
intervalMs: number;
|
||||
timeoutMs: number;
|
||||
export interface ResolvedTcpTarget extends ResolvedTargetBase {
|
||||
expect?: TcpExpectConfig;
|
||||
tcp: {
|
||||
connectTimeout: number;
|
||||
host: string;
|
||||
port: number;
|
||||
connectTimeout: number;
|
||||
};
|
||||
expect?: TcpExpectConfig;
|
||||
type: "tcp";
|
||||
}
|
||||
```
|
||||
|
||||
然后更新以下联合类型:
|
||||
|
||||
```typescript
|
||||
// TargetConfig 联合 — 新增一个分支
|
||||
export type TargetConfig = BaseTargetConfig &
|
||||
(
|
||||
| { http: HttpTargetConfig; type: "http" }
|
||||
| { command: CommandTargetConfig; type: "command" }
|
||||
| { tcp: TcpTargetConfig; type: "tcp" } // ← 新增
|
||||
);
|
||||
|
||||
// ResolvedTarget 联合
|
||||
export type ResolvedTarget = ResolvedHttpTarget | ResolvedCommandTarget | ResolvedTcpTarget; // ← 新增
|
||||
|
||||
// DefaultsConfig — 新增可选字段
|
||||
export interface DefaultsConfig {
|
||||
interval?: string;
|
||||
timeout?: string;
|
||||
http?: HttpDefaultsConfig;
|
||||
command?: CommandDefaultsConfig;
|
||||
tcp?: TcpDefaultsConfig; // ← 新增
|
||||
}
|
||||
|
||||
// TargetType 联合
|
||||
export type TargetType = "command" | "http" | "tcp"; // ← 新增
|
||||
|
||||
// ExpectConfig — 如有专属字段则扩展
|
||||
export interface ExpectConfig {
|
||||
// ... 现有字段
|
||||
connected?: boolean; // ← TcpChecker 专属(如果复用公共字段则不需要)
|
||||
}
|
||||
```
|
||||
**注意**:不需要修改顶层 `checker/types.ts`。base interface 使用 index signature(`[key: string]: unknown`),checker 专属类型通过 `extends ResolvedTargetBase` 自动兼容。
|
||||
|
||||
#### 1.7.3 步骤二:创建 TypeBox 契约 Schema
|
||||
|
||||
在 `src/server/checker/runner/tcp/contract.ts` 中定义三部分 schema:
|
||||
在 `src/server/checker/runner/tcp/schema.ts` 中定义三部分 schema:
|
||||
|
||||
```typescript
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { CheckerSchemas } from "../types";
|
||||
import { sizeSchema } from "../../config-contract/fragments"; // 复用共享 fragments
|
||||
|
||||
export const tcpCheckerSchemas: CheckerSchemas = {
|
||||
// target.tcp 字段的 schema
|
||||
config: Type.Object(
|
||||
{
|
||||
host: Type.String(),
|
||||
@@ -289,7 +268,6 @@ export const tcpCheckerSchemas: CheckerSchemas = {
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
|
||||
// defaults.tcp 字段的 schema
|
||||
defaults: Type.Object(
|
||||
{
|
||||
connectTimeout: Type.Optional(Type.Integer({ minimum: 100 })),
|
||||
@@ -297,7 +275,6 @@ export const tcpCheckerSchemas: CheckerSchemas = {
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
|
||||
// target.expect 中 tcp 专属字段的 schema(如果无专属字段则用 Type.Object({}))
|
||||
expect: Type.Object(
|
||||
{
|
||||
connected: Type.Optional(Type.Boolean()),
|
||||
@@ -307,12 +284,11 @@ export const tcpCheckerSchemas: CheckerSchemas = {
|
||||
};
|
||||
```
|
||||
|
||||
**可复用的共享 fragments**(来自 `config-contract/fragments.ts`):
|
||||
**可复用的共享 fragments**(来自 `schema/fragments.ts`):
|
||||
|
||||
| Fragment | 用途 |
|
||||
| ---------------------------- | ---------------------------------------------- |
|
||||
| `durationSchema` | 时长字符串(`"30s"`、`"5m"`、`"500ms"`) |
|
||||
| `httpMethodSchema` | HTTP 方法枚举 |
|
||||
| `sizeSchema` | 大小单位(字符串如 `"10MB"` 或整数) |
|
||||
| `statusCodePatternSchema` | 状态码(`100`-`599` 或 `"2xx"`) |
|
||||
| `stringMapSchema` | `Record<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() === "") {
|
||||
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 格式 |
|
||||
| `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);
|
||||
const durationResult = checkDuration(durationMs, t.expect?.maxDurationMs);
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
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 提取 |
|
||||
| `validate-operator.ts` | `validateOperatorObject(ops, path, name)` | 操作符语义校验 |
|
||||
|
||||
#### 1.7.6 步骤五:注册 Checker
|
||||
**Checker 专属断言**(如需要)放在同目录的 `expect.ts` 中,参考 `http/expect.ts`(checkStatus、checkHeaders)和 `command/expect.ts`(checkExitCode)。
|
||||
|
||||
在 `src/server/checker/runner/index.ts` 中注册:
|
||||
#### 1.7.6 步骤五:创建模块入口并注册
|
||||
|
||||
创建 `src/server/checker/runner/tcp/index.ts`:
|
||||
|
||||
```typescript
|
||||
import { TcpChecker } from "./tcp/runner"; // ← 新增导入
|
||||
export { TcpChecker } from "./execute";
|
||||
```
|
||||
|
||||
export function registerCheckers(registry = checkerRegistry): void {
|
||||
registry.register(new HttpChecker());
|
||||
registry.register(new CommandChecker());
|
||||
registry.register(new TcpChecker()); // ← 新增注册
|
||||
在 `src/server/checker/runner/index.ts` 中添加一行导入和一个数组元素:
|
||||
|
||||
```typescript
|
||||
import { CommandChecker } from "./command";
|
||||
import { HttpChecker } from "./http";
|
||||
import { TcpChecker } from "./tcp"; // ← 新增
|
||||
import { CheckerRegistry } from "./registry";
|
||||
|
||||
const checkers = [new HttpChecker(), new CommandChecker(), new TcpChecker()]; // ← 新增
|
||||
|
||||
export function createDefaultCheckerRegistry(): CheckerRegistry {
|
||||
const registry = new CheckerRegistry();
|
||||
for (const checker of checkers) {
|
||||
registry.register(checker);
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
export const checkerRegistry = createDefaultCheckerRegistry();
|
||||
```
|
||||
|
||||
注册后,以下管线会通过 registry 自动委托,**无需新增 type 分支**:
|
||||
|
||||
| 模块 | 自动行为 |
|
||||
| ----------------------------- | ------------------------------------------------------------------------ |
|
||||
| `config-contract/schema.ts` | 遍历 registry 生成全量 JSON Schema(defaults.tcp + target.tcp + expect) |
|
||||
| `config-contract/validate.ts` | 按注册 checker 构建 Ajv 校验,自动识别 `type: tcp` |
|
||||
| -------------------- | ------------------------------------------------------------------------ |
|
||||
| `schema/builder.ts` | 遍历 registry 生成全量 JSON Schema(defaults.tcp + target.tcp + expect) |
|
||||
| `schema/validate.ts` | 按注册 checker 构建 Ajv 校验,自动识别 `type: tcp` |
|
||||
| `config-loader.ts` | 遍历 registry 调用每个 checker 的 `validate()` + `resolve()` |
|
||||
| `engine.ts` | 按 `target.type` 从 registry 取对应 checker 执行 `execute()` |
|
||||
| `store.ts` | 按 `target.type` 从 registry 取对应 checker 执行 `serialize()` |
|
||||
|
||||
注意:自动适配指上述中间层不需要新增 `switch/case` 或类型分支;开发者仍需按后续步骤更新类型、注册、前端展示、示例、文档和测试。
|
||||
注意:自动适配指上述中间层不需要新增 `switch/case` 或类型分支;开发者仍需按后续步骤更新前端展示常量、配置示例、文档和测试。
|
||||
|
||||
#### 1.7.7 步骤六:更新前端展示
|
||||
|
||||
@@ -546,12 +522,13 @@ 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/execute.ts — Checker 类
|
||||
□ src/server/checker/runner/tcp/expect.ts — 专用断言(如需要)
|
||||
□ src/server/checker/runner/index.ts — 注册
|
||||
□ src/server/checker/runner/tcp/index.ts — 模块入口(re-export)
|
||||
□ src/server/checker/runner/index.ts — 注册(一行导入 + 一个数组元素)
|
||||
□ src/web/constants/target-type-display.ts — 前端类型标签
|
||||
□ src/web/constants/target-table-filters.ts — 前端类型筛选
|
||||
□ tests/ — 契约 + 校验 + resolve + execute + 注册 测试
|
||||
@@ -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 错误模式
|
||||
|
||||
|
||||
@@ -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 只需一个目录 + 一行注册
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
@@ -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 {
|
||||
@@ -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"]);
|
||||
|
||||
6
src/server/checker/expect/types.ts
Normal file
6
src/server/checker/expect/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { CheckFailure } from "../types";
|
||||
|
||||
export interface ExpectResult {
|
||||
failure: CheckFailure | null;
|
||||
matched: boolean;
|
||||
}
|
||||
80
src/server/checker/expect/validate-operator.ts
Normal file
80
src/server/checker/expect/validate-operator.ts
Normal 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)];
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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)) {
|
||||
|
||||
1
src/server/checker/runner/command/index.ts
Normal file
1
src/server/checker/runner/command/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CommandChecker } from "./execute";
|
||||
@@ -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(
|
||||
@@ -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++) {
|
||||
41
src/server/checker/runner/command/types.ts
Normal file
41
src/server/checker/runner/command/types.ts
Normal 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;
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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({
|
||||
@@ -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>,
|
||||
|
||||
1
src/server/checker/runner/http/index.ts
Normal file
1
src/server/checker/runner/http/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { HttpChecker } from "./execute";
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
sizeSchema,
|
||||
statusCodePatternSchema,
|
||||
stringMapSchema,
|
||||
} from "../../config-contract/fragments";
|
||||
} from "../../schema/fragments";
|
||||
|
||||
export const httpCheckerSchemas: CheckerSchemas = {
|
||||
config: Type.Object(
|
||||
59
src/server/checker/runner/http/types.ts
Normal file
59
src/server/checker/runner/http/types.ts
Normal 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 };
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -30,5 +30,3 @@ export class CheckerRegistry {
|
||||
return this.checkers.get(type);
|
||||
}
|
||||
}
|
||||
|
||||
export const checkerRegistry = new CheckerRegistry();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
@@ -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 });
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,9 +109,8 @@ 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");
|
||||
@@ -118,7 +120,6 @@ describe("loadConfig", () => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
test("解析完整配置", async () => {
|
||||
@@ -200,9 +199,8 @@ 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" });
|
||||
@@ -212,15 +210,12 @@ targets:
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
test("per-target 覆盖 defaults", async () => {
|
||||
@@ -246,13 +241,11 @@ targets:
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
const t = config.targets[0]!;
|
||||
if (t.type === "http") {
|
||||
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") {
|
||||
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") {
|
||||
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(command.command.env["CUSTOM_ENV_NAME"]).toBe("custom");
|
||||
});
|
||||
|
||||
test("command args 类型非法", async () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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("无规则返回匹配成功", () => {
|
||||
|
||||
@@ -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 返回匹配成功", () => {
|
||||
|
||||
@@ -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("短字符串不截断", () => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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("无规则返回匹配成功", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
Reference in New Issue
Block a user