refactor: 引入 Checker 统一接口与 Runner 抽象机制
定义 Checker 接口(resolve/execute/serialize)和 CheckerRegistry 注册中心,消除 engine/config-loader/store 中硬编码类型分支。 按 checker 类型分子包(runner/http/、runner/command/),提取 共享 expect 到 runner/shared/。超时控制通过引擎注入 AbortSignal。 CheckFailure.phase 从联合类型改为 string。配置校验下沉到各 Checker.resolve() 内部。 新增 checker-runner-abstraction spec,更新 DEVELOPMENT.md。
This commit is contained in:
@@ -37,15 +37,24 @@ src/
|
|||||||
types.ts 类型定义
|
types.ts 类型定义
|
||||||
config-loader.ts YAML 配置解析与校验
|
config-loader.ts YAML 配置解析与校验
|
||||||
store.ts SQLite 数据存储
|
store.ts SQLite 数据存储
|
||||||
fetcher.ts HTTP 拨测执行
|
|
||||||
command-runner.ts 命令行拨测执行
|
|
||||||
size.ts 大小单位解析
|
|
||||||
engine.ts 调度引擎(按 interval 分组的 es-toolkit groupBy + Semaphore 并发控制)
|
engine.ts 调度引擎(按 interval 分组的 es-toolkit groupBy + Semaphore 并发控制)
|
||||||
expect/
|
size.ts 大小单位解析
|
||||||
http.ts HTTP 响应断言
|
runner/ Checker 统一抽象与注册机制
|
||||||
command.ts 命令行输出断言
|
types.ts Checker 接口、CheckerContext、ResolveContext
|
||||||
body.ts HTTP body 断言(JSONPath/XPath/CSS,类型判断使用 es-toolkit)
|
registry.ts CheckerRegistry 注册中心
|
||||||
failure.ts 失败信息类型
|
index.ts 注册入口(registerCheckers)
|
||||||
|
shared/ 共享 expect 断言函数(跨 checker 复用)
|
||||||
|
failure.ts 失败信息类型
|
||||||
|
operator.ts 操作符系统(applyOperator、evaluateJsonPath)
|
||||||
|
duration.ts 耗时断言
|
||||||
|
text.ts 文本规则断言
|
||||||
|
body.ts Body 规则断言(JSONPath/XPath/CSS/contains/regex)
|
||||||
|
http/ HTTP Checker 子包
|
||||||
|
runner.ts HttpChecker(resolve/execute/serialize)
|
||||||
|
expect.ts HTTP 专用断言(status/headers)
|
||||||
|
command/ Command Checker 子包
|
||||||
|
runner.ts CommandChecker(resolve/execute/serialize)
|
||||||
|
expect.ts Command 专用断言(exitCode)
|
||||||
shared/
|
shared/
|
||||||
api.ts 前后端共享 TypeScript 类型
|
api.ts 前后端共享 TypeScript 类型
|
||||||
web/ Vite + React 前端 Dashboard
|
web/ Vite + React 前端 Dashboard
|
||||||
@@ -76,7 +85,7 @@ openspec/ OpenSpec 变更与规格文档
|
|||||||
运行时:
|
运行时:
|
||||||
定时器(tick) → ProbeEngine.probeGroup()
|
定时器(tick) → ProbeEngine.probeGroup()
|
||||||
→ HTTP: fetcher.ts / Command: command-runner.ts
|
→ HTTP: fetcher.ts / Command: command-runner.ts
|
||||||
→ expect/*.ts 校验 → store.insertCheckResult()
|
→ runner/*/expect.ts 校验 → store.insertCheckResult()
|
||||||
|
|
||||||
HTTP 请求:
|
HTTP 请求:
|
||||||
Request → app.ts(路由分发) → routes/*.ts(handler)
|
Request → app.ts(路由分发) → routes/*.ts(handler)
|
||||||
@@ -206,7 +215,7 @@ runCommandCheck → 收集观测(exitCode/stdout/stderr/durationMs)
|
|||||||
|
|
||||||
### 1.10 测试规范
|
### 1.10 测试规范
|
||||||
|
|
||||||
- 测试文件与源文件对应:`tests/server/checker/store.test.ts` ↔ `src/server/checker/store.ts`
|
- 测试文件与源文件对应:`tests/server/checker/runner/shared/body.test.ts` ↔ `src/server/checker/runner/shared/body.ts`
|
||||||
- 使用 `bun:test` 框架(`describe`/`test`/`expect`),测试数据库用临时目录 + `tmpdir()`
|
- 使用 `bun:test` 框架(`describe`/`test`/`expect`),测试数据库用临时目录 + `tmpdir()`
|
||||||
- 新增 store 方法必须编写单元测试;新增 API 端点必须在 `app.test.ts` 中添加集成测试
|
- 新增 store 方法必须编写单元测试;新增 API 端点必须在 `app.test.ts` 中添加集成测试
|
||||||
- 测试后清理:`afterAll` 中 `store.close()` + `rm(tempDir, { recursive: true })`
|
- 测试后清理:`afterAll` 中 `store.close()` + `rm(tempDir, { recursive: true })`
|
||||||
|
|||||||
113
openspec/specs/checker-runner-abstraction/spec.md
Normal file
113
openspec/specs/checker-runner-abstraction/spec.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
定义 Checker 接口规范、注册机制、CheckerContext 上下文注入,以及共享 expect 断言函数的职责边界。此 capability 是 checker 系统的架构基础,不定义任何具体 checker 类型的业务行为。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Checker 接口定义
|
||||||
|
系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义 `Checker` 接口,包含 `type`、`resolve`、`execute`、`serialize` 四个成员。`CheckerContext` SHALL 包含引擎注入的 `AbortSignal`。
|
||||||
|
|
||||||
|
#### Scenario: Checker 接口包含必要方法
|
||||||
|
- **WHEN** 开发者实现一个新的 Checker
|
||||||
|
- **THEN** 该实现 MUST 提供 `type`(字符串标识)、`resolve(target, context)`(解析配置并校验)、`execute(target, ctx)`(执行探测返回 CheckResult)和 `serialize(target)`(返回 target 展示文本和 config JSON)
|
||||||
|
|
||||||
|
#### Scenario: CheckerContext 注入 signal
|
||||||
|
- **WHEN** 引擎调用 `checker.execute(target, ctx)`
|
||||||
|
- **THEN** `ctx.signal` SHALL 是一个由引擎创建的 `AbortSignal`,在超时或引擎关闭时 abort
|
||||||
|
|
||||||
|
### Requirement: CheckerRegistry 注册中心
|
||||||
|
系统 SHALL 在 `src/server/checker/runner/registry.ts` 中提供 `CheckerRegistry` 类,支持 `register(checker)`、`get(type)` 和 `supportedTypes`。重复注册同一 type SHALL 抛出错误。
|
||||||
|
|
||||||
|
#### Scenario: 注册并获取 Checker
|
||||||
|
- **WHEN** 调用 `registry.register(new HttpChecker())` 后再调用 `registry.get("http")`
|
||||||
|
- **THEN** 返回的 SHALL 是之前注册的 HttpChecker 实例
|
||||||
|
|
||||||
|
#### Scenario: 获取未注册的 type
|
||||||
|
- **WHEN** 调用 `registry.get("unknown")` 且未注册对应 type 的 checker
|
||||||
|
- **THEN** 系统 SHALL 抛出错误,提示不支持的 probe type
|
||||||
|
|
||||||
|
#### Scenario: 重复注册
|
||||||
|
- **WHEN** 同一 type 值被重复 `register()`
|
||||||
|
- **THEN** 系统 SHALL 抛出错误,提示该 type 已注册
|
||||||
|
|
||||||
|
#### Scenario: 查询支持的 type 列表
|
||||||
|
- **WHEN** 注册了 "http" 和 "command" 两个 checker 后查询 `registry.supportedTypes`
|
||||||
|
- **THEN** 返回的数组 SHALL 包含 `["http", "command"]`(按注册顺序)
|
||||||
|
|
||||||
|
### Requirement: 引擎通过 registry 调度 checker
|
||||||
|
系统 SHALL 在 `ProbeEngine.runCheck()` 中通过 `checkerRegistry.get(target.type).execute(target, ctx)` 调度检查,替代原有的 `switch/case` 分支。
|
||||||
|
|
||||||
|
#### Scenario: 引擎使用 registry 调度
|
||||||
|
- **WHEN** engine 需要执行一个 type 为 "http" 的 target
|
||||||
|
- **THEN** engine SHALL 从 `checkerRegistry` 中获取对应 checker 并调用其 `execute()` 方法,不再使用 `switch/case`
|
||||||
|
|
||||||
|
#### Scenario: 引擎注入超时 signal
|
||||||
|
- **WHEN** engine 调度一次 checker 执行
|
||||||
|
- **THEN** engine SHALL 创建 `AbortController`,设置超时定时器,将 `controller.signal` 注入 `CheckerContext`,执行完成后清理定时器
|
||||||
|
|
||||||
|
### Requirement: 配置解析通过 registry 委托 checker
|
||||||
|
系统 SHALL 在 `config-loader.ts` 的 `resolveTarget()` 中通过 `checkerRegistry.get(target.type).resolve(target, context)` 委托解析,替代原有的 `if/else` 分支。`validateConfig()` SHALL 仅校验通用字段(name 非空、name 不重复、group 类型),不再包含 type 专属字段校验。
|
||||||
|
|
||||||
|
#### Scenario: 配置解析委托 checker
|
||||||
|
- **WHEN** config-loader 解析一个 type 为 "command" 的 target
|
||||||
|
- **THEN** config-loader SHALL 调用 `checkerRegistry.get("command").resolve()` 进行解析、校验和默认值填充
|
||||||
|
|
||||||
|
#### Scenario: 通用字段校验保留在 config-loader
|
||||||
|
- **WHEN** YAML 配置中某个 target 缺少 name 或 type 字段
|
||||||
|
- **THEN** config-loader 的 `validateConfig()` SHALL 仍负责校验这些通用字段
|
||||||
|
|
||||||
|
#### Scenario: type 专属校验下沉到 checker
|
||||||
|
- **WHEN** YAML 配置中 HTTP target 缺少 `http.url`
|
||||||
|
- **THEN** HttpChecker 的 `resolve()` SHALL 抛出校验错误,提示缺少必填字段
|
||||||
|
|
||||||
|
### Requirement: 存储序列化通过 registry 获取展示格式
|
||||||
|
系统 SHALL 在 `ProbeStore.syncTargets()` 中通过 `checkerRegistry.get(t.type).serialize(t)` 获取每个 target 的展示摘要(`target` 列)和配置 JSON(`config` 列),替代 `buildTargetDisplay()` / `buildTargetConfig()` 中的类型分支。
|
||||||
|
|
||||||
|
#### Scenario: 序列化委托 checker
|
||||||
|
- **WHEN** store 同步 targets 表
|
||||||
|
- **THEN** store SHALL 对每个 target 调用对应 checker 的 `serialize()` 方法获取 `{ target, config }`
|
||||||
|
|
||||||
|
### Requirement: 共享 expect 断言函数
|
||||||
|
系统 SHALL 在 `src/server/checker/runner/shared/` 中提供可被多个 checker 复用的 expect 函数。checker 专用的 expect 函数 SHALL 保留在各自子包内。
|
||||||
|
|
||||||
|
#### Scenario: 共享 duration 断言
|
||||||
|
- **WHEN** 任何 checker 需要校验执行耗时
|
||||||
|
- **THEN** SHALL 调用 `runner/shared/duration.ts` 中的 `checkDuration(durationMs, maxDurationMs?)`,返回统一的 `ExpectResult`
|
||||||
|
|
||||||
|
#### Scenario: 共享 text 规则断言
|
||||||
|
- **WHEN** 任何 checker 需要对文本输出执行有序规则校验
|
||||||
|
- **THEN** SHALL 调用 `runner/shared/text.ts` 中的 `checkTextRules(text, rules, phase)`,返回统一的 `ExpectResult`
|
||||||
|
|
||||||
|
#### Scenario: 共享 body 规则断言
|
||||||
|
- **WHEN** 任何 checker 需要对文本体执行 contains/regex/json/css/xpath 规则校验
|
||||||
|
- **THEN** SHALL 调用 `runner/shared/body.ts` 中的 `checkBodyExpect(body, rules)`,返回统一的 `ExpectResult`
|
||||||
|
|
||||||
|
#### Scenario: HTTP 专用 expect
|
||||||
|
- **WHEN** HTTP checker 需要校验响应状态码和响应头
|
||||||
|
- **THEN** SHALL 调用 `runner/http/expect.ts` 中的 `checkStatus()` 和 `checkHeaders()`
|
||||||
|
|
||||||
|
#### Scenario: Command 专用 expect
|
||||||
|
- **WHEN** Command checker 需要校验退出码
|
||||||
|
- **THEN** SHALL 调用 `runner/command/expect.ts` 中的 `checkExitCode()`
|
||||||
|
|
||||||
|
### Requirement: 超时控制由引擎注入 signal
|
||||||
|
Checker 实现的 `execute()` MUST 使用 `ctx.signal` 感知超时,不得自行创建 `AbortController` 或 `setTimeout` 用于超时控制。仅 command checker 可在 signal abort 时 `proc.kill()` 以确保子进程被终止。
|
||||||
|
|
||||||
|
#### Scenario: HTTP checker 使用 signal
|
||||||
|
- **WHEN** HttpChecker 执行 HTTP 请求
|
||||||
|
- **THEN** SHALL 将 `ctx.signal` 传入 `fetch()` 的 `signal` 选项,不自行创建 `AbortController`
|
||||||
|
|
||||||
|
#### Scenario: Command checker 响应 signal
|
||||||
|
- **WHEN** CommandChecker 执行命令且 signal 被 abort
|
||||||
|
- **THEN** SHALL 调用 `proc.kill()` 终止子进程,并在 CheckResult 中记录超时错误
|
||||||
|
|
||||||
|
### Requirement: CheckFailure.phase 使用 string 类型
|
||||||
|
`shared/api.ts` 中 `CheckFailure.phase` 的类型 SHALL 定义为 `string`,替代原有的硬编码联合类型 `"status" | "duration" | "headers" | "body" | "exitCode" | "stdout" | "stderr"`。
|
||||||
|
|
||||||
|
#### Scenario: phase 支持 checker 专用值
|
||||||
|
- **WHEN** command checker 在执行失败(spawn error)时生成 failure
|
||||||
|
- **THEN** `failure.phase` SHALL 可以是 `"spawn"` 等任意字符串值,类型系统 SHALL 不报错
|
||||||
|
|
||||||
|
#### Scenario: 前端展示 phase 不依赖硬编码类型
|
||||||
|
- **WHEN** 前端收到任意 phase 字符串值
|
||||||
|
- **THEN** 前端 SHALL 直接展示而不做类型判断
|
||||||
@@ -103,9 +103,12 @@ import { ProbeStore } from "../src/server/checker/store";
|
|||||||
import { ProbeEngine } from "../src/server/checker/engine";
|
import { ProbeEngine } from "../src/server/checker/engine";
|
||||||
import { startServer } from "../src/server/server";
|
import { startServer } from "../src/server/server";
|
||||||
import { readRuntimeConfig } from "../src/server/config";
|
import { readRuntimeConfig } from "../src/server/config";
|
||||||
|
import { registerCheckers } from "../src/server/checker/runner";
|
||||||
import { staticAssets } from "./static-assets";
|
import { staticAssets } from "./static-assets";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
registerCheckers();
|
||||||
|
|
||||||
const { configPath } = readRuntimeConfig();
|
const { configPath } = readRuntimeConfig();
|
||||||
const config = await loadConfig(configPath);
|
const config = await loadConfig(configPath);
|
||||||
|
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
import { isError } from "es-toolkit";
|
|
||||||
import type { CheckResult, ResolvedCommandTarget } from "./types";
|
|
||||||
import { checkCommandExpect } from "./expect/command";
|
|
||||||
import { errorFailure } from "./expect/failure";
|
|
||||||
|
|
||||||
async function readOutput(
|
|
||||||
stdout: ReadableStream<Uint8Array>,
|
|
||||||
stderr: ReadableStream<Uint8Array>,
|
|
||||||
kill: () => void,
|
|
||||||
maxBytes: number,
|
|
||||||
): Promise<{ stdout: string; stderr: string; exceeded: boolean }> {
|
|
||||||
let totalBytes = 0;
|
|
||||||
let exceeded = false;
|
|
||||||
let killed = false;
|
|
||||||
|
|
||||||
async function readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
|
|
||||||
const reader = stream.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let text = "";
|
|
||||||
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
totalBytes += value.byteLength;
|
|
||||||
text += decoder.decode(value, { stream: true });
|
|
||||||
if (totalBytes > maxBytes && !killed) {
|
|
||||||
exceeded = true;
|
|
||||||
killed = true;
|
|
||||||
try {
|
|
||||||
kill();
|
|
||||||
} catch {
|
|
||||||
/* best-effort kill */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* stream already closed */
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
reader.releaseLock();
|
|
||||||
} catch {
|
|
||||||
/* already released */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [out, err] = await Promise.all([readStream(stdout), readStream(stderr)]);
|
|
||||||
|
|
||||||
return { stdout: out, stderr: err, exceeded };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runCommandCheck(target: ResolvedCommandTarget): Promise<CheckResult> {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
const start = performance.now();
|
|
||||||
|
|
||||||
let proc: ReturnType<typeof Bun.spawn>;
|
|
||||||
|
|
||||||
try {
|
|
||||||
proc = Bun.spawn([target.command.exec, ...target.command.args], {
|
|
||||||
cwd: target.command.cwd,
|
|
||||||
env: target.command.env,
|
|
||||||
stdin: "ignore",
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const durationMs = Math.round(performance.now() - start);
|
|
||||||
return {
|
|
||||||
targetName: target.name,
|
|
||||||
timestamp,
|
|
||||||
matched: false,
|
|
||||||
durationMs,
|
|
||||||
statusDetail: null,
|
|
||||||
failure: errorFailure("exitCode", "spawn", isError(error) ? error.message : String(error)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let timedOut = false;
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
timedOut = true;
|
|
||||||
try {
|
|
||||||
proc.kill();
|
|
||||||
} catch {
|
|
||||||
/* best-effort kill */
|
|
||||||
}
|
|
||||||
}, target.timeoutMs);
|
|
||||||
|
|
||||||
let outputResult: { stdout: string; stderr: string; exceeded: boolean };
|
|
||||||
|
|
||||||
try {
|
|
||||||
outputResult = await readOutput(
|
|
||||||
proc.stdout as ReadableStream<Uint8Array>,
|
|
||||||
proc.stderr as ReadableStream<Uint8Array>,
|
|
||||||
() => proc.kill(),
|
|
||||||
target.command.maxOutputBytes,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
const durationMs = Math.round(performance.now() - start);
|
|
||||||
return {
|
|
||||||
targetName: target.name,
|
|
||||||
timestamp,
|
|
||||||
matched: false,
|
|
||||||
durationMs,
|
|
||||||
statusDetail: null,
|
|
||||||
failure: errorFailure("exitCode", "execution", "输出读取失败"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await proc.exited;
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
const durationMs = Math.round(performance.now() - start);
|
|
||||||
const exitCode = proc.exitCode ?? 1;
|
|
||||||
|
|
||||||
if (outputResult.exceeded) {
|
|
||||||
return {
|
|
||||||
targetName: target.name,
|
|
||||||
timestamp,
|
|
||||||
matched: false,
|
|
||||||
durationMs,
|
|
||||||
statusDetail: `exitCode=${exitCode}`,
|
|
||||||
failure: errorFailure("exitCode", "output", `输出超过限制 ${target.command.maxOutputBytes} 字节`),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timedOut) {
|
|
||||||
return {
|
|
||||||
targetName: target.name,
|
|
||||||
timestamp,
|
|
||||||
matched: false,
|
|
||||||
durationMs,
|
|
||||||
statusDetail: null,
|
|
||||||
failure: errorFailure("exitCode", "timeout", `命令执行超时 (${target.timeoutMs}ms)`),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const obs = { exitCode, stdout: outputResult.stdout, stderr: outputResult.stderr, durationMs };
|
|
||||||
const expectResult = checkCommandExpect(obs, target.expect);
|
|
||||||
|
|
||||||
return {
|
|
||||||
targetName: target.name,
|
|
||||||
timestamp,
|
|
||||||
matched: expectResult.matched,
|
|
||||||
durationMs,
|
|
||||||
statusDetail: `exitCode=${exitCode}`,
|
|
||||||
failure: expectResult.failure,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,32 +1,19 @@
|
|||||||
import type {
|
import type {
|
||||||
CommandDefaultsConfig,
|
|
||||||
CommandTargetConfig,
|
|
||||||
DefaultsConfig,
|
DefaultsConfig,
|
||||||
HttpDefaultsConfig,
|
|
||||||
HttpExpectConfig,
|
|
||||||
HttpTargetConfig,
|
|
||||||
ProbeConfig,
|
ProbeConfig,
|
||||||
ResolvedCommandTarget,
|
|
||||||
ResolvedHttpTarget,
|
|
||||||
ResolvedTarget,
|
ResolvedTarget,
|
||||||
EngineRuntimeConfig,
|
EngineRuntimeConfig,
|
||||||
TargetConfig,
|
TargetConfig,
|
||||||
TargetType,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { parseSize } from "./size";
|
import { dirname, resolve } from "node:path";
|
||||||
import { resolve } from "node:path";
|
import { checkerRegistry } from "./runner";
|
||||||
import { dirname } from "node:path";
|
|
||||||
|
|
||||||
const DEFAULT_HOST = "127.0.0.1";
|
const DEFAULT_HOST = "127.0.0.1";
|
||||||
const DEFAULT_PORT = 3000;
|
const DEFAULT_PORT = 3000;
|
||||||
const DEFAULT_DATA_DIR = "./data";
|
const DEFAULT_DATA_DIR = "./data";
|
||||||
const DEFAULT_INTERVAL = "30s";
|
const DEFAULT_INTERVAL = "30s";
|
||||||
const DEFAULT_TIMEOUT = "10s";
|
const DEFAULT_TIMEOUT = "10s";
|
||||||
const DEFAULT_HTTP_METHOD = "GET";
|
|
||||||
const DEFAULT_MAX_BODY_BYTES = "100MB";
|
|
||||||
const DEFAULT_MAX_OUTPUT_BYTES = "100MB";
|
|
||||||
const DEFAULT_MAX_CONCURRENT_CHECKS = 20;
|
const DEFAULT_MAX_CONCURRENT_CHECKS = 20;
|
||||||
const SUPPORTED_TYPES: TargetType[] = ["http", "command"];
|
|
||||||
|
|
||||||
export interface ResolvedConfig {
|
export interface ResolvedConfig {
|
||||||
host: string;
|
host: string;
|
||||||
@@ -100,73 +87,14 @@ function resolveTarget(
|
|||||||
): ResolvedTarget {
|
): ResolvedTarget {
|
||||||
const intervalMs = parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL);
|
const intervalMs = parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL);
|
||||||
const timeoutMs = parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT);
|
const timeoutMs = parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT);
|
||||||
const group = target.group ?? "default";
|
|
||||||
|
|
||||||
if (target.type === "http") {
|
const checker = checkerRegistry.get(target.type);
|
||||||
return resolveHttpTarget(target, defaults.http, intervalMs, timeoutMs, group);
|
const result = checker.resolve(target, { defaults, configDir, defaultIntervalMs, defaultTimeoutMs });
|
||||||
}
|
|
||||||
|
|
||||||
return resolveCommandTarget(target, defaults.command, intervalMs, timeoutMs, configDir, group);
|
result.intervalMs = intervalMs;
|
||||||
}
|
result.timeoutMs = timeoutMs;
|
||||||
|
|
||||||
function resolveHttpTarget(
|
return result;
|
||||||
target: TargetConfig & { type: "http"; http: HttpTargetConfig },
|
|
||||||
httpDefaults: HttpDefaultsConfig | undefined,
|
|
||||||
intervalMs: number,
|
|
||||||
timeoutMs: number,
|
|
||||||
group: string,
|
|
||||||
): ResolvedHttpTarget {
|
|
||||||
const maxBodyBytes = parseSize(target.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES);
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: "http",
|
|
||||||
name: target.name,
|
|
||||||
group,
|
|
||||||
http: {
|
|
||||||
url: target.http.url,
|
|
||||||
method: target.http.method ?? httpDefaults?.method ?? DEFAULT_HTTP_METHOD,
|
|
||||||
headers: { ...(httpDefaults?.headers ?? {}), ...(target.http.headers ?? {}) },
|
|
||||||
body: target.http.body,
|
|
||||||
maxBodyBytes,
|
|
||||||
},
|
|
||||||
intervalMs,
|
|
||||||
timeoutMs,
|
|
||||||
expect: target.expect as HttpExpectConfig | undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveCommandTarget(
|
|
||||||
target: TargetConfig & { type: "command"; command: CommandTargetConfig },
|
|
||||||
commandDefaults: CommandDefaultsConfig | undefined,
|
|
||||||
intervalMs: number,
|
|
||||||
timeoutMs: number,
|
|
||||||
configDir: string,
|
|
||||||
group: string,
|
|
||||||
): ResolvedCommandTarget {
|
|
||||||
const cwd = target.command.cwd ?? commandDefaults?.cwd ?? ".";
|
|
||||||
const resolvedCwd = resolve(configDir, cwd);
|
|
||||||
|
|
||||||
const maxOutputBytes = parseSize(
|
|
||||||
target.command.maxOutputBytes ?? commandDefaults?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
|
|
||||||
);
|
|
||||||
|
|
||||||
const env = { ...process.env, ...(target.command.env ?? {}) } as Record<string, string>;
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: "command",
|
|
||||||
name: target.name,
|
|
||||||
group,
|
|
||||||
command: {
|
|
||||||
exec: target.command.exec,
|
|
||||||
args: target.command.args ?? [],
|
|
||||||
cwd: resolvedCwd,
|
|
||||||
env,
|
|
||||||
maxOutputBytes,
|
|
||||||
},
|
|
||||||
intervalMs,
|
|
||||||
timeoutMs,
|
|
||||||
expect: target.expect as import("./types").CommandExpectConfig | undefined,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateConfig(config: ProbeConfig): void {
|
function validateConfig(config: ProbeConfig): void {
|
||||||
@@ -175,6 +103,7 @@ function validateConfig(config: ProbeConfig): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
|
const supportedTypes = checkerRegistry.supportedTypes;
|
||||||
|
|
||||||
for (let i = 0; i < config.targets.length; i++) {
|
for (let i = 0; i < config.targets.length; i++) {
|
||||||
const raw = config.targets[i] as unknown as Record<string, unknown>;
|
const raw = config.targets[i] as unknown as Record<string, unknown>;
|
||||||
@@ -189,22 +118,8 @@ function validateConfig(config: ProbeConfig): void {
|
|||||||
throw new Error(`target "${name}" 缺少 type 字段`);
|
throw new Error(`target "${name}" 缺少 type 字段`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!SUPPORTED_TYPES.includes(type as TargetType)) {
|
if (!supportedTypes.includes(type)) {
|
||||||
throw new Error(`target "${name}" 使用不支持的 type: "${type}",支持: ${SUPPORTED_TYPES.join(", ")}`);
|
throw new Error(`target "${name}" 使用不支持的 type: "${type}",支持: ${supportedTypes.join(", ")}`);
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "http") {
|
|
||||||
const http = raw["http"] as Record<string, unknown> | undefined;
|
|
||||||
if (!http?.["url"] || typeof http["url"] !== "string" || (http["url"] as string).trim() === "") {
|
|
||||||
throw new Error(`target "${name}" 缺少 http.url 字段`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "command") {
|
|
||||||
const cmd = raw["command"] as Record<string, unknown> | undefined;
|
|
||||||
if (!cmd?.["exec"] || typeof cmd["exec"] !== "string" || (cmd["exec"] as string).trim() === "") {
|
|
||||||
throw new Error(`target "${name}" 缺少 command.exec 字段`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const group = raw["group"];
|
const group = raw["group"];
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { CheckResult, ResolvedTarget } from "./types";
|
import type { CheckResult, ResolvedTarget } from "./types";
|
||||||
import type { ProbeStore } from "./store";
|
import type { ProbeStore } from "./store";
|
||||||
import { runHttpCheck } from "./fetcher";
|
import { checkerRegistry } from "./runner";
|
||||||
import { runCommandCheck } from "./command-runner";
|
|
||||||
import { groupBy, Semaphore } from "es-toolkit";
|
import { groupBy, Semaphore } from "es-toolkit";
|
||||||
|
|
||||||
export class ProbeEngine {
|
export class ProbeEngine {
|
||||||
@@ -61,11 +60,14 @@ export class ProbeEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async runCheck(target: ResolvedTarget): Promise<CheckResult> {
|
private async runCheck(target: ResolvedTarget): Promise<CheckResult> {
|
||||||
switch (target.type) {
|
const checker = checkerRegistry.get(target.type);
|
||||||
case "http":
|
const controller = new AbortController();
|
||||||
return runHttpCheck(target);
|
const timeoutId = setTimeout(() => controller.abort(), target.timeoutMs);
|
||||||
case "command":
|
|
||||||
return runCommandCheck(target);
|
try {
|
||||||
|
return await checker.execute(target, { signal: controller.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
import type { CheckFailure, CommandExpectConfig, TextRule } from "../types";
|
|
||||||
import { applyOperator } from "./body";
|
|
||||||
import { mismatchFailure } from "./failure";
|
|
||||||
|
|
||||||
export interface CommandObservation {
|
|
||||||
exitCode: number;
|
|
||||||
stdout: string;
|
|
||||||
stderr: string;
|
|
||||||
durationMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkExitCode(obs: CommandObservation, allowed: number[]): { matched: boolean; failure: CheckFailure | null } {
|
|
||||||
if (!allowed.includes(obs.exitCode)) {
|
|
||||||
return {
|
|
||||||
matched: false,
|
|
||||||
failure: mismatchFailure(
|
|
||||||
"exitCode",
|
|
||||||
"exitCode",
|
|
||||||
allowed,
|
|
||||||
obs.exitCode,
|
|
||||||
`exitCode ${obs.exitCode} not in [${allowed}]`,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { matched: true, failure: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkDuration(
|
|
||||||
obs: CommandObservation,
|
|
||||||
maxDurationMs?: number,
|
|
||||||
): { matched: boolean; failure: CheckFailure | null } {
|
|
||||||
if (maxDurationMs === undefined) return { matched: true, failure: null };
|
|
||||||
if (obs.durationMs > maxDurationMs) {
|
|
||||||
return {
|
|
||||||
matched: false,
|
|
||||||
failure: mismatchFailure(
|
|
||||||
"duration",
|
|
||||||
"duration",
|
|
||||||
`<=${maxDurationMs}ms`,
|
|
||||||
obs.durationMs,
|
|
||||||
`duration ${obs.durationMs}ms > ${maxDurationMs}ms`,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { matched: true, failure: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkTextRules(
|
|
||||||
text: string,
|
|
||||||
rules: TextRule[],
|
|
||||||
phase: "stdout" | "stderr",
|
|
||||||
): { matched: boolean; failure: CheckFailure | null } {
|
|
||||||
for (let i = 0; i < rules.length; i++) {
|
|
||||||
const rule = rules[i]!;
|
|
||||||
const path = `${phase}[${i}]`;
|
|
||||||
if (!applyOperator(text, rule)) {
|
|
||||||
return {
|
|
||||||
matched: false,
|
|
||||||
failure: mismatchFailure(phase, path, rule, text, `${phase} rule at index ${i} mismatch`),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { matched: true, failure: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function checkCommandExpect(
|
|
||||||
obs: CommandObservation,
|
|
||||||
expect?: CommandExpectConfig,
|
|
||||||
): { matched: boolean; failure: CheckFailure | null } {
|
|
||||||
if (!expect) {
|
|
||||||
return checkExitCode(obs, [0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const exitCodeResult = checkExitCode(obs, expect.exitCode ?? [0]);
|
|
||||||
if (!exitCodeResult.matched) return exitCodeResult;
|
|
||||||
|
|
||||||
const durationResult = checkDuration(obs, expect.maxDurationMs);
|
|
||||||
if (!durationResult.matched) return durationResult;
|
|
||||||
|
|
||||||
if (expect.stdout && expect.stdout.length > 0) {
|
|
||||||
const stdoutResult = checkTextRules(obs.stdout, expect.stdout, "stdout");
|
|
||||||
if (!stdoutResult.matched) return stdoutResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (expect.stderr && expect.stderr.length > 0) {
|
|
||||||
const stderrResult = checkTextRules(obs.stderr, expect.stderr, "stderr");
|
|
||||||
if (!stderrResult.matched) return stderrResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { matched: true, failure: null };
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import type { BodyRule, CheckFailure, HeaderExpect, HttpExpectConfig } from "../types";
|
|
||||||
import { checkBodyExpect } from "./body";
|
|
||||||
import { applyOperator } from "./body";
|
|
||||||
import { mismatchFailure, errorFailure } from "./failure";
|
|
||||||
|
|
||||||
export interface HttpObservation {
|
|
||||||
statusCode: number;
|
|
||||||
headers: Record<string, string>;
|
|
||||||
body: string | null;
|
|
||||||
durationMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkStatus(obs: HttpObservation, allowed: number[]): { matched: boolean; failure: CheckFailure | null } {
|
|
||||||
if (!allowed.includes(obs.statusCode)) {
|
|
||||||
return {
|
|
||||||
matched: false,
|
|
||||||
failure: mismatchFailure(
|
|
||||||
"status",
|
|
||||||
"status",
|
|
||||||
allowed,
|
|
||||||
obs.statusCode,
|
|
||||||
`status ${obs.statusCode} not in [${allowed}]`,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { matched: true, failure: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkDuration(
|
|
||||||
obs: HttpObservation,
|
|
||||||
maxDurationMs?: number,
|
|
||||||
): { matched: boolean; failure: CheckFailure | null } {
|
|
||||||
if (maxDurationMs === undefined) return { matched: true, failure: null };
|
|
||||||
if (obs.durationMs > maxDurationMs) {
|
|
||||||
return {
|
|
||||||
matched: false,
|
|
||||||
failure: mismatchFailure(
|
|
||||||
"duration",
|
|
||||||
"duration",
|
|
||||||
`<=${maxDurationMs}ms`,
|
|
||||||
obs.durationMs,
|
|
||||||
`duration ${obs.durationMs}ms > ${maxDurationMs}ms`,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { matched: true, failure: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkHeaders(
|
|
||||||
obs: HttpObservation,
|
|
||||||
headerExpects?: Record<string, HeaderExpect>,
|
|
||||||
): { matched: boolean; failure: CheckFailure | null } {
|
|
||||||
if (!headerExpects) return { matched: true, failure: null };
|
|
||||||
|
|
||||||
for (const [key, expected] of Object.entries(headerExpects)) {
|
|
||||||
const actualValue = obs.headers[key.toLowerCase()];
|
|
||||||
const path = `headers.${key}`;
|
|
||||||
|
|
||||||
if (typeof expected === "string") {
|
|
||||||
if (actualValue !== expected) {
|
|
||||||
return {
|
|
||||||
matched: false,
|
|
||||||
failure: mismatchFailure("headers", path, expected, actualValue, `header ${key} mismatch`),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (actualValue === undefined) {
|
|
||||||
if (expected.exists !== false) {
|
|
||||||
return {
|
|
||||||
matched: false,
|
|
||||||
failure: mismatchFailure("headers", path, "defined", undefined, `header ${key} not found`),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!applyOperator(actualValue, expected)) {
|
|
||||||
return {
|
|
||||||
matched: false,
|
|
||||||
failure: mismatchFailure("headers", path, expected, actualValue, `header ${key} mismatch`),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { matched: true, failure: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkBody(obs: HttpObservation, bodyRules?: BodyRule[]): { matched: boolean; failure: CheckFailure | null } {
|
|
||||||
if (!bodyRules || bodyRules.length === 0) return { matched: true, failure: null };
|
|
||||||
|
|
||||||
if (obs.body === null) {
|
|
||||||
return {
|
|
||||||
matched: false,
|
|
||||||
failure: errorFailure("body", "body", "body is null but body rules are configured"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return checkBodyExpect(obs.body, bodyRules);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function checkHttpExpect(
|
|
||||||
obs: HttpObservation,
|
|
||||||
expect?: HttpExpectConfig,
|
|
||||||
): { matched: boolean; failure: CheckFailure | null } {
|
|
||||||
if (!expect) {
|
|
||||||
return checkStatus(obs, [200]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusResult = checkStatus(obs, expect.status ?? [200]);
|
|
||||||
if (!statusResult.matched) return statusResult;
|
|
||||||
|
|
||||||
const durationResult = checkDuration(obs, expect.maxDurationMs);
|
|
||||||
if (!durationResult.matched) return durationResult;
|
|
||||||
|
|
||||||
const headersResult = checkHeaders(obs, expect.headers);
|
|
||||||
if (!headersResult.matched) return headersResult;
|
|
||||||
|
|
||||||
const bodyResult = checkBody(obs, expect.body);
|
|
||||||
if (!bodyResult.matched) return bodyResult;
|
|
||||||
|
|
||||||
return { matched: true, failure: null };
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import type { CheckResult, ResolvedHttpTarget } from "./types";
|
|
||||||
import { checkHttpExpect } from "./expect/http";
|
|
||||||
import { errorFailure } from "./expect/failure";
|
|
||||||
import { isError } from "es-toolkit";
|
|
||||||
|
|
||||||
export async function runHttpCheck(target: ResolvedHttpTarget): Promise<CheckResult> {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), target.timeoutMs);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const start = performance.now();
|
|
||||||
|
|
||||||
const response = await fetch(target.http.url, {
|
|
||||||
method: target.http.method,
|
|
||||||
headers: target.http.headers,
|
|
||||||
body: target.http.method !== "GET" && target.http.method !== "HEAD" ? target.http.body : undefined,
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
const durationMs = Math.round(performance.now() - start);
|
|
||||||
const statusCode = response.status;
|
|
||||||
const responseHeaders = Object.fromEntries(response.headers);
|
|
||||||
|
|
||||||
const hasBodyRules = !!(target.expect?.body && target.expect.body.length > 0);
|
|
||||||
|
|
||||||
const preBodyExpect = target.expect
|
|
||||||
? { status: target.expect.status, maxDurationMs: target.expect.maxDurationMs, headers: target.expect.headers }
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const preBodyObs = { statusCode, headers: responseHeaders, body: null as string | null, durationMs };
|
|
||||||
const preBodyResult = checkHttpExpect(preBodyObs, preBodyExpect);
|
|
||||||
|
|
||||||
if (!hasBodyRules || !preBodyResult.matched) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
return {
|
|
||||||
targetName: target.name,
|
|
||||||
timestamp,
|
|
||||||
matched: preBodyResult.matched,
|
|
||||||
durationMs,
|
|
||||||
statusDetail: `HTTP ${statusCode}`,
|
|
||||||
failure: preBodyResult.failure,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const bodyBuffer = await response.arrayBuffer();
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
if (bodyBuffer.byteLength > target.http.maxBodyBytes) {
|
|
||||||
return {
|
|
||||||
targetName: target.name,
|
|
||||||
timestamp,
|
|
||||||
matched: false,
|
|
||||||
durationMs,
|
|
||||||
statusDetail: `HTTP ${statusCode}`,
|
|
||||||
failure: errorFailure(
|
|
||||||
"body",
|
|
||||||
"body",
|
|
||||||
`响应体大小 ${bodyBuffer.byteLength} 超过限制 ${target.http.maxBodyBytes}`,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = new TextDecoder().decode(bodyBuffer);
|
|
||||||
const fullObs = { statusCode, headers: responseHeaders, body, durationMs };
|
|
||||||
const fullResult = checkHttpExpect(fullObs, target.expect);
|
|
||||||
|
|
||||||
return {
|
|
||||||
targetName: target.name,
|
|
||||||
timestamp,
|
|
||||||
matched: fullResult.matched,
|
|
||||||
durationMs,
|
|
||||||
statusDetail: `HTTP ${statusCode}`,
|
|
||||||
failure: fullResult.failure,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
const isTimeout = error instanceof DOMException && error.name === "AbortError";
|
|
||||||
|
|
||||||
return {
|
|
||||||
targetName: target.name,
|
|
||||||
timestamp,
|
|
||||||
matched: false,
|
|
||||||
durationMs: null,
|
|
||||||
statusDetail: null,
|
|
||||||
failure: errorFailure(
|
|
||||||
"status",
|
|
||||||
"request",
|
|
||||||
isTimeout ? `请求超时 (${target.timeoutMs}ms)` : isError(error) ? error.message : String(error),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
18
src/server/checker/runner/command/expect.ts
Normal file
18
src/server/checker/runner/command/expect.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { mismatchFailure } from "../shared/failure";
|
||||||
|
import type { ExpectResult } from "../shared/duration";
|
||||||
|
|
||||||
|
export function checkExitCode(exitCode: number, allowed: number[]): ExpectResult {
|
||||||
|
if (!allowed.includes(exitCode)) {
|
||||||
|
return {
|
||||||
|
matched: false,
|
||||||
|
failure: mismatchFailure(
|
||||||
|
"exitCode",
|
||||||
|
"exitCode",
|
||||||
|
allowed,
|
||||||
|
exitCode,
|
||||||
|
`exitCode ${exitCode} not in [${allowed}]`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { matched: true, failure: null };
|
||||||
|
}
|
||||||
263
src/server/checker/runner/command/runner.ts
Normal file
263
src/server/checker/runner/command/runner.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import { isError } from "es-toolkit";
|
||||||
|
import type { CheckResult } from "../../types";
|
||||||
|
import type { Checker, CheckerContext, ResolveContext } from "../types";
|
||||||
|
import type {
|
||||||
|
CommandExpectConfig,
|
||||||
|
CommandTargetConfig,
|
||||||
|
ResolvedCommandTarget,
|
||||||
|
ResolvedTarget,
|
||||||
|
TargetConfig,
|
||||||
|
} from "../../types";
|
||||||
|
import { parseSize } from "../../size";
|
||||||
|
import { checkExitCode } from "./expect";
|
||||||
|
import { checkDuration } from "../shared/duration";
|
||||||
|
import { checkTextRules } from "../shared/text";
|
||||||
|
import { errorFailure } from "../shared/failure";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
async function readOutput(
|
||||||
|
stdout: ReadableStream<Uint8Array>,
|
||||||
|
stderr: ReadableStream<Uint8Array>,
|
||||||
|
kill: () => void,
|
||||||
|
maxBytes: number,
|
||||||
|
): Promise<{ stdout: string; stderr: string; exceeded: boolean }> {
|
||||||
|
let totalBytes = 0;
|
||||||
|
let exceeded = false;
|
||||||
|
let killed = false;
|
||||||
|
|
||||||
|
async function readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
|
||||||
|
const reader = stream.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let text = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
totalBytes += value.byteLength;
|
||||||
|
text += decoder.decode(value, { stream: true });
|
||||||
|
if (totalBytes > maxBytes && !killed) {
|
||||||
|
exceeded = true;
|
||||||
|
killed = true;
|
||||||
|
try {
|
||||||
|
kill();
|
||||||
|
} catch {
|
||||||
|
/* best-effort kill */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* stream already closed */
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
reader.releaseLock();
|
||||||
|
} catch {
|
||||||
|
/* already released */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [out, err] = await Promise.all([readStream(stdout), readStream(stderr)]);
|
||||||
|
|
||||||
|
return { stdout: out, stderr: err, exceeded };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CommandChecker implements Checker {
|
||||||
|
readonly type = "command";
|
||||||
|
|
||||||
|
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget {
|
||||||
|
const t = target as TargetConfig & { type: "command"; command: CommandTargetConfig };
|
||||||
|
const commandDefaults = context.defaults.command;
|
||||||
|
|
||||||
|
if (!t.command.exec || t.command.exec.trim() === "") {
|
||||||
|
throw new Error(`target "${t.name}" 缺少 command.exec 字段`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cwd = t.command.cwd ?? commandDefaults?.cwd ?? ".";
|
||||||
|
const resolvedCwd = resolve(context.configDir, cwd);
|
||||||
|
|
||||||
|
const maxOutputBytes = parseSize(
|
||||||
|
t.command.maxOutputBytes ?? commandDefaults?.maxOutputBytes ?? "100MB",
|
||||||
|
);
|
||||||
|
|
||||||
|
const env = { ...process.env, ...(t.command.env ?? {}) } as Record<string, string>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "command",
|
||||||
|
name: t.name,
|
||||||
|
group: target.group ?? "default",
|
||||||
|
command: {
|
||||||
|
exec: t.command.exec,
|
||||||
|
args: t.command.args ?? [],
|
||||||
|
cwd: resolvedCwd,
|
||||||
|
env,
|
||||||
|
maxOutputBytes,
|
||||||
|
},
|
||||||
|
intervalMs: context.defaultIntervalMs,
|
||||||
|
timeoutMs: context.defaultTimeoutMs,
|
||||||
|
expect: target.expect as CommandExpectConfig | undefined,
|
||||||
|
} satisfies ResolvedCommandTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||||
|
const t = target as ResolvedCommandTarget;
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const start = performance.now();
|
||||||
|
|
||||||
|
let proc: ReturnType<typeof Bun.spawn>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
proc = Bun.spawn([t.command.exec, ...t.command.args], {
|
||||||
|
cwd: t.command.cwd,
|
||||||
|
env: t.command.env,
|
||||||
|
stdin: "ignore",
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
targetName: t.name,
|
||||||
|
timestamp,
|
||||||
|
matched: false,
|
||||||
|
durationMs,
|
||||||
|
statusDetail: null,
|
||||||
|
failure: errorFailure("exitCode", "spawn", isError(error) ? error.message : String(error)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.signal.addEventListener("abort", () => {
|
||||||
|
try {
|
||||||
|
proc.kill();
|
||||||
|
} catch {
|
||||||
|
/* best-effort kill */
|
||||||
|
}
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
|
let outputResult: { stdout: string; stderr: string; exceeded: boolean };
|
||||||
|
|
||||||
|
try {
|
||||||
|
outputResult = await readOutput(
|
||||||
|
proc.stdout as ReadableStream<Uint8Array>,
|
||||||
|
proc.stderr as ReadableStream<Uint8Array>,
|
||||||
|
() => proc.kill(),
|
||||||
|
t.command.maxOutputBytes,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
targetName: t.name,
|
||||||
|
timestamp,
|
||||||
|
matched: false,
|
||||||
|
durationMs,
|
||||||
|
statusDetail: null,
|
||||||
|
failure: errorFailure("exitCode", "execution", "输出读取失败"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await proc.exited;
|
||||||
|
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
const exitCode = proc.exitCode ?? 1;
|
||||||
|
|
||||||
|
if (outputResult.exceeded) {
|
||||||
|
return {
|
||||||
|
targetName: t.name,
|
||||||
|
timestamp,
|
||||||
|
matched: false,
|
||||||
|
durationMs,
|
||||||
|
statusDetail: `exitCode=${exitCode}`,
|
||||||
|
failure: errorFailure("exitCode", "output", `输出超过限制 ${t.command.maxOutputBytes} 字节`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.signal.aborted) {
|
||||||
|
return {
|
||||||
|
targetName: t.name,
|
||||||
|
timestamp,
|
||||||
|
matched: false,
|
||||||
|
durationMs,
|
||||||
|
statusDetail: null,
|
||||||
|
failure: errorFailure("exitCode", "timeout", `命令执行超时 (${t.timeoutMs}ms)`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitCodeResult = checkExitCode(exitCode, t.expect?.exitCode ?? [0]);
|
||||||
|
if (!exitCodeResult.matched) {
|
||||||
|
return {
|
||||||
|
targetName: t.name,
|
||||||
|
timestamp,
|
||||||
|
matched: false,
|
||||||
|
durationMs,
|
||||||
|
statusDetail: `exitCode=${exitCode}`,
|
||||||
|
failure: exitCodeResult.failure,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationResult = checkDuration(durationMs, t.expect?.maxDurationMs);
|
||||||
|
if (!durationResult.matched) {
|
||||||
|
return {
|
||||||
|
targetName: t.name,
|
||||||
|
timestamp,
|
||||||
|
matched: false,
|
||||||
|
durationMs,
|
||||||
|
statusDetail: `exitCode=${exitCode}`,
|
||||||
|
failure: durationResult.failure,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.expect?.stdout && t.expect.stdout.length > 0) {
|
||||||
|
const stdoutResult = checkTextRules(outputResult.stdout, t.expect.stdout, "stdout");
|
||||||
|
if (!stdoutResult.matched) {
|
||||||
|
return {
|
||||||
|
targetName: t.name,
|
||||||
|
timestamp,
|
||||||
|
matched: false,
|
||||||
|
durationMs,
|
||||||
|
statusDetail: `exitCode=${exitCode}`,
|
||||||
|
failure: stdoutResult.failure,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.expect?.stderr && t.expect.stderr.length > 0) {
|
||||||
|
const stderrResult = checkTextRules(outputResult.stderr, t.expect.stderr, "stderr");
|
||||||
|
if (!stderrResult.matched) {
|
||||||
|
return {
|
||||||
|
targetName: t.name,
|
||||||
|
timestamp,
|
||||||
|
matched: false,
|
||||||
|
durationMs,
|
||||||
|
statusDetail: `exitCode=${exitCode}`,
|
||||||
|
failure: stderrResult.failure,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
targetName: t.name,
|
||||||
|
timestamp,
|
||||||
|
matched: true,
|
||||||
|
durationMs,
|
||||||
|
statusDetail: `exitCode=${exitCode}`,
|
||||||
|
failure: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
serialize(target: ResolvedTarget): { target: string; config: string } {
|
||||||
|
const t = target as ResolvedCommandTarget;
|
||||||
|
const parts = [t.command.exec, ...t.command.args];
|
||||||
|
return {
|
||||||
|
target: `exec ${parts.join(" ")}`,
|
||||||
|
config: JSON.stringify({
|
||||||
|
exec: t.command.exec,
|
||||||
|
args: t.command.args,
|
||||||
|
cwd: t.command.cwd,
|
||||||
|
env: t.command.env,
|
||||||
|
maxOutputBytes: t.command.maxOutputBytes,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/server/checker/runner/http/expect.ts
Normal file
95
src/server/checker/runner/http/expect.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import type { HeaderExpect, HttpExpectConfig } from "../../types";
|
||||||
|
import { mismatchFailure, errorFailure } from "../shared/failure";
|
||||||
|
import { applyOperator } from "../shared/operator";
|
||||||
|
import { checkDuration } from "../shared/duration";
|
||||||
|
import { checkBodyExpect } from "../shared/body";
|
||||||
|
import type { ExpectResult } from "../shared/duration";
|
||||||
|
|
||||||
|
export function checkStatus(statusCode: number, allowed: number[]): ExpectResult {
|
||||||
|
if (!allowed.includes(statusCode)) {
|
||||||
|
return {
|
||||||
|
matched: false,
|
||||||
|
failure: mismatchFailure(
|
||||||
|
"status",
|
||||||
|
"status",
|
||||||
|
allowed,
|
||||||
|
statusCode,
|
||||||
|
`status ${statusCode} not in [${allowed}]`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { matched: true, failure: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkHeaders(
|
||||||
|
headers: Record<string, string>,
|
||||||
|
headerExpects?: Record<string, HeaderExpect>,
|
||||||
|
): ExpectResult {
|
||||||
|
if (!headerExpects) return { matched: true, failure: null };
|
||||||
|
|
||||||
|
for (const [key, expected] of Object.entries(headerExpects)) {
|
||||||
|
const actualValue = headers[key.toLowerCase()];
|
||||||
|
const path = `headers.${key}`;
|
||||||
|
|
||||||
|
if (typeof expected === "string") {
|
||||||
|
if (actualValue !== expected) {
|
||||||
|
return {
|
||||||
|
matched: false,
|
||||||
|
failure: mismatchFailure("headers", path, expected, actualValue, `header ${key} mismatch`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (actualValue === undefined) {
|
||||||
|
if (expected.exists !== false) {
|
||||||
|
return {
|
||||||
|
matched: false,
|
||||||
|
failure: mismatchFailure("headers", path, "defined", undefined, `header ${key} not found`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!applyOperator(actualValue, expected)) {
|
||||||
|
return {
|
||||||
|
matched: false,
|
||||||
|
failure: mismatchFailure("headers", path, expected, actualValue, `header ${key} mismatch`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { matched: true, failure: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkHttpExpect(
|
||||||
|
statusCode: number,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
body: string | null,
|
||||||
|
durationMs: number,
|
||||||
|
expect?: HttpExpectConfig,
|
||||||
|
): ExpectResult {
|
||||||
|
if (!expect) {
|
||||||
|
return checkStatus(statusCode, [200]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusResult = checkStatus(statusCode, expect.status ?? [200]);
|
||||||
|
if (!statusResult.matched) return statusResult;
|
||||||
|
|
||||||
|
const durationResult = checkDuration(durationMs, expect.maxDurationMs);
|
||||||
|
if (!durationResult.matched) return durationResult;
|
||||||
|
|
||||||
|
const headersResult = checkHeaders(headers, expect.headers);
|
||||||
|
if (!headersResult.matched) return headersResult;
|
||||||
|
|
||||||
|
if (expect.body && expect.body.length > 0) {
|
||||||
|
if (body === null) {
|
||||||
|
return {
|
||||||
|
matched: false,
|
||||||
|
failure: errorFailure("body", "body", "body is null but body rules are configured"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const bodyResult = checkBodyExpect(body, expect.body);
|
||||||
|
if (!bodyResult.matched) return bodyResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { matched: true, failure: null };
|
||||||
|
}
|
||||||
145
src/server/checker/runner/http/runner.ts
Normal file
145
src/server/checker/runner/http/runner.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import type { CheckResult } from "../../types";
|
||||||
|
import { isError } from "es-toolkit";
|
||||||
|
import type {
|
||||||
|
Checker,
|
||||||
|
CheckerContext,
|
||||||
|
ResolveContext,
|
||||||
|
} from "../types";
|
||||||
|
import type {
|
||||||
|
HttpExpectConfig,
|
||||||
|
HttpTargetConfig,
|
||||||
|
ResolvedHttpTarget,
|
||||||
|
ResolvedTarget,
|
||||||
|
TargetConfig,
|
||||||
|
} from "../../types";
|
||||||
|
import { parseSize } from "../../size";
|
||||||
|
import { checkHttpExpect } from "./expect";
|
||||||
|
import { errorFailure } from "../shared/failure";
|
||||||
|
|
||||||
|
export class HttpChecker implements Checker {
|
||||||
|
readonly type = "http";
|
||||||
|
|
||||||
|
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget {
|
||||||
|
const t = target as TargetConfig & { type: "http"; http: HttpTargetConfig };
|
||||||
|
const httpDefaults = context.defaults.http;
|
||||||
|
|
||||||
|
if (!t.http.url || t.http.url.trim() === "") {
|
||||||
|
throw new Error(`target "${t.name}" 缺少 http.url 字段`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB");
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "http",
|
||||||
|
name: t.name,
|
||||||
|
group: target.group ?? "default",
|
||||||
|
http: {
|
||||||
|
url: t.http.url,
|
||||||
|
method: t.http.method ?? httpDefaults?.method ?? "GET",
|
||||||
|
headers: { ...(httpDefaults?.headers ?? {}), ...(t.http.headers ?? {}) },
|
||||||
|
body: t.http.body,
|
||||||
|
maxBodyBytes,
|
||||||
|
},
|
||||||
|
intervalMs: context.defaultIntervalMs,
|
||||||
|
timeoutMs: context.defaultTimeoutMs,
|
||||||
|
expect: target.expect as HttpExpectConfig | undefined,
|
||||||
|
} satisfies ResolvedHttpTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||||
|
const t = target as ResolvedHttpTarget;
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const start = performance.now();
|
||||||
|
|
||||||
|
const response = await fetch(t.http.url, {
|
||||||
|
method: t.http.method,
|
||||||
|
headers: t.http.headers,
|
||||||
|
body: t.http.method !== "GET" && t.http.method !== "HEAD" ? t.http.body : undefined,
|
||||||
|
signal: ctx.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
const statusCode = response.status;
|
||||||
|
const responseHeaders = Object.fromEntries(response.headers);
|
||||||
|
|
||||||
|
const hasBodyRules = !!(t.expect?.body && t.expect.body.length > 0);
|
||||||
|
|
||||||
|
const preBodyExpect = t.expect
|
||||||
|
? { status: t.expect.status, maxDurationMs: t.expect.maxDurationMs, headers: t.expect.headers }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const preBodyResult = checkHttpExpect(statusCode, responseHeaders, null, durationMs, preBodyExpect);
|
||||||
|
|
||||||
|
if (!hasBodyRules || !preBodyResult.matched) {
|
||||||
|
return {
|
||||||
|
targetName: t.name,
|
||||||
|
timestamp,
|
||||||
|
matched: preBodyResult.matched,
|
||||||
|
durationMs,
|
||||||
|
statusDetail: `HTTP ${statusCode}`,
|
||||||
|
failure: preBodyResult.failure,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyBuffer = await response.arrayBuffer();
|
||||||
|
|
||||||
|
if (bodyBuffer.byteLength > t.http.maxBodyBytes) {
|
||||||
|
return {
|
||||||
|
targetName: t.name,
|
||||||
|
timestamp,
|
||||||
|
matched: false,
|
||||||
|
durationMs,
|
||||||
|
statusDetail: `HTTP ${statusCode}`,
|
||||||
|
failure: errorFailure(
|
||||||
|
"body",
|
||||||
|
"body",
|
||||||
|
`响应体大小 ${bodyBuffer.byteLength} 超过限制 ${t.http.maxBodyBytes}`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = new TextDecoder().decode(bodyBuffer);
|
||||||
|
const fullResult = checkHttpExpect(statusCode, responseHeaders, body, durationMs, t.expect);
|
||||||
|
|
||||||
|
return {
|
||||||
|
targetName: t.name,
|
||||||
|
timestamp,
|
||||||
|
matched: fullResult.matched,
|
||||||
|
durationMs,
|
||||||
|
statusDetail: `HTTP ${statusCode}`,
|
||||||
|
failure: fullResult.failure,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const isTimeout = ctx.signal.aborted || (error instanceof DOMException && error.name === "AbortError");
|
||||||
|
|
||||||
|
return {
|
||||||
|
targetName: t.name,
|
||||||
|
timestamp,
|
||||||
|
matched: false,
|
||||||
|
durationMs: null,
|
||||||
|
statusDetail: null,
|
||||||
|
failure: errorFailure(
|
||||||
|
"status",
|
||||||
|
"request",
|
||||||
|
isTimeout ? `请求超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serialize(target: ResolvedTarget): { target: string; config: string } {
|
||||||
|
const t = target as ResolvedHttpTarget;
|
||||||
|
return {
|
||||||
|
target: t.http.url,
|
||||||
|
config: JSON.stringify({
|
||||||
|
url: t.http.url,
|
||||||
|
method: t.http.method,
|
||||||
|
headers: t.http.headers,
|
||||||
|
body: t.http.body,
|
||||||
|
maxBodyBytes: t.http.maxBodyBytes,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/server/checker/runner/index.ts
Normal file
10
src/server/checker/runner/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { checkerRegistry } from "./registry";
|
||||||
|
import { HttpChecker } from "./http/runner";
|
||||||
|
import { CommandChecker } from "./command/runner";
|
||||||
|
|
||||||
|
export function registerCheckers(): void {
|
||||||
|
checkerRegistry.register(new HttpChecker());
|
||||||
|
checkerRegistry.register(new CommandChecker());
|
||||||
|
}
|
||||||
|
|
||||||
|
export { checkerRegistry } from "./registry";
|
||||||
26
src/server/checker/runner/registry.ts
Normal file
26
src/server/checker/runner/registry.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Checker } from "./types";
|
||||||
|
|
||||||
|
export class CheckerRegistry {
|
||||||
|
private checkers = new Map<string, Checker>();
|
||||||
|
|
||||||
|
register(checker: Checker): void {
|
||||||
|
if (this.checkers.has(checker.type)) {
|
||||||
|
throw new Error(`Checker type "${checker.type}" 已注册`);
|
||||||
|
}
|
||||||
|
this.checkers.set(checker.type, checker);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(type: string): Checker {
|
||||||
|
const checker = this.checkers.get(type);
|
||||||
|
if (!checker) {
|
||||||
|
throw new Error(`不支持的 probe type: "${type}"`);
|
||||||
|
}
|
||||||
|
return checker;
|
||||||
|
}
|
||||||
|
|
||||||
|
get supportedTypes(): string[] {
|
||||||
|
return [...this.checkers.keys()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const checkerRegistry = new CheckerRegistry();
|
||||||
@@ -1,89 +1,16 @@
|
|||||||
import type { BodyRule, CheckFailure, CssRule, ExpectOperator, ExpectValue, JsonRule, XpathRule } from "../types";
|
import type { BodyRule, CssRule, JsonRule, XpathRule } from "../../types";
|
||||||
import * as cheerio from "cheerio";
|
import * as cheerio from "cheerio";
|
||||||
import * as xpath from "xpath";
|
import * as xpath from "xpath";
|
||||||
import { DOMParser } from "@xmldom/xmldom";
|
import { DOMParser } from "@xmldom/xmldom";
|
||||||
import { mismatchFailure, errorFailure } from "./failure";
|
import { mismatchFailure, errorFailure } from "./failure";
|
||||||
import { isNil, isEmptyObject, isEqual, isPlainObject } from "es-toolkit";
|
import { applyOperator, evaluateJsonPath } from "./operator";
|
||||||
|
import type { ExpectResult } from "./duration";
|
||||||
export function evaluateJsonPath(json: unknown, path: string): unknown {
|
|
||||||
if (!path.startsWith("$.")) return undefined;
|
|
||||||
|
|
||||||
const segments = path.slice(2).split(".");
|
|
||||||
let current: unknown = json;
|
|
||||||
|
|
||||||
for (const seg of segments) {
|
|
||||||
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
|
|
||||||
if (bracketMatch) {
|
|
||||||
current = (current as Record<string, unknown>)?.[bracketMatch[1]!];
|
|
||||||
const idx = parseInt(bracketMatch[2]!, 10);
|
|
||||||
if (!Array.isArray(current) || idx >= current.length) return undefined;
|
|
||||||
current = current[idx];
|
|
||||||
} else {
|
|
||||||
if (current === null || current === undefined) return undefined;
|
|
||||||
current = (current as Record<string, unknown>)[seg];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
|
|
||||||
for (const [key, expected] of Object.entries(op)) {
|
|
||||||
if (expected === undefined) continue;
|
|
||||||
|
|
||||||
switch (key) {
|
|
||||||
case "equals":
|
|
||||||
if (!isEqual(actual, expected)) return false;
|
|
||||||
break;
|
|
||||||
case "contains":
|
|
||||||
if (!String(actual).includes(expected as string)) return false;
|
|
||||||
break;
|
|
||||||
case "match":
|
|
||||||
if (!new RegExp(expected as string).test(String(actual))) return false;
|
|
||||||
break;
|
|
||||||
case "empty": {
|
|
||||||
const isEmpty =
|
|
||||||
isNil(actual) || actual === "" || (Array.isArray(actual) && actual.length === 0) || isEmptyObject(actual);
|
|
||||||
if (expected !== isEmpty) return false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "exists":
|
|
||||||
if (expected) {
|
|
||||||
if (actual === undefined) return false;
|
|
||||||
} else {
|
|
||||||
if (actual !== undefined) return false;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "gte":
|
|
||||||
if (!(Number(actual) >= (expected as number))) return false;
|
|
||||||
break;
|
|
||||||
case "lte":
|
|
||||||
if (!(Number(actual) <= (expected as number))) return false;
|
|
||||||
break;
|
|
||||||
case "gt":
|
|
||||||
if (!(Number(actual) > (expected as number))) return false;
|
|
||||||
break;
|
|
||||||
case "lt":
|
|
||||||
if (!(Number(actual) < (expected as number))) return false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function checkExpectValue(actual: unknown, expected: ExpectValue): boolean {
|
|
||||||
if (isPlainObject(expected)) {
|
|
||||||
return applyOperator(actual, expected as ExpectOperator);
|
|
||||||
}
|
|
||||||
return applyOperator(actual, { equals: expected as string | number | boolean | null });
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkJsonRule(
|
function checkJsonRule(
|
||||||
body: string,
|
body: string,
|
||||||
rule: JsonRule,
|
rule: JsonRule,
|
||||||
rulePath: string,
|
rulePath: string,
|
||||||
): { matched: boolean; failure: CheckFailure | null } {
|
): ExpectResult {
|
||||||
const { path, ...operators } = rule;
|
const { path, ...operators } = rule;
|
||||||
const fullPath = `${rulePath}.json(${path})`;
|
const fullPath = `${rulePath}.json(${path})`;
|
||||||
|
|
||||||
@@ -124,7 +51,7 @@ function checkCssRule(
|
|||||||
body: string,
|
body: string,
|
||||||
rule: CssRule,
|
rule: CssRule,
|
||||||
rulePath: string,
|
rulePath: string,
|
||||||
): { matched: boolean; failure: CheckFailure | null } {
|
): ExpectResult {
|
||||||
const { selector, attr, ...operators } = rule;
|
const { selector, attr, ...operators } = rule;
|
||||||
const fullPath = `${rulePath}.css(${selector}${attr ? `@${attr}` : ""})`;
|
const fullPath = `${rulePath}.css(${selector}${attr ? `@${attr}` : ""})`;
|
||||||
|
|
||||||
@@ -201,7 +128,7 @@ function checkXpathRule(
|
|||||||
body: string,
|
body: string,
|
||||||
rule: XpathRule,
|
rule: XpathRule,
|
||||||
rulePath: string,
|
rulePath: string,
|
||||||
): { matched: boolean; failure: CheckFailure | null } {
|
): ExpectResult {
|
||||||
const { path, ...operators } = rule;
|
const { path, ...operators } = rule;
|
||||||
const fullPath = `${rulePath}.xpath(${path})`;
|
const fullPath = `${rulePath}.xpath(${path})`;
|
||||||
|
|
||||||
@@ -245,7 +172,7 @@ function checkSingleBodyRule(
|
|||||||
body: string,
|
body: string,
|
||||||
rule: BodyRule,
|
rule: BodyRule,
|
||||||
index: number,
|
index: number,
|
||||||
): { matched: boolean; failure: CheckFailure | null } {
|
): ExpectResult {
|
||||||
const rulePath = `body[${index}]`;
|
const rulePath = `body[${index}]`;
|
||||||
|
|
||||||
if ("contains" in rule) {
|
if ("contains" in rule) {
|
||||||
@@ -285,7 +212,7 @@ function checkSingleBodyRule(
|
|||||||
return { matched: true, failure: null };
|
return { matched: true, failure: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function checkBodyExpect(body: string, rules?: BodyRule[]): { matched: boolean; failure: CheckFailure | null } {
|
export function checkBodyExpect(body: string, rules?: BodyRule[]): ExpectResult {
|
||||||
if (!rules || rules.length === 0) return { matched: true, failure: null };
|
if (!rules || rules.length === 0) return { matched: true, failure: null };
|
||||||
|
|
||||||
for (let i = 0; i < rules.length; i++) {
|
for (let i = 0; i < rules.length; i++) {
|
||||||
24
src/server/checker/runner/shared/duration.ts
Normal file
24
src/server/checker/runner/shared/duration.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { CheckFailure } from "../../types";
|
||||||
|
import { mismatchFailure } from "./failure";
|
||||||
|
|
||||||
|
export interface ExpectResult {
|
||||||
|
matched: boolean;
|
||||||
|
failure: CheckFailure | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkDuration(durationMs: number, maxDurationMs?: number): ExpectResult {
|
||||||
|
if (maxDurationMs === undefined) return { matched: true, failure: null };
|
||||||
|
if (durationMs > maxDurationMs) {
|
||||||
|
return {
|
||||||
|
matched: false,
|
||||||
|
failure: mismatchFailure(
|
||||||
|
"duration",
|
||||||
|
"duration",
|
||||||
|
`<=${maxDurationMs}ms`,
|
||||||
|
durationMs,
|
||||||
|
`duration ${durationMs}ms > ${maxDurationMs}ms`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { matched: true, failure: null };
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CheckFailure } from "../types";
|
import type { CheckFailure } from "../../types";
|
||||||
|
|
||||||
export function truncateActual(value: unknown, maxLen = 200): unknown {
|
export function truncateActual(value: unknown, maxLen = 200): unknown {
|
||||||
if (value === undefined || value === null) return value;
|
if (value === undefined || value === null) return value;
|
||||||
76
src/server/checker/runner/shared/operator.ts
Normal file
76
src/server/checker/runner/shared/operator.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { isNil, isEmptyObject, isEqual, isPlainObject } from "es-toolkit";
|
||||||
|
import type { ExpectOperator, ExpectValue } from "../../types";
|
||||||
|
|
||||||
|
export function evaluateJsonPath(json: unknown, path: string): unknown {
|
||||||
|
if (!path.startsWith("$.")) return undefined;
|
||||||
|
|
||||||
|
const segments = path.slice(2).split(".");
|
||||||
|
let current: unknown = json;
|
||||||
|
|
||||||
|
for (const seg of segments) {
|
||||||
|
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
|
||||||
|
if (bracketMatch) {
|
||||||
|
current = (current as Record<string, unknown>)?.[bracketMatch[1]!];
|
||||||
|
const idx = parseInt(bracketMatch[2]!, 10);
|
||||||
|
if (!Array.isArray(current) || idx >= current.length) return undefined;
|
||||||
|
current = current[idx];
|
||||||
|
} else {
|
||||||
|
if (current === null || current === undefined) return undefined;
|
||||||
|
current = (current as Record<string, unknown>)[seg];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
|
||||||
|
for (const [key, expected] of Object.entries(op)) {
|
||||||
|
if (expected === undefined) continue;
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case "equals":
|
||||||
|
if (!isEqual(actual, expected)) return false;
|
||||||
|
break;
|
||||||
|
case "contains":
|
||||||
|
if (!String(actual).includes(expected as string)) return false;
|
||||||
|
break;
|
||||||
|
case "match":
|
||||||
|
if (!new RegExp(expected as string).test(String(actual))) return false;
|
||||||
|
break;
|
||||||
|
case "empty": {
|
||||||
|
const isEmpty =
|
||||||
|
isNil(actual) || actual === "" || (Array.isArray(actual) && actual.length === 0) || isEmptyObject(actual);
|
||||||
|
if (expected !== isEmpty) return false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "exists":
|
||||||
|
if (expected) {
|
||||||
|
if (actual === undefined) return false;
|
||||||
|
} else {
|
||||||
|
if (actual !== undefined) return false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "gte":
|
||||||
|
if (!(Number(actual) >= (expected as number))) return false;
|
||||||
|
break;
|
||||||
|
case "lte":
|
||||||
|
if (!(Number(actual) <= (expected as number))) return false;
|
||||||
|
break;
|
||||||
|
case "gt":
|
||||||
|
if (!(Number(actual) > (expected as number))) return false;
|
||||||
|
break;
|
||||||
|
case "lt":
|
||||||
|
if (!(Number(actual) < (expected as number))) return false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkExpectValue(actual: unknown, expected: ExpectValue): boolean {
|
||||||
|
if (isPlainObject(expected)) {
|
||||||
|
return applyOperator(actual, expected as ExpectOperator);
|
||||||
|
}
|
||||||
|
return applyOperator(actual, { equals: expected as string | number | boolean | null });
|
||||||
|
}
|
||||||
18
src/server/checker/runner/shared/text.ts
Normal file
18
src/server/checker/runner/shared/text.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { TextRule } from "../../types";
|
||||||
|
import { applyOperator } from "./operator";
|
||||||
|
import { mismatchFailure } from "./failure";
|
||||||
|
import type { ExpectResult } from "./duration";
|
||||||
|
|
||||||
|
export function checkTextRules(text: string, rules: TextRule[], phase: string): ExpectResult {
|
||||||
|
for (let i = 0; i < rules.length; i++) {
|
||||||
|
const rule = rules[i]!;
|
||||||
|
const path = `${phase}[${i}]`;
|
||||||
|
if (!applyOperator(text, rule)) {
|
||||||
|
return {
|
||||||
|
matched: false,
|
||||||
|
failure: mismatchFailure(phase, path, rule, text, `${phase} rule at index ${i} mismatch`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { matched: true, failure: null };
|
||||||
|
}
|
||||||
20
src/server/checker/runner/types.ts
Normal file
20
src/server/checker/runner/types.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { CheckResult } from "../types";
|
||||||
|
import type { DefaultsConfig, ResolvedTarget, TargetConfig } from "../types";
|
||||||
|
|
||||||
|
export interface CheckerContext {
|
||||||
|
signal: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveContext {
|
||||||
|
defaults: DefaultsConfig;
|
||||||
|
configDir: string;
|
||||||
|
defaultIntervalMs: number;
|
||||||
|
defaultTimeoutMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Checker {
|
||||||
|
readonly type: string;
|
||||||
|
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget;
|
||||||
|
execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult>;
|
||||||
|
serialize(target: ResolvedTarget): { target: string; config: string };
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Database } from "bun:sqlite";
|
|||||||
import { mkdirSync as fsMkdirSync } from "node:fs";
|
import { mkdirSync as fsMkdirSync } from "node:fs";
|
||||||
import { dirname } from "node:path";
|
import { dirname } from "node:path";
|
||||||
import type { CheckFailure, ResolvedTarget, StoredCheckResult, StoredTarget } from "./types";
|
import type { CheckFailure, ResolvedTarget, StoredCheckResult, StoredTarget } from "./types";
|
||||||
|
import { checkerRegistry } from "./runner";
|
||||||
|
|
||||||
const CREATE_TARGETS_TABLE = `
|
const CREATE_TARGETS_TABLE = `
|
||||||
CREATE TABLE IF NOT EXISTS targets (
|
CREATE TABLE IF NOT EXISTS targets (
|
||||||
@@ -297,30 +298,11 @@ export class ProbeStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildTargetDisplay(t: ResolvedTarget): string {
|
function buildTargetDisplay(t: ResolvedTarget): string {
|
||||||
if (t.type === "http") {
|
return checkerRegistry.get(t.type).serialize(t).target;
|
||||||
return t.http.url;
|
|
||||||
}
|
|
||||||
const parts = [t.command.exec, ...t.command.args];
|
|
||||||
return `exec ${parts.join(" ")}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTargetConfig(t: ResolvedTarget): string {
|
function buildTargetConfig(t: ResolvedTarget): string {
|
||||||
if (t.type === "http") {
|
return checkerRegistry.get(t.type).serialize(t).config;
|
||||||
return JSON.stringify({
|
|
||||||
url: t.http.url,
|
|
||||||
method: t.http.method,
|
|
||||||
headers: t.http.headers,
|
|
||||||
body: t.http.body,
|
|
||||||
maxBodyBytes: t.http.maxBodyBytes,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return JSON.stringify({
|
|
||||||
exec: t.command.exec,
|
|
||||||
args: t.command.args,
|
|
||||||
cwd: t.command.cwd,
|
|
||||||
env: t.command.env,
|
|
||||||
maxOutputBytes: t.command.maxOutputBytes,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureDir(dir: string): void {
|
function ensureDir(dir: string): void {
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import { ProbeStore } from "./checker/store";
|
|||||||
import { ProbeEngine } from "./checker/engine";
|
import { ProbeEngine } from "./checker/engine";
|
||||||
import { startServer } from "./server";
|
import { startServer } from "./server";
|
||||||
import { readRuntimeConfig } from "./config";
|
import { readRuntimeConfig } from "./config";
|
||||||
|
import { registerCheckers } from "./checker/runner";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
registerCheckers();
|
||||||
|
|
||||||
const { configPath } = readRuntimeConfig();
|
const { configPath } = readRuntimeConfig();
|
||||||
const config = await loadConfig(configPath);
|
const config = await loadConfig(configPath);
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export interface CheckResult {
|
|||||||
|
|
||||||
export interface CheckFailure {
|
export interface CheckFailure {
|
||||||
kind: "error" | "mismatch";
|
kind: "error" | "mismatch";
|
||||||
phase: "status" | "duration" | "headers" | "body" | "exitCode" | "stdout" | "stderr";
|
phase: string;
|
||||||
path: string;
|
path: string;
|
||||||
expected?: unknown;
|
expected?: unknown;
|
||||||
actual?: unknown;
|
actual?: unknown;
|
||||||
|
|||||||
@@ -5,6 +5,20 @@ import type { HistoryResponse, SummaryResponse, TargetStatus, HealthResponse } f
|
|||||||
import { mkdir, rm } from "node:fs/promises";
|
import { mkdir, rm } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
|
import { checkerRegistry } from "../../src/server/checker/runner";
|
||||||
|
import { HttpChecker } from "../../src/server/checker/runner/http/runner";
|
||||||
|
import { CommandChecker } from "../../src/server/checker/runner/command/runner";
|
||||||
|
|
||||||
|
function ensureRegistered() {
|
||||||
|
if (!checkerRegistry.supportedTypes.includes("http")) {
|
||||||
|
checkerRegistry.register(new HttpChecker());
|
||||||
|
checkerRegistry.register(new CommandChecker());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
ensureRegistered();
|
||||||
|
});
|
||||||
|
|
||||||
const staticAssets: StaticAssets = {
|
const staticAssets: StaticAssets = {
|
||||||
indexHtml: new Blob(['<!doctype html><title>DiAL</title><div id="root"></div>'], {
|
indexHtml: new Blob(['<!doctype html><title>DiAL</title><div id="root"></div>'], {
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { runCommandCheck } from "../../../src/server/checker/command-runner";
|
|
||||||
import type { ResolvedCommandTarget } from "../../../src/server/checker/types";
|
|
||||||
|
|
||||||
function makeTarget(
|
|
||||||
command: Partial<ResolvedCommandTarget["command"]>,
|
|
||||||
overrides?: Partial<ResolvedCommandTarget>,
|
|
||||||
): ResolvedCommandTarget {
|
|
||||||
return {
|
|
||||||
type: "command",
|
|
||||||
name: "test-cmd",
|
|
||||||
group: "default",
|
|
||||||
command: {
|
|
||||||
exec: "echo",
|
|
||||||
args: ["hello"],
|
|
||||||
cwd: "/tmp",
|
|
||||||
env: {},
|
|
||||||
maxOutputBytes: 1024 * 1024,
|
|
||||||
...command,
|
|
||||||
},
|
|
||||||
intervalMs: 60000,
|
|
||||||
timeoutMs: 5000,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("runCommandCheck", () => {
|
|
||||||
test("exitCode=0 成功", async () => {
|
|
||||||
const result = await runCommandCheck(makeTarget({ exec: "true", args: [] }));
|
|
||||||
expect(result.matched).toBe(true);
|
|
||||||
expect(result.statusDetail).toBe("exitCode=0");
|
|
||||||
expect(result.failure).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("exitCode=1 不匹配默认 [0]", async () => {
|
|
||||||
const result = await runCommandCheck(makeTarget({ exec: "false", args: [] }));
|
|
||||||
expect(result.matched).toBe(false);
|
|
||||||
expect(result.statusDetail).toBe("exitCode=1");
|
|
||||||
expect(result.failure).not.toBeNull();
|
|
||||||
expect(result.failure!.phase).toBe("exitCode");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("exitCode=1 匹配自定义 [1]", async () => {
|
|
||||||
const result = await runCommandCheck(makeTarget({ exec: "false", args: [] }, { expect: { exitCode: [1] } }));
|
|
||||||
expect(result.matched).toBe(true);
|
|
||||||
expect(result.statusDetail).toBe("exitCode=1");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("命令不存在返回 spawn 错误", async () => {
|
|
||||||
const result = await runCommandCheck(makeTarget({ exec: "/nonexistent/command/xyz" }));
|
|
||||||
expect(result.matched).toBe(false);
|
|
||||||
expect(result.failure).not.toBeNull();
|
|
||||||
expect(result.failure!.phase).toBe("exitCode");
|
|
||||||
expect(result.failure!.message).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("超时返回错误", async () => {
|
|
||||||
const result = await runCommandCheck(makeTarget({ exec: "sleep", args: ["10"] }, { timeoutMs: 100 }));
|
|
||||||
expect(result.matched).toBe(false);
|
|
||||||
expect(result.failure).not.toBeNull();
|
|
||||||
expect(result.failure!.message).toContain("超时");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("stdout 输出捕获", async () => {
|
|
||||||
const result = await runCommandCheck(makeTarget({ exec: "echo", args: ["hello world"] }));
|
|
||||||
expect(result.matched).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("stdout 匹配 expect", async () => {
|
|
||||||
const result = await runCommandCheck(
|
|
||||||
makeTarget({ exec: "echo", args: ["hello"] }, { expect: { stdout: [{ contains: "hello" }] } }),
|
|
||||||
);
|
|
||||||
expect(result.matched).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("stdout 不匹配 expect", async () => {
|
|
||||||
const result = await runCommandCheck(
|
|
||||||
makeTarget({ exec: "echo", args: ["hello"] }, { expect: { stdout: [{ contains: "nonexistent" }] } }),
|
|
||||||
);
|
|
||||||
expect(result.matched).toBe(false);
|
|
||||||
expect(result.failure!.phase).toBe("stdout");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("stderr 匹配 expect", async () => {
|
|
||||||
const result = await runCommandCheck(
|
|
||||||
makeTarget({ exec: "bash", args: ["-c", "echo error >&2"] }, { expect: { stderr: [{ contains: "error" }] } }),
|
|
||||||
);
|
|
||||||
expect(result.matched).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("输出超过 maxOutputBytes", async () => {
|
|
||||||
const result = await runCommandCheck(
|
|
||||||
makeTarget({
|
|
||||||
exec: "bash",
|
|
||||||
args: ["-c", "yes | head -1000"],
|
|
||||||
maxOutputBytes: 10,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(result.matched).toBe(false);
|
|
||||||
expect(result.failure).not.toBeNull();
|
|
||||||
expect(result.failure!.message).toContain("超过限制");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("durationMs 非空", async () => {
|
|
||||||
const result = await runCommandCheck(makeTarget({ exec: "true", args: [] }));
|
|
||||||
expect(result.durationMs).not.toBeNull();
|
|
||||||
expect(result.durationMs!).toBeGreaterThanOrEqual(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ls 命令执行成功", async () => {
|
|
||||||
const result = await runCommandCheck(makeTarget({ exec: "ls", args: ["/tmp"] }));
|
|
||||||
expect(result.matched).toBe(true);
|
|
||||||
expect(result.statusDetail).toBe("exitCode=0");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("不使用 shell,通配符不被展开", async () => {
|
|
||||||
const result = await runCommandCheck(
|
|
||||||
makeTarget({ exec: "echo", args: ["*"] }, { expect: { stdout: [{ contains: "*" }] } }),
|
|
||||||
);
|
|
||||||
expect(result.matched).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("不提供 stdin,等待输入的命令会阻塞超时", async () => {
|
|
||||||
const result = await runCommandCheck(makeTarget({ exec: "bash", args: ["-c", "read line"] }, { timeoutMs: 500 }));
|
|
||||||
expect(result.matched).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -4,6 +4,20 @@ import { readRuntimeConfig } from "../../../src/server/config";
|
|||||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
|
import { checkerRegistry } from "../../../src/server/checker/runner";
|
||||||
|
import { HttpChecker } from "../../../src/server/checker/runner/http/runner";
|
||||||
|
import { CommandChecker } from "../../../src/server/checker/runner/command/runner";
|
||||||
|
|
||||||
|
function ensureRegistered() {
|
||||||
|
if (!checkerRegistry.supportedTypes.includes("http")) {
|
||||||
|
checkerRegistry.register(new HttpChecker());
|
||||||
|
checkerRegistry.register(new CommandChecker());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
ensureRegistered();
|
||||||
|
});
|
||||||
|
|
||||||
describe("parseDuration", () => {
|
describe("parseDuration", () => {
|
||||||
test("解析秒", () => {
|
test("解析秒", () => {
|
||||||
|
|||||||
@@ -2,6 +2,16 @@ import { describe, expect, test } from "bun:test";
|
|||||||
import { ProbeEngine } from "../../../src/server/checker/engine";
|
import { ProbeEngine } from "../../../src/server/checker/engine";
|
||||||
import type { ProbeStore } from "../../../src/server/checker/store";
|
import type { ProbeStore } from "../../../src/server/checker/store";
|
||||||
import type { ResolvedCommandTarget, ResolvedHttpTarget, ResolvedTarget } from "../../../src/server/checker/types";
|
import type { ResolvedCommandTarget, ResolvedHttpTarget, ResolvedTarget } from "../../../src/server/checker/types";
|
||||||
|
import { checkerRegistry } from "../../../src/server/checker/runner";
|
||||||
|
import { HttpChecker } from "../../../src/server/checker/runner/http/runner";
|
||||||
|
import { CommandChecker } from "../../../src/server/checker/runner/command/runner";
|
||||||
|
|
||||||
|
function ensureRegistered() {
|
||||||
|
if (!checkerRegistry.supportedTypes.includes("http")) {
|
||||||
|
checkerRegistry.register(new HttpChecker());
|
||||||
|
checkerRegistry.register(new CommandChecker());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createMockStore(targetNames: string[]) {
|
function createMockStore(targetNames: string[]) {
|
||||||
let nextId = 1;
|
let nextId = 1;
|
||||||
@@ -49,6 +59,7 @@ function makeCommandTarget(name: string, overrides?: Partial<ResolvedCommandTarg
|
|||||||
|
|
||||||
describe("ProbeEngine", () => {
|
describe("ProbeEngine", () => {
|
||||||
test("start/stop 不抛错", () => {
|
test("start/stop 不抛错", () => {
|
||||||
|
ensureRegistered();
|
||||||
const mockStore = createMockStore(["test"]) as unknown as ProbeStore;
|
const mockStore = createMockStore(["test"]) as unknown as ProbeStore;
|
||||||
const targets: ResolvedTarget[] = [makeCommandTarget("test")];
|
const targets: ResolvedTarget[] = [makeCommandTarget("test")];
|
||||||
const engine = new ProbeEngine(mockStore, targets);
|
const engine = new ProbeEngine(mockStore, targets);
|
||||||
|
|||||||
@@ -1,168 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { checkCommandExpect } from "../../../../src/server/checker/expect/command";
|
|
||||||
import type { CommandObservation } from "../../../../src/server/checker/expect/command";
|
|
||||||
import type { CommandExpectConfig } from "../../../../src/server/checker/types";
|
|
||||||
|
|
||||||
function obs(overrides: Partial<CommandObservation> = {}): CommandObservation {
|
|
||||||
return {
|
|
||||||
exitCode: 0,
|
|
||||||
stdout: "",
|
|
||||||
stderr: "",
|
|
||||||
durationMs: 100,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("checkCommandExpect", () => {
|
|
||||||
test("无 expect 配置时默认检查 exitCode [0] 匹配成功", () => {
|
|
||||||
const r = checkCommandExpect(obs());
|
|
||||||
expect(r.matched).toBe(true);
|
|
||||||
expect(r.failure).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("无 expect 配置时 exitCode 非 0 匹配失败", () => {
|
|
||||||
const r = checkCommandExpect(obs({ exitCode: 1 }));
|
|
||||||
expect(r.matched).toBe(false);
|
|
||||||
expect(r.failure).not.toBeNull();
|
|
||||||
expect(r.failure!.phase).toBe("exitCode");
|
|
||||||
expect(r.failure!.kind).toBe("mismatch");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("exitCode 匹配指定退出码", () => {
|
|
||||||
const cfg: CommandExpectConfig = { exitCode: [0, 1] };
|
|
||||||
expect(checkCommandExpect(obs({ exitCode: 0 }), cfg).matched).toBe(true);
|
|
||||||
expect(checkCommandExpect(obs({ exitCode: 1 }), cfg).matched).toBe(true);
|
|
||||||
expect(checkCommandExpect(obs({ exitCode: 2 }), cfg).matched).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("exitCode 不匹配返回 phase=exitCode 的失败", () => {
|
|
||||||
const r = checkCommandExpect(obs({ exitCode: 2 }), { exitCode: [0] });
|
|
||||||
expect(r.matched).toBe(false);
|
|
||||||
expect(r.failure!.phase).toBe("exitCode");
|
|
||||||
expect(r.failure!.expected).toEqual([0]);
|
|
||||||
expect(r.failure!.actual).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("duration 在限制内匹配成功", () => {
|
|
||||||
const r = checkCommandExpect(obs({ durationMs: 50 }), { maxDurationMs: 100 });
|
|
||||||
expect(r.matched).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("duration 超过限制匹配失败", () => {
|
|
||||||
const r = checkCommandExpect(obs({ durationMs: 200 }), { maxDurationMs: 100 });
|
|
||||||
expect(r.matched).toBe(false);
|
|
||||||
expect(r.failure!.phase).toBe("duration");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("stdout TextRule 数组匹配", () => {
|
|
||||||
const o = obs({ stdout: "build completed successfully" });
|
|
||||||
expect(checkCommandExpect(o, { stdout: [{ contains: "completed" }] }).matched).toBe(true);
|
|
||||||
expect(checkCommandExpect(o, { stdout: [{ contains: "failed" }] }).matched).toBe(false);
|
|
||||||
expect(checkCommandExpect(o, { stdout: [{ match: "completed.*successfully$" }] }).matched).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("stdout 多条规则全部通过", () => {
|
|
||||||
const o = obs({ stdout: "version: 3.2.1, build: ok" });
|
|
||||||
const r = checkCommandExpect(o, {
|
|
||||||
stdout: [{ contains: "version" }, { match: "\\d+\\.\\d+\\.\\d+" }],
|
|
||||||
});
|
|
||||||
expect(r.matched).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("stdout 第一条规则失败立即返回", () => {
|
|
||||||
const o = obs({ stdout: "error occurred" });
|
|
||||||
const r = checkCommandExpect(o, {
|
|
||||||
stdout: [{ contains: "success" }, { contains: "error" }],
|
|
||||||
});
|
|
||||||
expect(r.matched).toBe(false);
|
|
||||||
expect(r.failure!.phase).toBe("stdout");
|
|
||||||
expect(r.failure!.path).toBe("stdout[0]");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("stderr TextRule 数组匹配", () => {
|
|
||||||
const o = obs({ stderr: "warning: deprecated" });
|
|
||||||
expect(checkCommandExpect(o, { stderr: [{ contains: "warning" }] }).matched).toBe(true);
|
|
||||||
expect(checkCommandExpect(o, { stderr: [{ contains: "error" }] }).matched).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("stdout 失败阻止 stderr 检查", () => {
|
|
||||||
const o = obs({ stdout: "bad output", stderr: "warning message" });
|
|
||||||
const r = checkCommandExpect(o, {
|
|
||||||
exitCode: [0],
|
|
||||||
stdout: [{ contains: "success" }],
|
|
||||||
stderr: [{ contains: "warning" }],
|
|
||||||
});
|
|
||||||
expect(r.matched).toBe(false);
|
|
||||||
expect(r.failure!.phase).toBe("stdout");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("stdout 通过但 stderr 失败", () => {
|
|
||||||
const o = obs({ stdout: "ok", stderr: "fatal error" });
|
|
||||||
const r = checkCommandExpect(o, {
|
|
||||||
stdout: [{ contains: "ok" }],
|
|
||||||
stderr: [{ equals: "clean" }],
|
|
||||||
});
|
|
||||||
expect(r.matched).toBe(false);
|
|
||||||
expect(r.failure!.phase).toBe("stderr");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("完整流水线 exitCode->duration->stdout->stderr 全部通过", () => {
|
|
||||||
const o = obs({
|
|
||||||
exitCode: 0,
|
|
||||||
durationMs: 50,
|
|
||||||
stdout: "build success",
|
|
||||||
stderr: "",
|
|
||||||
});
|
|
||||||
const r = checkCommandExpect(o, {
|
|
||||||
exitCode: [0],
|
|
||||||
maxDurationMs: 100,
|
|
||||||
stdout: [{ contains: "success" }],
|
|
||||||
stderr: [{ empty: true }],
|
|
||||||
});
|
|
||||||
expect(r.matched).toBe(true);
|
|
||||||
expect(r.failure).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("完整流水线 exitCode 通过但 duration 失败", () => {
|
|
||||||
const o = obs({ exitCode: 0, durationMs: 500 });
|
|
||||||
const r = checkCommandExpect(o, {
|
|
||||||
exitCode: [0],
|
|
||||||
maxDurationMs: 100,
|
|
||||||
stdout: [{ contains: "ok" }],
|
|
||||||
});
|
|
||||||
expect(r.matched).toBe(false);
|
|
||||||
expect(r.failure!.phase).toBe("duration");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("完整流水线 exitCode/duration 通过但 stdout 失败", () => {
|
|
||||||
const o = obs({ exitCode: 0, durationMs: 50, stdout: "error" });
|
|
||||||
const r = checkCommandExpect(o, {
|
|
||||||
exitCode: [0],
|
|
||||||
maxDurationMs: 100,
|
|
||||||
stdout: [{ contains: "success" }],
|
|
||||||
});
|
|
||||||
expect(r.matched).toBe(false);
|
|
||||||
expect(r.failure!.phase).toBe("stdout");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("完整流水线 exitCode/duration/stdout 通过但 stderr 失败", () => {
|
|
||||||
const o = obs({ exitCode: 0, durationMs: 50, stdout: "ok", stderr: "warning" });
|
|
||||||
const r = checkCommandExpect(o, {
|
|
||||||
exitCode: [0],
|
|
||||||
maxDurationMs: 100,
|
|
||||||
stdout: [{ contains: "ok" }],
|
|
||||||
stderr: [{ empty: true }],
|
|
||||||
});
|
|
||||||
expect(r.matched).toBe(false);
|
|
||||||
expect(r.failure!.phase).toBe("stderr");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("stdout 操作符组合", () => {
|
|
||||||
const o = obs({ stdout: "count: 42" });
|
|
||||||
expect(
|
|
||||||
checkCommandExpect(o, {
|
|
||||||
stdout: [{ contains: "count" }, { match: "\\d+" }],
|
|
||||||
}).matched,
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { checkHttpExpect } from "../../../../src/server/checker/expect/http";
|
|
||||||
import type { HttpObservation } from "../../../../src/server/checker/expect/http";
|
|
||||||
import type { HttpExpectConfig } from "../../../../src/server/checker/types";
|
|
||||||
|
|
||||||
function obs(overrides: Partial<HttpObservation> = {}): HttpObservation {
|
|
||||||
return {
|
|
||||||
statusCode: 200,
|
|
||||||
headers: {},
|
|
||||||
body: "",
|
|
||||||
durationMs: 100,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("checkHttpExpect", () => {
|
|
||||||
test("无 expect 配置时默认检查 status [200] 匹配成功", () => {
|
|
||||||
const r = checkHttpExpect(obs());
|
|
||||||
expect(r.matched).toBe(true);
|
|
||||||
expect(r.failure).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("无 expect 配置时 status 非 200 匹配失败", () => {
|
|
||||||
const r = checkHttpExpect(obs({ statusCode: 500 }));
|
|
||||||
expect(r.matched).toBe(false);
|
|
||||||
expect(r.failure).not.toBeNull();
|
|
||||||
expect(r.failure!.phase).toBe("status");
|
|
||||||
expect(r.failure!.kind).toBe("mismatch");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("status 匹配指定状态码", () => {
|
|
||||||
const cfg: HttpExpectConfig = { status: [200, 301] };
|
|
||||||
expect(checkHttpExpect(obs({ statusCode: 200 }), cfg).matched).toBe(true);
|
|
||||||
expect(checkHttpExpect(obs({ statusCode: 301 }), cfg).matched).toBe(true);
|
|
||||||
expect(checkHttpExpect(obs({ statusCode: 404 }), cfg).matched).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("status 不匹配返回 phase=status 的失败", () => {
|
|
||||||
const r = checkHttpExpect(obs({ statusCode: 503 }), { status: [200] });
|
|
||||||
expect(r.matched).toBe(false);
|
|
||||||
expect(r.failure!.phase).toBe("status");
|
|
||||||
expect(r.failure!.expected).toEqual([200]);
|
|
||||||
expect(r.failure!.actual).toBe(503);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("duration 在限制内匹配成功", () => {
|
|
||||||
const r = checkHttpExpect(obs({ durationMs: 50 }), { maxDurationMs: 100 });
|
|
||||||
expect(r.matched).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("duration 超过限制匹配失败", () => {
|
|
||||||
const r = checkHttpExpect(obs({ durationMs: 200 }), { maxDurationMs: 100 });
|
|
||||||
expect(r.matched).toBe(false);
|
|
||||||
expect(r.failure!.phase).toBe("duration");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("duration 恰好等于限制匹配成功", () => {
|
|
||||||
const r = checkHttpExpect(obs({ durationMs: 100 }), { maxDurationMs: 100 });
|
|
||||||
expect(r.matched).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("headers 字符串格式检查(等于)", () => {
|
|
||||||
const o = obs({ headers: { "content-type": "application/json", "x-api": "v1" } });
|
|
||||||
expect(checkHttpExpect(o, { headers: { "content-type": "application/json" } }).matched).toBe(true);
|
|
||||||
expect(checkHttpExpect(o, { headers: { "content-type": "text/html" } }).matched).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("headers 操作符格式检查", () => {
|
|
||||||
const o = obs({ headers: { "content-type": "application/json" } });
|
|
||||||
expect(checkHttpExpect(o, { headers: { "content-type": { contains: "json" } } }).matched).toBe(true);
|
|
||||||
expect(checkHttpExpect(o, { headers: { "content-type": { match: "^application/" } } }).matched).toBe(true);
|
|
||||||
expect(checkHttpExpect(o, { headers: { "content-type": { contains: "xml" } } }).matched).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("headers 大小写不敏感匹配", () => {
|
|
||||||
const o = obs({ headers: { "content-type": "application/json" } });
|
|
||||||
expect(checkHttpExpect(o, { headers: { "Content-Type": "application/json" } }).matched).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("headers 不存在时返回失败", () => {
|
|
||||||
const o = obs({ headers: {} });
|
|
||||||
const r = checkHttpExpect(o, { headers: { "x-missing": "value" } });
|
|
||||||
expect(r.matched).toBe(false);
|
|
||||||
expect(r.failure!.phase).toBe("headers");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("body 规则数组按顺序检查", () => {
|
|
||||||
const o = obs({ body: JSON.stringify({ status: "ok", count: 5 }) });
|
|
||||||
const r = checkHttpExpect(o, {
|
|
||||||
body: [{ contains: "ok" }, { json: { path: "$.count", gte: 1 } }],
|
|
||||||
});
|
|
||||||
expect(r.matched).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("body 第一条规则失败立即返回", () => {
|
|
||||||
const o = obs({ body: "hello world" });
|
|
||||||
const r = checkHttpExpect(o, {
|
|
||||||
body: [{ contains: "missing" }, { contains: "hello" }],
|
|
||||||
});
|
|
||||||
expect(r.matched).toBe(false);
|
|
||||||
expect(r.failure!.path).toBe("body[0]");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("body 为 null 但有 body 规则时报错", () => {
|
|
||||||
const o = obs({ body: null });
|
|
||||||
const r = checkHttpExpect(o, { body: [{ contains: "test" }] });
|
|
||||||
expect(r.matched).toBe(false);
|
|
||||||
expect(r.failure!.kind).toBe("error");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("完整流水线 status->duration->headers->body 全部通过", () => {
|
|
||||||
const o = obs({
|
|
||||||
statusCode: 200,
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({ status: "healthy" }),
|
|
||||||
durationMs: 50,
|
|
||||||
});
|
|
||||||
const r = checkHttpExpect(o, {
|
|
||||||
status: [200],
|
|
||||||
maxDurationMs: 100,
|
|
||||||
headers: { "content-type": { contains: "json" } },
|
|
||||||
body: [{ json: { path: "$.status", equals: "healthy" } }],
|
|
||||||
});
|
|
||||||
expect(r.matched).toBe(true);
|
|
||||||
expect(r.failure).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("完整流水线 status 通过但 duration 失败", () => {
|
|
||||||
const o = obs({ statusCode: 200, durationMs: 500 });
|
|
||||||
const r = checkHttpExpect(o, {
|
|
||||||
status: [200],
|
|
||||||
maxDurationMs: 100,
|
|
||||||
});
|
|
||||||
expect(r.matched).toBe(false);
|
|
||||||
expect(r.failure!.phase).toBe("duration");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("完整流水线 status 和 duration 通过但 headers 失败", () => {
|
|
||||||
const o = obs({ statusCode: 200, durationMs: 50, headers: { "x-api": "v1" } });
|
|
||||||
const r = checkHttpExpect(o, {
|
|
||||||
status: [200],
|
|
||||||
maxDurationMs: 100,
|
|
||||||
headers: { "x-api": "v2" },
|
|
||||||
});
|
|
||||||
expect(r.matched).toBe(false);
|
|
||||||
expect(r.failure!.phase).toBe("headers");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("完整流水线 status/duration/headers 通过但 body 失败", () => {
|
|
||||||
const o = obs({
|
|
||||||
statusCode: 200,
|
|
||||||
durationMs: 50,
|
|
||||||
headers: { "content-type": "text/plain" },
|
|
||||||
body: "error occurred",
|
|
||||||
});
|
|
||||||
const r = checkHttpExpect(o, {
|
|
||||||
status: [200],
|
|
||||||
maxDurationMs: 100,
|
|
||||||
headers: { "content-type": "text/plain" },
|
|
||||||
body: [{ contains: "success" }],
|
|
||||||
});
|
|
||||||
expect(r.matched).toBe(false);
|
|
||||||
expect(r.failure!.phase).toBe("body");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
25
tests/server/checker/runner/command/expect.test.ts
Normal file
25
tests/server/checker/runner/command/expect.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { checkExitCode } from "../../../../../src/server/checker/runner/command/expect";
|
||||||
|
|
||||||
|
describe("checkExitCode", () => {
|
||||||
|
test("exitCode 在允许列表中匹配成功", () => {
|
||||||
|
const r = checkExitCode(0, [0]);
|
||||||
|
expect(r.matched).toBe(true);
|
||||||
|
expect(r.failure).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exitCode 不在允许列表中匹配失败", () => {
|
||||||
|
const r = checkExitCode(1, [0]);
|
||||||
|
expect(r.matched).toBe(false);
|
||||||
|
expect(r.failure!.phase).toBe("exitCode");
|
||||||
|
expect(r.failure!.kind).toBe("mismatch");
|
||||||
|
expect(r.failure!.expected).toEqual([0]);
|
||||||
|
expect(r.failure!.actual).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("多个允许退出码", () => {
|
||||||
|
expect(checkExitCode(0, [0, 1]).matched).toBe(true);
|
||||||
|
expect(checkExitCode(1, [0, 1]).matched).toBe(true);
|
||||||
|
expect(checkExitCode(2, [0, 1]).matched).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
116
tests/server/checker/runner/command/runner.test.ts
Normal file
116
tests/server/checker/runner/command/runner.test.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { CommandChecker } from "../../../../../src/server/checker/runner/command/runner";
|
||||||
|
import type { CheckerContext } from "../../../../../src/server/checker/runner/types";
|
||||||
|
import type { ResolvedCommandTarget } from "../../../../../src/server/checker/types";
|
||||||
|
|
||||||
|
const checker = new CommandChecker();
|
||||||
|
|
||||||
|
function makeTarget(
|
||||||
|
command: Partial<ResolvedCommandTarget["command"]>,
|
||||||
|
overrides?: Partial<ResolvedCommandTarget>,
|
||||||
|
): ResolvedCommandTarget {
|
||||||
|
return {
|
||||||
|
type: "command",
|
||||||
|
name: "test-cmd",
|
||||||
|
group: "default",
|
||||||
|
command: {
|
||||||
|
exec: "echo",
|
||||||
|
args: ["hello"],
|
||||||
|
cwd: "/tmp",
|
||||||
|
env: {},
|
||||||
|
maxOutputBytes: 1024 * 1024,
|
||||||
|
...command,
|
||||||
|
},
|
||||||
|
intervalMs: 60000,
|
||||||
|
timeoutMs: 5000,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCtx(timeoutMs = 5000): CheckerContext {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
return { signal: controller.signal };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CommandChecker", () => {
|
||||||
|
test("exitCode=0 成功", async () => {
|
||||||
|
const result = await checker.execute(makeTarget({ exec: "true", args: [] }), makeCtx());
|
||||||
|
expect(result.matched).toBe(true);
|
||||||
|
expect(result.statusDetail).toBe("exitCode=0");
|
||||||
|
expect(result.failure).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exitCode=1 不匹配默认 [0]", async () => {
|
||||||
|
const result = await checker.execute(makeTarget({ exec: "false", args: [] }), makeCtx());
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.statusDetail).toBe("exitCode=1");
|
||||||
|
expect(result.failure!.phase).toBe("exitCode");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exitCode=1 匹配自定义 [1]", async () => {
|
||||||
|
const result = await checker.execute(makeTarget({ exec: "false", args: [] }, { expect: { exitCode: [1] } }), makeCtx());
|
||||||
|
expect(result.matched).toBe(true);
|
||||||
|
expect(result.statusDetail).toBe("exitCode=1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("命令不存在返回 spawn 错误", async () => {
|
||||||
|
const result = await checker.execute(makeTarget({ exec: "/nonexistent/command/xyz" }), makeCtx());
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.failure!.phase).toBe("exitCode");
|
||||||
|
expect(result.failure!.message).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("超时返回错误", async () => {
|
||||||
|
const result = await checker.execute(makeTarget({ exec: "sleep", args: ["10"] }, { timeoutMs: 100 }), makeCtx(100));
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.failure!.message).toContain("超时");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stdout 输出捕获", async () => {
|
||||||
|
const result = await checker.execute(makeTarget({ exec: "echo", args: ["hello world"] }), makeCtx());
|
||||||
|
expect(result.matched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stdout 匹配 expect", async () => {
|
||||||
|
const result = await checker.execute(makeTarget({ exec: "echo", args: ["hello"] }, { expect: { stdout: [{ contains: "hello" }] } }), makeCtx());
|
||||||
|
expect(result.matched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stdout 不匹配 expect", async () => {
|
||||||
|
const result = await checker.execute(makeTarget({ exec: "echo", args: ["hello"] }, { expect: { stdout: [{ contains: "nonexistent" }] } }), makeCtx());
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.failure!.phase).toBe("stdout");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stderr 匹配 expect", async () => {
|
||||||
|
const result = await checker.execute(makeTarget({ exec: "bash", args: ["-c", "echo error >&2"] }, { expect: { stderr: [{ contains: "error" }] } }), makeCtx());
|
||||||
|
expect(result.matched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("输出超过 maxOutputBytes", async () => {
|
||||||
|
const result = await checker.execute(makeTarget({ exec: "bash", args: ["-c", "yes | head -1000"], maxOutputBytes: 10 }), makeCtx());
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.failure!.message).toContain("超过限制");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("durationMs 非空", async () => {
|
||||||
|
const result = await checker.execute(makeTarget({ exec: "true", args: [] }), makeCtx());
|
||||||
|
expect(result.durationMs).not.toBeNull();
|
||||||
|
expect(result.durationMs!).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("不使用 shell,通配符不被展开", async () => {
|
||||||
|
const result = await checker.execute(makeTarget({ exec: "echo", args: ["*"] }, { expect: { stdout: [{ contains: "*" }] } }), makeCtx());
|
||||||
|
expect(result.matched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serialize 返回命令摘要和 config JSON", () => {
|
||||||
|
const target = makeTarget({ exec: "echo", args: ["hello"] });
|
||||||
|
const s = checker.serialize(target);
|
||||||
|
expect(s.target).toBe("exec echo hello");
|
||||||
|
const config = JSON.parse(s.config);
|
||||||
|
expect(config.exec).toBe("echo");
|
||||||
|
expect(config.args).toEqual(["hello"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
142
tests/server/checker/runner/http/expect.test.ts
Normal file
142
tests/server/checker/runner/http/expect.test.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { checkHttpExpect } from "../../../../../src/server/checker/runner/http/expect";
|
||||||
|
|
||||||
|
function obs(overrides: { statusCode?: number; headers?: Record<string, string>; body?: string | null; durationMs?: number } = {}) {
|
||||||
|
return {
|
||||||
|
statusCode: overrides.statusCode ?? 200,
|
||||||
|
headers: overrides.headers ?? {},
|
||||||
|
body: overrides.body ?? "",
|
||||||
|
durationMs: overrides.durationMs ?? 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("checkHttpExpect", () => {
|
||||||
|
test("无 expect 配置时默认检查 status [200] 匹配成功", () => {
|
||||||
|
const r = checkHttpExpect(obs().statusCode, obs().headers, obs().body as string, obs().durationMs);
|
||||||
|
expect(r.matched).toBe(true);
|
||||||
|
expect(r.failure).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("无 expect 配置时 status 非 200 匹配失败", () => {
|
||||||
|
const r = checkHttpExpect(500, {}, "", 100);
|
||||||
|
expect(r.matched).toBe(false);
|
||||||
|
expect(r.failure).not.toBeNull();
|
||||||
|
expect(r.failure!.phase).toBe("status");
|
||||||
|
expect(r.failure!.kind).toBe("mismatch");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("status 匹配指定状态码", () => {
|
||||||
|
const cfg = { status: [200, 301] };
|
||||||
|
expect(checkHttpExpect(200, {}, "", 100, cfg).matched).toBe(true);
|
||||||
|
expect(checkHttpExpect(301, {}, "", 100, cfg).matched).toBe(true);
|
||||||
|
expect(checkHttpExpect(404, {}, "", 100, cfg).matched).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("status 不匹配返回 phase=status 的失败", () => {
|
||||||
|
const r = checkHttpExpect(503, {}, "", 100, { status: [200] });
|
||||||
|
expect(r.matched).toBe(false);
|
||||||
|
expect(r.failure!.phase).toBe("status");
|
||||||
|
expect(r.failure!.expected).toEqual([200]);
|
||||||
|
expect(r.failure!.actual).toBe(503);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("duration 在限制内匹配成功", () => {
|
||||||
|
const r = checkHttpExpect(200, {}, "", 50, { maxDurationMs: 100 });
|
||||||
|
expect(r.matched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("duration 超过限制匹配失败", () => {
|
||||||
|
const r = checkHttpExpect(200, {}, "", 200, { maxDurationMs: 100 });
|
||||||
|
expect(r.matched).toBe(false);
|
||||||
|
expect(r.failure!.phase).toBe("duration");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("duration 恰好等于限制匹配成功", () => {
|
||||||
|
const r = checkHttpExpect(200, {}, "", 100, { maxDurationMs: 100 });
|
||||||
|
expect(r.matched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("headers 字符串格式检查(等于)", () => {
|
||||||
|
const h = { "content-type": "application/json", "x-api": "v1" };
|
||||||
|
expect(checkHttpExpect(200, h, "", 100, { headers: { "content-type": "application/json" } }).matched).toBe(true);
|
||||||
|
expect(checkHttpExpect(200, h, "", 100, { headers: { "content-type": "text/html" } }).matched).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("headers 操作符格式检查", () => {
|
||||||
|
const h = { "content-type": "application/json" };
|
||||||
|
expect(checkHttpExpect(200, h, "", 100, { headers: { "content-type": { contains: "json" } } }).matched).toBe(true);
|
||||||
|
expect(checkHttpExpect(200, h, "", 100, { headers: { "content-type": { match: "^application/" } } }).matched).toBe(true);
|
||||||
|
expect(checkHttpExpect(200, h, "", 100, { headers: { "content-type": { contains: "xml" } } }).matched).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("headers 大小写不敏感匹配", () => {
|
||||||
|
const h = { "content-type": "application/json" };
|
||||||
|
expect(checkHttpExpect(200, h, "", 100, { headers: { "Content-Type": "application/json" } }).matched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("headers 不存在时返回失败", () => {
|
||||||
|
const r = checkHttpExpect(200, {}, "", 100, { headers: { "x-missing": "value" } });
|
||||||
|
expect(r.matched).toBe(false);
|
||||||
|
expect(r.failure!.phase).toBe("headers");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("body 规则数组按顺序检查", () => {
|
||||||
|
const body = JSON.stringify({ status: "ok", count: 5 });
|
||||||
|
const r = checkHttpExpect(200, {}, body, 100, {
|
||||||
|
body: [{ contains: "ok" }, { json: { path: "$.count", gte: 1 } }],
|
||||||
|
});
|
||||||
|
expect(r.matched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("body 第一条规则失败立即返回", () => {
|
||||||
|
const r = checkHttpExpect(200, {}, "hello world", 100, {
|
||||||
|
body: [{ contains: "missing" }, { contains: "hello" }],
|
||||||
|
});
|
||||||
|
expect(r.matched).toBe(false);
|
||||||
|
expect(r.failure!.path).toBe("body[0]");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("body 为 null 但有 body 规则时报错", () => {
|
||||||
|
const r = checkHttpExpect(200, {}, null, 100, { body: [{ contains: "test" }] });
|
||||||
|
expect(r.matched).toBe(false);
|
||||||
|
expect(r.failure!.kind).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("完整流水线 status->duration->headers->body 全部通过", () => {
|
||||||
|
const r = checkHttpExpect(200, { "content-type": "application/json" }, JSON.stringify({ status: "healthy" }), 50, {
|
||||||
|
status: [200],
|
||||||
|
maxDurationMs: 100,
|
||||||
|
headers: { "content-type": { contains: "json" } },
|
||||||
|
body: [{ json: { path: "$.status", equals: "healthy" } }],
|
||||||
|
});
|
||||||
|
expect(r.matched).toBe(true);
|
||||||
|
expect(r.failure).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("完整流水线 status 通过但 duration 失败", () => {
|
||||||
|
const r = checkHttpExpect(200, {}, "", 500, { status: [200], maxDurationMs: 100 });
|
||||||
|
expect(r.matched).toBe(false);
|
||||||
|
expect(r.failure!.phase).toBe("duration");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("完整流水线 status 和 duration 通过但 headers 失败", () => {
|
||||||
|
const r = checkHttpExpect(200, { "x-api": "v1" }, "", 50, {
|
||||||
|
status: [200],
|
||||||
|
maxDurationMs: 100,
|
||||||
|
headers: { "x-api": "v2" },
|
||||||
|
});
|
||||||
|
expect(r.matched).toBe(false);
|
||||||
|
expect(r.failure!.phase).toBe("headers");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("完整流水线 status/duration/headers 通过但 body 失败", () => {
|
||||||
|
const r = checkHttpExpect(200, { "content-type": "text/plain" }, "error occurred", 50, {
|
||||||
|
status: [200],
|
||||||
|
maxDurationMs: 100,
|
||||||
|
headers: { "content-type": "text/plain" },
|
||||||
|
body: [{ contains: "success" }],
|
||||||
|
});
|
||||||
|
expect(r.matched).toBe(false);
|
||||||
|
expect(r.failure!.phase).toBe("body");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { runHttpCheck } from "../../../src/server/checker/fetcher";
|
import { HttpChecker } from "../../../../../src/server/checker/runner/http/runner";
|
||||||
|
import type { CheckerContext } from "../../../../../src/server/checker/runner/types";
|
||||||
|
import type { ResolvedHttpTarget } from "../../../../../src/server/checker/types";
|
||||||
|
|
||||||
describe("runHttpCheck", () => {
|
const checker = new HttpChecker();
|
||||||
test("checkExpect 已移除", async () => {
|
|
||||||
const mod = await import("../../../src/server/checker/fetcher");
|
|
||||||
expect((mod as Record<string, unknown>).checkExpect).toBeUndefined();
|
|
||||||
expect((mod as Record<string, unknown>).fetchTarget).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("runHttpCheck 集成", () => {
|
describe("HttpChecker", () => {
|
||||||
let server: ReturnType<typeof Bun.serve>;
|
let server: ReturnType<typeof Bun.serve>;
|
||||||
let baseUrl: string;
|
let baseUrl: string;
|
||||||
|
|
||||||
@@ -35,8 +31,6 @@ describe("runHttpCheck 集成", () => {
|
|||||||
return new Response("x".repeat(2000));
|
return new Response("x".repeat(2000));
|
||||||
case "/notfound":
|
case "/notfound":
|
||||||
return new Response("not found", { status: 404 });
|
return new Response("not found", { status: 404 });
|
||||||
case "/slow":
|
|
||||||
return new Response("slow", { status: 200 });
|
|
||||||
default:
|
default:
|
||||||
return new Response("ok");
|
return new Response("ok");
|
||||||
}
|
}
|
||||||
@@ -57,26 +51,32 @@ describe("runHttpCheck 集成", () => {
|
|||||||
expect?: Record<string, unknown>;
|
expect?: Record<string, unknown>;
|
||||||
maxBodyBytes?: number;
|
maxBodyBytes?: number;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
}) {
|
}): ResolvedHttpTarget {
|
||||||
return {
|
return {
|
||||||
type: "http" as const,
|
type: "http",
|
||||||
name: "test-http",
|
name: "test-http",
|
||||||
group: "default",
|
group: "default",
|
||||||
http: {
|
http: {
|
||||||
url: overrides.url ?? `${baseUrl}/ok`,
|
url: overrides.url ?? `${baseUrl}/ok`,
|
||||||
method: overrides.method ?? "GET",
|
method: overrides.method ?? "GET",
|
||||||
headers: overrides.headers ?? ({} as Record<string, string>),
|
headers: overrides.headers ?? {},
|
||||||
body: overrides.body,
|
body: overrides.body,
|
||||||
maxBodyBytes: overrides.maxBodyBytes ?? 1024 * 1024,
|
maxBodyBytes: overrides.maxBodyBytes ?? 1024 * 1024,
|
||||||
},
|
},
|
||||||
intervalMs: 60000,
|
intervalMs: 60000,
|
||||||
timeoutMs: overrides.timeoutMs ?? 5000,
|
timeoutMs: overrides.timeoutMs ?? 5000,
|
||||||
expect: overrides.expect as import("../../../src/server/checker/types").HttpExpectConfig | undefined,
|
expect: overrides.expect as ResolvedHttpTarget["expect"],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeCtx(timeoutMs = 5000): CheckerContext {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
return { signal: controller.signal };
|
||||||
|
}
|
||||||
|
|
||||||
test("成功请求 200", async () => {
|
test("成功请求 200", async () => {
|
||||||
const result = await runHttpCheck(makeTarget({ url: `${baseUrl}/ok` }));
|
const result = await checker.execute(makeTarget({ url: `${baseUrl}/ok` }), makeCtx());
|
||||||
expect(result.matched).toBe(true);
|
expect(result.matched).toBe(true);
|
||||||
expect(result.statusDetail).toBe("HTTP 200");
|
expect(result.statusDetail).toBe("HTTP 200");
|
||||||
expect(result.durationMs).not.toBeNull();
|
expect(result.durationMs).not.toBeNull();
|
||||||
@@ -84,85 +84,47 @@ describe("runHttpCheck 集成", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("404 不匹配默认 status [200]", async () => {
|
test("404 不匹配默认 status [200]", async () => {
|
||||||
const result = await runHttpCheck(makeTarget({ url: `${baseUrl}/notfound` }));
|
const result = await checker.execute(makeTarget({ url: `${baseUrl}/notfound` }), makeCtx());
|
||||||
expect(result.matched).toBe(false);
|
expect(result.matched).toBe(false);
|
||||||
expect(result.statusDetail).toBe("HTTP 404");
|
expect(result.statusDetail).toBe("HTTP 404");
|
||||||
expect(result.failure).not.toBeNull();
|
|
||||||
expect(result.failure!.phase).toBe("status");
|
expect(result.failure!.phase).toBe("status");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("404 匹配自定义 status [404]", async () => {
|
test("404 匹配自定义 status [404]", async () => {
|
||||||
const result = await runHttpCheck(
|
const result = await checker.execute(makeTarget({ url: `${baseUrl}/notfound`, expect: { status: [404] } }), makeCtx());
|
||||||
makeTarget({
|
|
||||||
url: `${baseUrl}/notfound`,
|
|
||||||
expect: { status: [404] },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(result.matched).toBe(true);
|
expect(result.matched).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("headers 检查通过", async () => {
|
test("headers 检查通过", async () => {
|
||||||
const result = await runHttpCheck(
|
const result = await checker.execute(makeTarget({ url: `${baseUrl}/ok`, expect: { headers: { "x-custom": "test-value" } } }), makeCtx());
|
||||||
makeTarget({
|
|
||||||
url: `${baseUrl}/ok`,
|
|
||||||
expect: { headers: { "x-custom": "test-value" } },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(result.matched).toBe(true);
|
expect(result.matched).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("headers 检查失败", async () => {
|
test("headers 检查失败", async () => {
|
||||||
const result = await runHttpCheck(
|
const result = await checker.execute(makeTarget({ url: `${baseUrl}/ok`, expect: { headers: { "x-custom": "wrong-value" } } }), makeCtx());
|
||||||
makeTarget({
|
|
||||||
url: `${baseUrl}/ok`,
|
|
||||||
expect: { headers: { "x-custom": "wrong-value" } },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(result.matched).toBe(false);
|
expect(result.matched).toBe(false);
|
||||||
expect(result.failure!.phase).toBe("headers");
|
expect(result.failure!.phase).toBe("headers");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("body contains 检查", async () => {
|
test("body contains 检查", async () => {
|
||||||
const result = await runHttpCheck(
|
const result = await checker.execute(makeTarget({ url: `${baseUrl}/ok`, expect: { body: [{ contains: "hello" }] } }), makeCtx());
|
||||||
makeTarget({
|
|
||||||
url: `${baseUrl}/ok`,
|
|
||||||
expect: { body: [{ contains: "hello" }] },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(result.matched).toBe(true);
|
expect(result.matched).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("body contains 失败", async () => {
|
test("body contains 失败", async () => {
|
||||||
const result = await runHttpCheck(
|
const result = await checker.execute(makeTarget({ url: `${baseUrl}/ok`, expect: { body: [{ contains: "nonexistent" }] } }), makeCtx());
|
||||||
makeTarget({
|
|
||||||
url: `${baseUrl}/ok`,
|
|
||||||
expect: { body: [{ contains: "nonexistent" }] },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(result.matched).toBe(false);
|
expect(result.matched).toBe(false);
|
||||||
expect(result.failure!.phase).toBe("body");
|
expect(result.failure!.phase).toBe("body");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("body json 检查", async () => {
|
test("body json 检查", async () => {
|
||||||
const result = await runHttpCheck(
|
const result = await checker.execute(makeTarget({ url: `${baseUrl}/json`, expect: { body: [{ json: { path: "$.status", equals: "ok" } }] } }), makeCtx());
|
||||||
makeTarget({
|
|
||||||
url: `${baseUrl}/json`,
|
|
||||||
expect: { body: [{ json: { path: "$.status", equals: "ok" } }] },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(result.matched).toBe(true);
|
expect(result.matched).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("响应体超过 maxBodyBytes", async () => {
|
test("响应体超过 maxBodyBytes", async () => {
|
||||||
const result = await runHttpCheck(
|
const result = await checker.execute(makeTarget({ url: `${baseUrl}/large`, maxBodyBytes: 100, expect: { body: [{ contains: "x" }] } }), makeCtx());
|
||||||
makeTarget({
|
|
||||||
url: `${baseUrl}/large`,
|
|
||||||
maxBodyBytes: 100,
|
|
||||||
expect: { body: [{ contains: "x" }] },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(result.matched).toBe(false);
|
expect(result.matched).toBe(false);
|
||||||
expect(result.failure).not.toBeNull();
|
|
||||||
expect(result.failure!.phase).toBe("body");
|
expect(result.failure!.phase).toBe("body");
|
||||||
expect(result.failure!.message).toContain("超过限制");
|
expect(result.failure!.message).toContain("超过限制");
|
||||||
});
|
});
|
||||||
@@ -177,14 +139,8 @@ describe("runHttpCheck 集成", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runHttpCheck(
|
const result = await checker.execute(makeTarget({ url: `http://localhost:${timeoutServer.port}/`, timeoutMs: 100 }), makeCtx(100));
|
||||||
makeTarget({
|
|
||||||
url: `http://localhost:${timeoutServer.port}/`,
|
|
||||||
timeoutMs: 100,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(result.matched).toBe(false);
|
expect(result.matched).toBe(false);
|
||||||
expect(result.failure).not.toBeNull();
|
|
||||||
expect(result.failure!.message).toContain("超时");
|
expect(result.failure!.message).toContain("超时");
|
||||||
} finally {
|
} finally {
|
||||||
timeoutServer.stop();
|
timeoutServer.stop();
|
||||||
@@ -192,63 +148,33 @@ describe("runHttpCheck 集成", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("快速失败:status 失败时不读取 body", async () => {
|
test("快速失败:status 失败时不读取 body", async () => {
|
||||||
const result = await runHttpCheck(
|
const result = await checker.execute(makeTarget({ url: `${baseUrl}/notfound`, expect: { status: [200], body: [{ contains: "something" }] } }), makeCtx());
|
||||||
makeTarget({
|
|
||||||
url: `${baseUrl}/notfound`,
|
|
||||||
expect: { status: [200], body: [{ contains: "something" }] },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(result.matched).toBe(false);
|
expect(result.matched).toBe(false);
|
||||||
expect(result.failure!.phase).toBe("status");
|
expect(result.failure!.phase).toBe("status");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("快速失败:headers 失败时不读取 body", async () => {
|
|
||||||
const result = await runHttpCheck(
|
|
||||||
makeTarget({
|
|
||||||
url: `${baseUrl}/ok`,
|
|
||||||
expect: { headers: { "x-missing": "value" }, body: [{ contains: "hello" }] },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(result.matched).toBe(false);
|
|
||||||
expect(result.failure!.phase).toBe("headers");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("status 通过但 body 失败", async () => {
|
test("status 通过但 body 失败", async () => {
|
||||||
const result = await runHttpCheck(
|
const result = await checker.execute(makeTarget({ url: `${baseUrl}/ok`, expect: { status: [200], body: [{ contains: "not-in-body" }] } }), makeCtx());
|
||||||
makeTarget({
|
|
||||||
url: `${baseUrl}/ok`,
|
|
||||||
expect: { status: [200], body: [{ contains: "not-in-body" }] },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(result.matched).toBe(false);
|
expect(result.matched).toBe(false);
|
||||||
expect(result.failure!.phase).toBe("body");
|
expect(result.failure!.phase).toBe("body");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("无 expect 时默认检查 status 200", async () => {
|
test("无 expect 时默认检查 status 200", async () => {
|
||||||
const result = await runHttpCheck(makeTarget({ url: `${baseUrl}/ok`, expect: undefined }));
|
const result = await checker.execute(makeTarget({ url: `${baseUrl}/ok`, expect: undefined }), makeCtx());
|
||||||
expect(result.matched).toBe(true);
|
expect(result.matched).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("POST 请求携带 body", async () => {
|
test("POST 请求携带 body", async () => {
|
||||||
const result = await runHttpCheck(
|
const result = await checker.execute(makeTarget({ url: `${baseUrl}/echo`, method: "POST", body: "test-body", headers: { "content-type": "text/plain" }, expect: { status: [200], body: [{ json: { path: "$.body", equals: "present" } }] } }), makeCtx());
|
||||||
makeTarget({
|
|
||||||
url: `${baseUrl}/echo`,
|
|
||||||
method: "POST",
|
|
||||||
body: "test-body",
|
|
||||||
headers: { "content-type": "text/plain" },
|
|
||||||
expect: { status: [200], body: [{ json: { path: "$.body", equals: "present" } }] },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(result.matched).toBe(true);
|
expect(result.matched).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("仅 contains 规则时不解析 JSON", async () => {
|
test("serialize 返回 URL 和 config JSON", () => {
|
||||||
const result = await runHttpCheck(
|
const target = makeTarget({});
|
||||||
makeTarget({
|
const s = checker.serialize(target);
|
||||||
url: `${baseUrl}/ok`,
|
expect(s.target).toBe(target.http.url);
|
||||||
expect: { body: [{ contains: "hello world" }] },
|
const config = JSON.parse(s.config);
|
||||||
}),
|
expect(config.url).toBe(target.http.url);
|
||||||
);
|
expect(config.method).toBe("GET");
|
||||||
expect(result.matched).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
39
tests/server/checker/runner/registry.test.ts
Normal file
39
tests/server/checker/runner/registry.test.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { CheckerRegistry } from "../../../../src/server/checker/runner/registry";
|
||||||
|
import type { Checker } from "../../../../src/server/checker/runner/types";
|
||||||
|
|
||||||
|
function createChecker(type: string): Checker {
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
resolve: () => ({}) as any,
|
||||||
|
execute: () => Promise.resolve({} as any),
|
||||||
|
serialize: () => ({ target: "", config: "" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CheckerRegistry", () => {
|
||||||
|
test("注册并获取 Checker", () => {
|
||||||
|
const registry = new CheckerRegistry();
|
||||||
|
const checker = createChecker("http");
|
||||||
|
registry.register(checker);
|
||||||
|
expect(registry.get("http")).toBe(checker);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("获取未注册的 type 抛出错误", () => {
|
||||||
|
const registry = new CheckerRegistry();
|
||||||
|
expect(() => registry.get("unknown")).toThrow("不支持的 probe type");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("重复注册同一 type 抛出错误", () => {
|
||||||
|
const registry = new CheckerRegistry();
|
||||||
|
registry.register(createChecker("http"));
|
||||||
|
expect(() => registry.register(createChecker("http"))).toThrow("已注册");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("查询支持的 type 列表", () => {
|
||||||
|
const registry = new CheckerRegistry();
|
||||||
|
registry.register(createChecker("http"));
|
||||||
|
registry.register(createChecker("command"));
|
||||||
|
expect(registry.supportedTypes).toEqual(["http", "command"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,149 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import {
|
import { checkBodyExpect } from "../../../../../src/server/checker/runner/shared/body";
|
||||||
applyOperator,
|
|
||||||
checkBodyExpect,
|
|
||||||
checkExpectValue,
|
|
||||||
evaluateJsonPath,
|
|
||||||
} from "../../../../src/server/checker/expect/body";
|
|
||||||
|
|
||||||
describe("evaluateJsonPath", () => {
|
|
||||||
const obj = {
|
|
||||||
status: "ok",
|
|
||||||
code: 0,
|
|
||||||
active: true,
|
|
||||||
error: null,
|
|
||||||
data: {
|
|
||||||
count: 42,
|
|
||||||
items: [{ name: "a" }, { name: "b" }],
|
|
||||||
nested: { deep: "value" },
|
|
||||||
},
|
|
||||||
emptyObj: {},
|
|
||||||
emptyArr: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
test("简单字段访问", () => {
|
|
||||||
expect(evaluateJsonPath(obj, "$.status")).toBe("ok");
|
|
||||||
expect(evaluateJsonPath(obj, "$.code")).toBe(0);
|
|
||||||
expect(evaluateJsonPath(obj, "$.active")).toBe(true);
|
|
||||||
expect(evaluateJsonPath(obj, "$.error")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("嵌套对象访问", () => {
|
|
||||||
expect(evaluateJsonPath(obj, "$.data.count")).toBe(42);
|
|
||||||
expect(evaluateJsonPath(obj, "$.data.nested.deep")).toBe("value");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("数组索引访问", () => {
|
|
||||||
expect(evaluateJsonPath(obj, "$.data.items[0].name")).toBe("a");
|
|
||||||
expect(evaluateJsonPath(obj, "$.data.items[1].name")).toBe("b");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("路径不存在返回 undefined", () => {
|
|
||||||
expect(evaluateJsonPath(obj, "$.notExist")).toBeUndefined();
|
|
||||||
expect(evaluateJsonPath(obj, "$.data.notExist")).toBeUndefined();
|
|
||||||
expect(evaluateJsonPath(obj, "$.data.items[99]")).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("空对象和空数组", () => {
|
|
||||||
expect(evaluateJsonPath(obj, "$.emptyObj")).toEqual({});
|
|
||||||
expect(evaluateJsonPath(obj, "$.emptyArr")).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("非 $ 开头路径返回 undefined", () => {
|
|
||||||
expect(evaluateJsonPath(obj, "status")).toBeUndefined();
|
|
||||||
expect(evaluateJsonPath(obj, ".status")).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("null 对象上访问", () => {
|
|
||||||
expect(evaluateJsonPath(null, "$.any")).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("applyOperator", () => {
|
|
||||||
test("equals 操作符", () => {
|
|
||||||
expect(applyOperator("ok", { equals: "ok" })).toBe(true);
|
|
||||||
expect(applyOperator("ok", { equals: "error" })).toBe(false);
|
|
||||||
expect(applyOperator(42, { equals: 42 })).toBe(true);
|
|
||||||
expect(applyOperator(42, { equals: 41 })).toBe(false);
|
|
||||||
expect(applyOperator(null, { equals: null })).toBe(true);
|
|
||||||
expect(applyOperator(true, { equals: true })).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("contains 操作符", () => {
|
|
||||||
expect(applyOperator("hello world", { contains: "hello" })).toBe(true);
|
|
||||||
expect(applyOperator("hello world", { contains: "missing" })).toBe(false);
|
|
||||||
expect(applyOperator(12345, { contains: "23" })).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("match 操作符", () => {
|
|
||||||
expect(applyOperator("v2.1.0", { match: "\\d+\\.\\d+\\.\\d+" })).toBe(true);
|
|
||||||
expect(applyOperator("v2.1", { match: "\\d+\\.\\d+\\.\\d+" })).toBe(false);
|
|
||||||
expect(applyOperator("abc123", { match: "^\\w+\\d+$" })).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("empty 操作符", () => {
|
|
||||||
expect(applyOperator("", { empty: true })).toBe(true);
|
|
||||||
expect(applyOperator(null, { empty: true })).toBe(true);
|
|
||||||
expect(applyOperator(undefined, { empty: true })).toBe(true);
|
|
||||||
expect(applyOperator([], { empty: true })).toBe(true);
|
|
||||||
expect(applyOperator({}, { empty: true })).toBe(true);
|
|
||||||
expect(applyOperator("ok", { empty: true })).toBe(false);
|
|
||||||
expect(applyOperator([1, 2], { empty: false })).toBe(true);
|
|
||||||
expect(applyOperator([], { empty: false })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("exists 操作符", () => {
|
|
||||||
expect(applyOperator("ok", { exists: true })).toBe(true);
|
|
||||||
expect(applyOperator(null, { exists: true })).toBe(true);
|
|
||||||
expect(applyOperator(undefined, { exists: true })).toBe(false);
|
|
||||||
expect(applyOperator(undefined, { exists: false })).toBe(true);
|
|
||||||
expect(applyOperator("ok", { exists: false })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("gte 操作符", () => {
|
|
||||||
expect(applyOperator(10, { gte: 5 })).toBe(true);
|
|
||||||
expect(applyOperator(5, { gte: 5 })).toBe(true);
|
|
||||||
expect(applyOperator(3, { gte: 5 })).toBe(false);
|
|
||||||
expect(applyOperator("10", { gte: 5 })).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("lte 操作符", () => {
|
|
||||||
expect(applyOperator(3, { lte: 5 })).toBe(true);
|
|
||||||
expect(applyOperator(5, { lte: 5 })).toBe(true);
|
|
||||||
expect(applyOperator(10, { lte: 5 })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("gt 操作符", () => {
|
|
||||||
expect(applyOperator(10, { gt: 5 })).toBe(true);
|
|
||||||
expect(applyOperator(5, { gt: 5 })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("lt 操作符", () => {
|
|
||||||
expect(applyOperator(3, { lt: 5 })).toBe(true);
|
|
||||||
expect(applyOperator(5, { lt: 5 })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("多操作符 AND 组合", () => {
|
|
||||||
expect(applyOperator(7, { gte: 5, lte: 10 })).toBe(true);
|
|
||||||
expect(applyOperator(3, { gte: 5, lte: 10 })).toBe(false);
|
|
||||||
expect(applyOperator(15, { gte: 5, lte: 10 })).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("checkExpectValue", () => {
|
|
||||||
test("原始值直接比较", () => {
|
|
||||||
expect(checkExpectValue("ok", "ok")).toBe(true);
|
|
||||||
expect(checkExpectValue("ok", "error")).toBe(false);
|
|
||||||
expect(checkExpectValue(42, 42)).toBe(true);
|
|
||||||
expect(checkExpectValue(null, null)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("对象作为操作符", () => {
|
|
||||||
expect(checkExpectValue(42, { gte: 10 })).toBe(true);
|
|
||||||
expect(checkExpectValue(42, { gte: 100 })).toBe(false);
|
|
||||||
expect(checkExpectValue("hello", { contains: "ell" })).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("checkBodyExpect (BodyRule[])", () => {
|
describe("checkBodyExpect (BodyRule[])", () => {
|
||||||
test("无规则返回匹配成功", () => {
|
test("无规则返回匹配成功", () => {
|
||||||
@@ -234,11 +90,6 @@ describe("checkBodyExpect (BodyRule[])", () => {
|
|||||||
expect(
|
expect(
|
||||||
checkBodyExpect(html, [{ css: { selector: 'meta[name="version"]', attr: "content", equals: "2.0.1" } }]).matched,
|
checkBodyExpect(html, [{ css: { selector: 'meta[name="version"]', attr: "content", equals: "2.0.1" } }]).matched,
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
|
||||||
checkBodyExpect(html, [
|
|
||||||
{ css: { selector: 'meta[name="version"]', attr: "content", match: "\\d+\\.\\d+\\.\\d+" } },
|
|
||||||
]).matched,
|
|
||||||
).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("css exists 检查", () => {
|
test("css exists 检查", () => {
|
||||||
29
tests/server/checker/runner/shared/duration.test.ts
Normal file
29
tests/server/checker/runner/shared/duration.test.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { checkDuration } from "../../../../../src/server/checker/runner/shared/duration";
|
||||||
|
|
||||||
|
describe("checkDuration", () => {
|
||||||
|
test("未配置 maxDurationMs 返回匹配成功", () => {
|
||||||
|
const r = checkDuration(100);
|
||||||
|
expect(r.matched).toBe(true);
|
||||||
|
expect(r.failure).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("duration 在限制内匹配成功", () => {
|
||||||
|
const r = checkDuration(50, 100);
|
||||||
|
expect(r.matched).toBe(true);
|
||||||
|
expect(r.failure).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("duration 等于限制匹配成功", () => {
|
||||||
|
const r = checkDuration(100, 100);
|
||||||
|
expect(r.matched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("duration 超过限制匹配失败", () => {
|
||||||
|
const r = checkDuration(200, 100);
|
||||||
|
expect(r.matched).toBe(false);
|
||||||
|
expect(r.failure).not.toBeNull();
|
||||||
|
expect(r.failure!.phase).toBe("duration");
|
||||||
|
expect(r.failure!.kind).toBe("mismatch");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { truncateActual, mismatchFailure, errorFailure } from "../../../../src/server/checker/expect/failure";
|
import { truncateActual, mismatchFailure, errorFailure } from "../../../../../src/server/checker/runner/shared/failure";
|
||||||
|
|
||||||
describe("truncateActual", () => {
|
describe("truncateActual", () => {
|
||||||
test("短字符串不截断", () => {
|
test("短字符串不截断", () => {
|
||||||
141
tests/server/checker/runner/shared/operator.test.ts
Normal file
141
tests/server/checker/runner/shared/operator.test.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { applyOperator, checkExpectValue, evaluateJsonPath } from "../../../../../src/server/checker/runner/shared/operator";
|
||||||
|
|
||||||
|
describe("evaluateJsonPath", () => {
|
||||||
|
const obj = {
|
||||||
|
status: "ok",
|
||||||
|
code: 0,
|
||||||
|
active: true,
|
||||||
|
error: null,
|
||||||
|
data: {
|
||||||
|
count: 42,
|
||||||
|
items: [{ name: "a" }, { name: "b" }],
|
||||||
|
nested: { deep: "value" },
|
||||||
|
},
|
||||||
|
emptyObj: {},
|
||||||
|
emptyArr: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
test("简单字段访问", () => {
|
||||||
|
expect(evaluateJsonPath(obj, "$.status")).toBe("ok");
|
||||||
|
expect(evaluateJsonPath(obj, "$.code")).toBe(0);
|
||||||
|
expect(evaluateJsonPath(obj, "$.active")).toBe(true);
|
||||||
|
expect(evaluateJsonPath(obj, "$.error")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("嵌套对象访问", () => {
|
||||||
|
expect(evaluateJsonPath(obj, "$.data.count")).toBe(42);
|
||||||
|
expect(evaluateJsonPath(obj, "$.data.nested.deep")).toBe("value");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("数组索引访问", () => {
|
||||||
|
expect(evaluateJsonPath(obj, "$.data.items[0].name")).toBe("a");
|
||||||
|
expect(evaluateJsonPath(obj, "$.data.items[1].name")).toBe("b");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("路径不存在返回 undefined", () => {
|
||||||
|
expect(evaluateJsonPath(obj, "$.notExist")).toBeUndefined();
|
||||||
|
expect(evaluateJsonPath(obj, "$.data.notExist")).toBeUndefined();
|
||||||
|
expect(evaluateJsonPath(obj, "$.data.items[99]")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("空对象和空数组", () => {
|
||||||
|
expect(evaluateJsonPath(obj, "$.emptyObj")).toEqual({});
|
||||||
|
expect(evaluateJsonPath(obj, "$.emptyArr")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("非 $ 开头路径返回 undefined", () => {
|
||||||
|
expect(evaluateJsonPath(obj, "status")).toBeUndefined();
|
||||||
|
expect(evaluateJsonPath(obj, ".status")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("null 对象上访问", () => {
|
||||||
|
expect(evaluateJsonPath(null, "$.any")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyOperator", () => {
|
||||||
|
test("equals 操作符", () => {
|
||||||
|
expect(applyOperator("ok", { equals: "ok" })).toBe(true);
|
||||||
|
expect(applyOperator("ok", { equals: "error" })).toBe(false);
|
||||||
|
expect(applyOperator(42, { equals: 42 })).toBe(true);
|
||||||
|
expect(applyOperator(42, { equals: 41 })).toBe(false);
|
||||||
|
expect(applyOperator(null, { equals: null })).toBe(true);
|
||||||
|
expect(applyOperator(true, { equals: true })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains 操作符", () => {
|
||||||
|
expect(applyOperator("hello world", { contains: "hello" })).toBe(true);
|
||||||
|
expect(applyOperator("hello world", { contains: "missing" })).toBe(false);
|
||||||
|
expect(applyOperator(12345, { contains: "23" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("match 操作符", () => {
|
||||||
|
expect(applyOperator("v2.1.0", { match: "\\d+\\.\\d+\\.\\d+" })).toBe(true);
|
||||||
|
expect(applyOperator("v2.1", { match: "\\d+\\.\\d+\\.\\d+" })).toBe(false);
|
||||||
|
expect(applyOperator("abc123", { match: "^\\w+\\d+$" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty 操作符", () => {
|
||||||
|
expect(applyOperator("", { empty: true })).toBe(true);
|
||||||
|
expect(applyOperator(null, { empty: true })).toBe(true);
|
||||||
|
expect(applyOperator(undefined, { empty: true })).toBe(true);
|
||||||
|
expect(applyOperator([], { empty: true })).toBe(true);
|
||||||
|
expect(applyOperator({}, { empty: true })).toBe(true);
|
||||||
|
expect(applyOperator("ok", { empty: true })).toBe(false);
|
||||||
|
expect(applyOperator([1, 2], { empty: false })).toBe(true);
|
||||||
|
expect(applyOperator([], { empty: false })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exists 操作符", () => {
|
||||||
|
expect(applyOperator("ok", { exists: true })).toBe(true);
|
||||||
|
expect(applyOperator(null, { exists: true })).toBe(true);
|
||||||
|
expect(applyOperator(undefined, { exists: true })).toBe(false);
|
||||||
|
expect(applyOperator(undefined, { exists: false })).toBe(true);
|
||||||
|
expect(applyOperator("ok", { exists: false })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gte 操作符", () => {
|
||||||
|
expect(applyOperator(10, { gte: 5 })).toBe(true);
|
||||||
|
expect(applyOperator(5, { gte: 5 })).toBe(true);
|
||||||
|
expect(applyOperator(3, { gte: 5 })).toBe(false);
|
||||||
|
expect(applyOperator("10", { gte: 5 })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lte 操作符", () => {
|
||||||
|
expect(applyOperator(3, { lte: 5 })).toBe(true);
|
||||||
|
expect(applyOperator(5, { lte: 5 })).toBe(true);
|
||||||
|
expect(applyOperator(10, { lte: 5 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gt 操作符", () => {
|
||||||
|
expect(applyOperator(10, { gt: 5 })).toBe(true);
|
||||||
|
expect(applyOperator(5, { gt: 5 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lt 操作符", () => {
|
||||||
|
expect(applyOperator(3, { lt: 5 })).toBe(true);
|
||||||
|
expect(applyOperator(5, { lt: 5 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("多操作符 AND 组合", () => {
|
||||||
|
expect(applyOperator(7, { gte: 5, lte: 10 })).toBe(true);
|
||||||
|
expect(applyOperator(3, { gte: 5, lte: 10 })).toBe(false);
|
||||||
|
expect(applyOperator(15, { gte: 5, lte: 10 })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkExpectValue", () => {
|
||||||
|
test("原始值直接比较", () => {
|
||||||
|
expect(checkExpectValue("ok", "ok")).toBe(true);
|
||||||
|
expect(checkExpectValue("ok", "error")).toBe(false);
|
||||||
|
expect(checkExpectValue(42, 42)).toBe(true);
|
||||||
|
expect(checkExpectValue(null, null)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("对象作为操作符", () => {
|
||||||
|
expect(checkExpectValue(42, { gte: 10 })).toBe(true);
|
||||||
|
expect(checkExpectValue(42, { gte: 100 })).toBe(false);
|
||||||
|
expect(checkExpectValue("hello", { contains: "ell" })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
45
tests/server/checker/runner/shared/text.test.ts
Normal file
45
tests/server/checker/runner/shared/text.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { checkTextRules } from "../../../../../src/server/checker/runner/shared/text";
|
||||||
|
|
||||||
|
describe("checkTextRules", () => {
|
||||||
|
test("无规则返回匹配成功", () => {
|
||||||
|
const r = checkTextRules("hello", [], "stdout");
|
||||||
|
expect(r.matched).toBe(true);
|
||||||
|
expect(r.failure).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("单条 contains 规则匹配成功", () => {
|
||||||
|
const r = checkTextRules("build completed successfully", [{ contains: "completed" }], "stdout");
|
||||||
|
expect(r.matched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("单条 contains 规则匹配失败", () => {
|
||||||
|
const r = checkTextRules("build completed successfully", [{ contains: "failed" }], "stdout");
|
||||||
|
expect(r.matched).toBe(false);
|
||||||
|
expect(r.failure!.phase).toBe("stdout");
|
||||||
|
expect(r.failure!.path).toBe("stdout[0]");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("多条规则全部通过", () => {
|
||||||
|
const r = checkTextRules("version: 3.2.1, build: ok", [{ contains: "version" }, { match: "\\d+\\.\\d+\\.\\d+" }], "stdout");
|
||||||
|
expect(r.matched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("第一条规则失败立即返回", () => {
|
||||||
|
const r = checkTextRules("error occurred", [{ contains: "success" }, { contains: "error" }], "stdout");
|
||||||
|
expect(r.matched).toBe(false);
|
||||||
|
expect(r.failure!.phase).toBe("stdout");
|
||||||
|
expect(r.failure!.path).toBe("stdout[0]");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stderr phase", () => {
|
||||||
|
const r = checkTextRules("warning: deprecated", [{ contains: "warning" }], "stderr");
|
||||||
|
expect(r.matched).toBe(true);
|
||||||
|
expect(r.failure).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty 操作符", () => {
|
||||||
|
const r = checkTextRules("", [{ empty: true }], "stderr");
|
||||||
|
expect(r.matched).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,20 @@ import type { CheckFailure, ResolvedTarget } from "../../../src/server/checker/t
|
|||||||
import { mkdir, rm } from "node:fs/promises";
|
import { mkdir, rm } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
|
import { checkerRegistry } from "../../../src/server/checker/runner";
|
||||||
|
import { HttpChecker } from "../../../src/server/checker/runner/http/runner";
|
||||||
|
import { CommandChecker } from "../../../src/server/checker/runner/command/runner";
|
||||||
|
|
||||||
|
function ensureRegistered() {
|
||||||
|
if (!checkerRegistry.supportedTypes.includes("http")) {
|
||||||
|
checkerRegistry.register(new HttpChecker());
|
||||||
|
checkerRegistry.register(new CommandChecker());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
ensureRegistered();
|
||||||
|
});
|
||||||
|
|
||||||
const httpTarget: ResolvedTarget = {
|
const httpTarget: ResolvedTarget = {
|
||||||
type: "http",
|
type: "http",
|
||||||
|
|||||||
Reference in New Issue
Block a user