From ce8baae3d17f0521da6531aa13603e92d19e0472 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Tue, 12 May 2026 17:08:57 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=BC=95=E5=85=A5=20Checker=20?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=8E=A5=E5=8F=A3=E4=B8=8E=20Runner=20?= =?UTF-8?q?=E6=8A=BD=E8=B1=A1=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 定义 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。 --- DEVELOPMENT.md | 29 +- .../specs/checker-runner-abstraction/spec.md | 113 ++++++++ scripts/build.ts | 3 + src/server/checker/command-runner.ts | 152 ---------- src/server/checker/config-loader.ts | 105 +------ src/server/checker/engine.ts | 16 +- src/server/checker/expect/command.ts | 91 ------ src/server/checker/expect/http.ts | 122 -------- src/server/checker/fetcher.ts | 93 ------- src/server/checker/runner/command/expect.ts | 18 ++ src/server/checker/runner/command/runner.ts | 263 ++++++++++++++++++ src/server/checker/runner/http/expect.ts | 95 +++++++ src/server/checker/runner/http/runner.ts | 145 ++++++++++ src/server/checker/runner/index.ts | 10 + src/server/checker/runner/registry.ts | 26 ++ .../checker/{expect => runner/shared}/body.ts | 89 +----- src/server/checker/runner/shared/duration.ts | 24 ++ .../{expect => runner/shared}/failure.ts | 2 +- src/server/checker/runner/shared/operator.ts | 76 +++++ src/server/checker/runner/shared/text.ts | 18 ++ src/server/checker/runner/types.ts | 20 ++ src/server/checker/store.ts | 24 +- src/server/dev.ts | 3 + src/shared/api.ts | 2 +- tests/server/app.test.ts | 14 + tests/server/checker/command-runner.test.ts | 127 --------- tests/server/checker/config-loader.test.ts | 14 + tests/server/checker/engine.test.ts | 11 + tests/server/checker/expect/command.test.ts | 168 ----------- tests/server/checker/expect/http.test.ts | 165 ----------- .../checker/runner/command/expect.test.ts | 25 ++ .../checker/runner/command/runner.test.ts | 116 ++++++++ .../server/checker/runner/http/expect.test.ts | 142 ++++++++++ .../http/runner.test.ts} | 146 +++------- tests/server/checker/runner/registry.test.ts | 39 +++ .../{expect => runner/shared}/body.test.ts | 151 +--------- .../checker/runner/shared/duration.test.ts | 29 ++ .../{expect => runner/shared}/failure.test.ts | 2 +- .../checker/runner/shared/operator.test.ts | 141 ++++++++++ .../server/checker/runner/shared/text.test.ts | 45 +++ tests/server/checker/store.test.ts | 14 + 41 files changed, 1493 insertions(+), 1395 deletions(-) create mode 100644 openspec/specs/checker-runner-abstraction/spec.md delete mode 100644 src/server/checker/command-runner.ts delete mode 100644 src/server/checker/expect/command.ts delete mode 100644 src/server/checker/expect/http.ts delete mode 100644 src/server/checker/fetcher.ts create mode 100644 src/server/checker/runner/command/expect.ts create mode 100644 src/server/checker/runner/command/runner.ts create mode 100644 src/server/checker/runner/http/expect.ts create mode 100644 src/server/checker/runner/http/runner.ts create mode 100644 src/server/checker/runner/index.ts create mode 100644 src/server/checker/runner/registry.ts rename src/server/checker/{expect => runner/shared}/body.ts (67%) create mode 100644 src/server/checker/runner/shared/duration.ts rename src/server/checker/{expect => runner/shared}/failure.ts (93%) create mode 100644 src/server/checker/runner/shared/operator.ts create mode 100644 src/server/checker/runner/shared/text.ts create mode 100644 src/server/checker/runner/types.ts delete mode 100644 tests/server/checker/command-runner.test.ts delete mode 100644 tests/server/checker/expect/command.test.ts delete mode 100644 tests/server/checker/expect/http.test.ts create mode 100644 tests/server/checker/runner/command/expect.test.ts create mode 100644 tests/server/checker/runner/command/runner.test.ts create mode 100644 tests/server/checker/runner/http/expect.test.ts rename tests/server/checker/{fetcher.test.ts => runner/http/runner.test.ts} (51%) create mode 100644 tests/server/checker/runner/registry.test.ts rename tests/server/checker/{expect => runner/shared}/body.test.ts (51%) create mode 100644 tests/server/checker/runner/shared/duration.test.ts rename tests/server/checker/{expect => runner/shared}/failure.test.ts (97%) create mode 100644 tests/server/checker/runner/shared/operator.test.ts create mode 100644 tests/server/checker/runner/shared/text.test.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f96c262..05ba56a 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -37,15 +37,24 @@ src/ types.ts 类型定义 config-loader.ts YAML 配置解析与校验 store.ts SQLite 数据存储 - fetcher.ts HTTP 拨测执行 - command-runner.ts 命令行拨测执行 - size.ts 大小单位解析 engine.ts 调度引擎(按 interval 分组的 es-toolkit groupBy + Semaphore 并发控制) - expect/ - http.ts HTTP 响应断言 - command.ts 命令行输出断言 - body.ts HTTP body 断言(JSONPath/XPath/CSS,类型判断使用 es-toolkit) - failure.ts 失败信息类型 + size.ts 大小单位解析 + runner/ Checker 统一抽象与注册机制 + types.ts Checker 接口、CheckerContext、ResolveContext + registry.ts CheckerRegistry 注册中心 + 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/ api.ts 前后端共享 TypeScript 类型 web/ Vite + React 前端 Dashboard @@ -76,7 +85,7 @@ openspec/ OpenSpec 变更与规格文档 运行时: 定时器(tick) → ProbeEngine.probeGroup() → HTTP: fetcher.ts / Command: command-runner.ts - → expect/*.ts 校验 → store.insertCheckResult() + → runner/*/expect.ts 校验 → store.insertCheckResult() HTTP 请求: Request → app.ts(路由分发) → routes/*.ts(handler) @@ -206,7 +215,7 @@ runCommandCheck → 收集观测(exitCode/stdout/stderr/durationMs) ### 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()` - 新增 store 方法必须编写单元测试;新增 API 端点必须在 `app.test.ts` 中添加集成测试 - 测试后清理:`afterAll` 中 `store.close()` + `rm(tempDir, { recursive: true })` diff --git a/openspec/specs/checker-runner-abstraction/spec.md b/openspec/specs/checker-runner-abstraction/spec.md new file mode 100644 index 0000000..eb846e5 --- /dev/null +++ b/openspec/specs/checker-runner-abstraction/spec.md @@ -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 直接展示而不做类型判断 diff --git a/scripts/build.ts b/scripts/build.ts index 74e5e9a..731d2be 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -103,9 +103,12 @@ import { ProbeStore } from "../src/server/checker/store"; import { ProbeEngine } from "../src/server/checker/engine"; import { startServer } from "../src/server/server"; import { readRuntimeConfig } from "../src/server/config"; +import { registerCheckers } from "../src/server/checker/runner"; import { staticAssets } from "./static-assets"; async function main() { + registerCheckers(); + const { configPath } = readRuntimeConfig(); const config = await loadConfig(configPath); diff --git a/src/server/checker/command-runner.ts b/src/server/checker/command-runner.ts deleted file mode 100644 index b68c35a..0000000 --- a/src/server/checker/command-runner.ts +++ /dev/null @@ -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, - stderr: ReadableStream, - 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): Promise { - 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 { - const timestamp = new Date().toISOString(); - const start = performance.now(); - - let proc: ReturnType; - - 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, - proc.stderr as ReadableStream, - () => 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, - }; -} diff --git a/src/server/checker/config-loader.ts b/src/server/checker/config-loader.ts index 9ab2fcc..1597b50 100644 --- a/src/server/checker/config-loader.ts +++ b/src/server/checker/config-loader.ts @@ -1,32 +1,19 @@ import type { - CommandDefaultsConfig, - CommandTargetConfig, DefaultsConfig, - HttpDefaultsConfig, - HttpExpectConfig, - HttpTargetConfig, ProbeConfig, - ResolvedCommandTarget, - ResolvedHttpTarget, ResolvedTarget, EngineRuntimeConfig, TargetConfig, - TargetType, } from "./types"; -import { parseSize } from "./size"; -import { resolve } from "node:path"; -import { dirname } from "node:path"; +import { dirname, resolve } from "node:path"; +import { checkerRegistry } from "./runner"; const DEFAULT_HOST = "127.0.0.1"; const DEFAULT_PORT = 3000; const DEFAULT_DATA_DIR = "./data"; const DEFAULT_INTERVAL = "30s"; 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 SUPPORTED_TYPES: TargetType[] = ["http", "command"]; export interface ResolvedConfig { host: string; @@ -100,73 +87,14 @@ function resolveTarget( ): ResolvedTarget { const intervalMs = parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL); const timeoutMs = parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT); - const group = target.group ?? "default"; - if (target.type === "http") { - return resolveHttpTarget(target, defaults.http, intervalMs, timeoutMs, group); - } + const checker = checkerRegistry.get(target.type); + 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( - 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; - - 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, - }; + return result; } function validateConfig(config: ProbeConfig): void { @@ -175,6 +103,7 @@ function validateConfig(config: ProbeConfig): void { } const names = new Set(); + const supportedTypes = checkerRegistry.supportedTypes; for (let i = 0; i < config.targets.length; i++) { const raw = config.targets[i] as unknown as Record; @@ -189,22 +118,8 @@ function validateConfig(config: ProbeConfig): void { throw new Error(`target "${name}" 缺少 type 字段`); } - if (!SUPPORTED_TYPES.includes(type as TargetType)) { - throw new Error(`target "${name}" 使用不支持的 type: "${type}",支持: ${SUPPORTED_TYPES.join(", ")}`); - } - - if (type === "http") { - const http = raw["http"] as Record | 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 | undefined; - if (!cmd?.["exec"] || typeof cmd["exec"] !== "string" || (cmd["exec"] as string).trim() === "") { - throw new Error(`target "${name}" 缺少 command.exec 字段`); - } + if (!supportedTypes.includes(type)) { + throw new Error(`target "${name}" 使用不支持的 type: "${type}",支持: ${supportedTypes.join(", ")}`); } const group = raw["group"]; diff --git a/src/server/checker/engine.ts b/src/server/checker/engine.ts index 5215909..6056873 100644 --- a/src/server/checker/engine.ts +++ b/src/server/checker/engine.ts @@ -1,7 +1,6 @@ import type { CheckResult, ResolvedTarget } from "./types"; import type { ProbeStore } from "./store"; -import { runHttpCheck } from "./fetcher"; -import { runCommandCheck } from "./command-runner"; +import { checkerRegistry } from "./runner"; import { groupBy, Semaphore } from "es-toolkit"; export class ProbeEngine { @@ -61,11 +60,14 @@ export class ProbeEngine { } private async runCheck(target: ResolvedTarget): Promise { - switch (target.type) { - case "http": - return runHttpCheck(target); - case "command": - return runCommandCheck(target); + const checker = checkerRegistry.get(target.type); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), target.timeoutMs); + + try { + return await checker.execute(target, { signal: controller.signal }); + } finally { + clearTimeout(timeoutId); } } diff --git a/src/server/checker/expect/command.ts b/src/server/checker/expect/command.ts deleted file mode 100644 index c939023..0000000 --- a/src/server/checker/expect/command.ts +++ /dev/null @@ -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 }; -} diff --git a/src/server/checker/expect/http.ts b/src/server/checker/expect/http.ts deleted file mode 100644 index 4f737f2..0000000 --- a/src/server/checker/expect/http.ts +++ /dev/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; - 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, -): { 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 }; -} diff --git a/src/server/checker/fetcher.ts b/src/server/checker/fetcher.ts deleted file mode 100644 index 063b8f0..0000000 --- a/src/server/checker/fetcher.ts +++ /dev/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 { - 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), - ), - }; - } -} diff --git a/src/server/checker/runner/command/expect.ts b/src/server/checker/runner/command/expect.ts new file mode 100644 index 0000000..f40df9f --- /dev/null +++ b/src/server/checker/runner/command/expect.ts @@ -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 }; +} diff --git a/src/server/checker/runner/command/runner.ts b/src/server/checker/runner/command/runner.ts new file mode 100644 index 0000000..9b4a9a4 --- /dev/null +++ b/src/server/checker/runner/command/runner.ts @@ -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, + stderr: ReadableStream, + 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): Promise { + 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; + + 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 { + const t = target as ResolvedCommandTarget; + const timestamp = new Date().toISOString(); + const start = performance.now(); + + let proc: ReturnType; + + 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, + proc.stderr as ReadableStream, + () => 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, + }), + }; + } +} diff --git a/src/server/checker/runner/http/expect.ts b/src/server/checker/runner/http/expect.ts new file mode 100644 index 0000000..7c694e5 --- /dev/null +++ b/src/server/checker/runner/http/expect.ts @@ -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, + headerExpects?: Record, +): 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, + 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 }; +} diff --git a/src/server/checker/runner/http/runner.ts b/src/server/checker/runner/http/runner.ts new file mode 100644 index 0000000..1700418 --- /dev/null +++ b/src/server/checker/runner/http/runner.ts @@ -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 { + 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, + }), + }; + } +} diff --git a/src/server/checker/runner/index.ts b/src/server/checker/runner/index.ts new file mode 100644 index 0000000..6fcdb52 --- /dev/null +++ b/src/server/checker/runner/index.ts @@ -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"; diff --git a/src/server/checker/runner/registry.ts b/src/server/checker/runner/registry.ts new file mode 100644 index 0000000..fe32d84 --- /dev/null +++ b/src/server/checker/runner/registry.ts @@ -0,0 +1,26 @@ +import type { Checker } from "./types"; + +export class CheckerRegistry { + private checkers = new Map(); + + 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(); diff --git a/src/server/checker/expect/body.ts b/src/server/checker/runner/shared/body.ts similarity index 67% rename from src/server/checker/expect/body.ts rename to src/server/checker/runner/shared/body.ts index 71302fb..fa3e1c7 100644 --- a/src/server/checker/expect/body.ts +++ b/src/server/checker/runner/shared/body.ts @@ -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 xpath from "xpath"; import { DOMParser } from "@xmldom/xmldom"; import { mismatchFailure, errorFailure } from "./failure"; -import { isNil, isEmptyObject, isEqual, isPlainObject } from "es-toolkit"; - -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)?.[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)[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 }); -} +import { applyOperator, evaluateJsonPath } from "./operator"; +import type { ExpectResult } from "./duration"; function checkJsonRule( body: string, rule: JsonRule, rulePath: string, -): { matched: boolean; failure: CheckFailure | null } { +): ExpectResult { const { path, ...operators } = rule; const fullPath = `${rulePath}.json(${path})`; @@ -124,7 +51,7 @@ function checkCssRule( body: string, rule: CssRule, rulePath: string, -): { matched: boolean; failure: CheckFailure | null } { +): ExpectResult { const { selector, attr, ...operators } = rule; const fullPath = `${rulePath}.css(${selector}${attr ? `@${attr}` : ""})`; @@ -201,7 +128,7 @@ function checkXpathRule( body: string, rule: XpathRule, rulePath: string, -): { matched: boolean; failure: CheckFailure | null } { +): ExpectResult { const { path, ...operators } = rule; const fullPath = `${rulePath}.xpath(${path})`; @@ -245,7 +172,7 @@ function checkSingleBodyRule( body: string, rule: BodyRule, index: number, -): { matched: boolean; failure: CheckFailure | null } { +): ExpectResult { const rulePath = `body[${index}]`; if ("contains" in rule) { @@ -285,7 +212,7 @@ function checkSingleBodyRule( 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 }; for (let i = 0; i < rules.length; i++) { diff --git a/src/server/checker/runner/shared/duration.ts b/src/server/checker/runner/shared/duration.ts new file mode 100644 index 0000000..db19e05 --- /dev/null +++ b/src/server/checker/runner/shared/duration.ts @@ -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 }; +} diff --git a/src/server/checker/expect/failure.ts b/src/server/checker/runner/shared/failure.ts similarity index 93% rename from src/server/checker/expect/failure.ts rename to src/server/checker/runner/shared/failure.ts index fdf47b2..884bfbc 100644 --- a/src/server/checker/expect/failure.ts +++ b/src/server/checker/runner/shared/failure.ts @@ -1,4 +1,4 @@ -import type { CheckFailure } from "../types"; +import type { CheckFailure } from "../../types"; export function truncateActual(value: unknown, maxLen = 200): unknown { if (value === undefined || value === null) return value; diff --git a/src/server/checker/runner/shared/operator.ts b/src/server/checker/runner/shared/operator.ts new file mode 100644 index 0000000..f16c253 --- /dev/null +++ b/src/server/checker/runner/shared/operator.ts @@ -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)?.[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)[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 }); +} diff --git a/src/server/checker/runner/shared/text.ts b/src/server/checker/runner/shared/text.ts new file mode 100644 index 0000000..9b186b9 --- /dev/null +++ b/src/server/checker/runner/shared/text.ts @@ -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 }; +} diff --git a/src/server/checker/runner/types.ts b/src/server/checker/runner/types.ts new file mode 100644 index 0000000..77d83ee --- /dev/null +++ b/src/server/checker/runner/types.ts @@ -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; + serialize(target: ResolvedTarget): { target: string; config: string }; +} diff --git a/src/server/checker/store.ts b/src/server/checker/store.ts index 9532d84..dc33ede 100644 --- a/src/server/checker/store.ts +++ b/src/server/checker/store.ts @@ -2,6 +2,7 @@ import { Database } from "bun:sqlite"; import { mkdirSync as fsMkdirSync } from "node:fs"; import { dirname } from "node:path"; import type { CheckFailure, ResolvedTarget, StoredCheckResult, StoredTarget } from "./types"; +import { checkerRegistry } from "./runner"; const CREATE_TARGETS_TABLE = ` CREATE TABLE IF NOT EXISTS targets ( @@ -297,30 +298,11 @@ export class ProbeStore { } function buildTargetDisplay(t: ResolvedTarget): string { - if (t.type === "http") { - return t.http.url; - } - const parts = [t.command.exec, ...t.command.args]; - return `exec ${parts.join(" ")}`; + return checkerRegistry.get(t.type).serialize(t).target; } function buildTargetConfig(t: ResolvedTarget): string { - if (t.type === "http") { - 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, - }); + return checkerRegistry.get(t.type).serialize(t).config; } function ensureDir(dir: string): void { diff --git a/src/server/dev.ts b/src/server/dev.ts index 40372b2..b57cb8e 100644 --- a/src/server/dev.ts +++ b/src/server/dev.ts @@ -3,8 +3,11 @@ import { ProbeStore } from "./checker/store"; import { ProbeEngine } from "./checker/engine"; import { startServer } from "./server"; import { readRuntimeConfig } from "./config"; +import { registerCheckers } from "./checker/runner"; async function main() { + registerCheckers(); + const { configPath } = readRuntimeConfig(); const config = await loadConfig(configPath); diff --git a/src/shared/api.ts b/src/shared/api.ts index 6b24dc6..09864a2 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -58,7 +58,7 @@ export interface CheckResult { export interface CheckFailure { kind: "error" | "mismatch"; - phase: "status" | "duration" | "headers" | "body" | "exitCode" | "stdout" | "stderr"; + phase: string; path: string; expected?: unknown; actual?: unknown; diff --git a/tests/server/app.test.ts b/tests/server/app.test.ts index 37d9242..74960cf 100644 --- a/tests/server/app.test.ts +++ b/tests/server/app.test.ts @@ -5,6 +5,20 @@ import type { HistoryResponse, SummaryResponse, TargetStatus, HealthResponse } f import { mkdir, rm } from "node:fs/promises"; import { join } from "node:path"; 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 = { indexHtml: new Blob(['DiAL
'], { diff --git a/tests/server/checker/command-runner.test.ts b/tests/server/checker/command-runner.test.ts deleted file mode 100644 index a4a0762..0000000 --- a/tests/server/checker/command-runner.test.ts +++ /dev/null @@ -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, - overrides?: Partial, -): 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); - }); -}); diff --git a/tests/server/checker/config-loader.test.ts b/tests/server/checker/config-loader.test.ts index f8534b1..8386e29 100644 --- a/tests/server/checker/config-loader.test.ts +++ b/tests/server/checker/config-loader.test.ts @@ -4,6 +4,20 @@ import { readRuntimeConfig } from "../../../src/server/config"; import { mkdir, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; 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", () => { test("解析秒", () => { diff --git a/tests/server/checker/engine.test.ts b/tests/server/checker/engine.test.ts index d32bf66..9d498b5 100644 --- a/tests/server/checker/engine.test.ts +++ b/tests/server/checker/engine.test.ts @@ -2,6 +2,16 @@ import { describe, expect, test } from "bun:test"; import { ProbeEngine } from "../../../src/server/checker/engine"; import type { ProbeStore } from "../../../src/server/checker/store"; 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[]) { let nextId = 1; @@ -49,6 +59,7 @@ function makeCommandTarget(name: string, overrides?: Partial { test("start/stop 不抛错", () => { + ensureRegistered(); const mockStore = createMockStore(["test"]) as unknown as ProbeStore; const targets: ResolvedTarget[] = [makeCommandTarget("test")]; const engine = new ProbeEngine(mockStore, targets); diff --git a/tests/server/checker/expect/command.test.ts b/tests/server/checker/expect/command.test.ts deleted file mode 100644 index ff0a919..0000000 --- a/tests/server/checker/expect/command.test.ts +++ /dev/null @@ -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 { - 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); - }); -}); diff --git a/tests/server/checker/expect/http.test.ts b/tests/server/checker/expect/http.test.ts deleted file mode 100644 index 6d9edd5..0000000 --- a/tests/server/checker/expect/http.test.ts +++ /dev/null @@ -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 { - 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"); - }); -}); diff --git a/tests/server/checker/runner/command/expect.test.ts b/tests/server/checker/runner/command/expect.test.ts new file mode 100644 index 0000000..59629dc --- /dev/null +++ b/tests/server/checker/runner/command/expect.test.ts @@ -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); + }); +}); diff --git a/tests/server/checker/runner/command/runner.test.ts b/tests/server/checker/runner/command/runner.test.ts new file mode 100644 index 0000000..ef354a6 --- /dev/null +++ b/tests/server/checker/runner/command/runner.test.ts @@ -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, + overrides?: Partial, +): 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"]); + }); +}); diff --git a/tests/server/checker/runner/http/expect.test.ts b/tests/server/checker/runner/http/expect.test.ts new file mode 100644 index 0000000..e08d9e6 --- /dev/null +++ b/tests/server/checker/runner/http/expect.test.ts @@ -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; 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"); + }); +}); diff --git a/tests/server/checker/fetcher.test.ts b/tests/server/checker/runner/http/runner.test.ts similarity index 51% rename from tests/server/checker/fetcher.test.ts rename to tests/server/checker/runner/http/runner.test.ts index 08e12b7..161bfeb 100644 --- a/tests/server/checker/fetcher.test.ts +++ b/tests/server/checker/runner/http/runner.test.ts @@ -1,15 +1,11 @@ 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", () => { - test("checkExpect 已移除", async () => { - const mod = await import("../../../src/server/checker/fetcher"); - expect((mod as Record).checkExpect).toBeUndefined(); - expect((mod as Record).fetchTarget).toBeUndefined(); - }); -}); +const checker = new HttpChecker(); -describe("runHttpCheck 集成", () => { +describe("HttpChecker", () => { let server: ReturnType; let baseUrl: string; @@ -35,8 +31,6 @@ describe("runHttpCheck 集成", () => { return new Response("x".repeat(2000)); case "/notfound": return new Response("not found", { status: 404 }); - case "/slow": - return new Response("slow", { status: 200 }); default: return new Response("ok"); } @@ -57,26 +51,32 @@ describe("runHttpCheck 集成", () => { expect?: Record; maxBodyBytes?: number; timeoutMs?: number; - }) { + }): ResolvedHttpTarget { return { - type: "http" as const, + type: "http", name: "test-http", group: "default", http: { url: overrides.url ?? `${baseUrl}/ok`, method: overrides.method ?? "GET", - headers: overrides.headers ?? ({} as Record), + headers: overrides.headers ?? {}, body: overrides.body, maxBodyBytes: overrides.maxBodyBytes ?? 1024 * 1024, }, intervalMs: 60000, 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 () => { - 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.statusDetail).toBe("HTTP 200"); expect(result.durationMs).not.toBeNull(); @@ -84,85 +84,47 @@ describe("runHttpCheck 集成", () => { }); 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.statusDetail).toBe("HTTP 404"); - expect(result.failure).not.toBeNull(); expect(result.failure!.phase).toBe("status"); }); test("404 匹配自定义 status [404]", async () => { - const result = await runHttpCheck( - makeTarget({ - url: `${baseUrl}/notfound`, - expect: { status: [404] }, - }), - ); + const result = await checker.execute(makeTarget({ url: `${baseUrl}/notfound`, expect: { status: [404] } }), makeCtx()); expect(result.matched).toBe(true); }); test("headers 检查通过", async () => { - const result = await runHttpCheck( - makeTarget({ - url: `${baseUrl}/ok`, - expect: { headers: { "x-custom": "test-value" } }, - }), - ); + const result = await checker.execute(makeTarget({ url: `${baseUrl}/ok`, expect: { headers: { "x-custom": "test-value" } } }), makeCtx()); expect(result.matched).toBe(true); }); test("headers 检查失败", async () => { - const result = await runHttpCheck( - makeTarget({ - url: `${baseUrl}/ok`, - expect: { headers: { "x-custom": "wrong-value" } }, - }), - ); + const result = await checker.execute(makeTarget({ url: `${baseUrl}/ok`, expect: { headers: { "x-custom": "wrong-value" } } }), makeCtx()); expect(result.matched).toBe(false); expect(result.failure!.phase).toBe("headers"); }); test("body contains 检查", async () => { - const result = await runHttpCheck( - makeTarget({ - url: `${baseUrl}/ok`, - expect: { body: [{ contains: "hello" }] }, - }), - ); + const result = await checker.execute(makeTarget({ url: `${baseUrl}/ok`, expect: { body: [{ contains: "hello" }] } }), makeCtx()); expect(result.matched).toBe(true); }); test("body contains 失败", async () => { - const result = await runHttpCheck( - makeTarget({ - url: `${baseUrl}/ok`, - expect: { body: [{ contains: "nonexistent" }] }, - }), - ); + const result = await checker.execute(makeTarget({ url: `${baseUrl}/ok`, expect: { body: [{ contains: "nonexistent" }] } }), makeCtx()); expect(result.matched).toBe(false); expect(result.failure!.phase).toBe("body"); }); test("body json 检查", async () => { - const result = await runHttpCheck( - makeTarget({ - url: `${baseUrl}/json`, - expect: { body: [{ json: { path: "$.status", equals: "ok" } }] }, - }), - ); + const result = await checker.execute(makeTarget({ url: `${baseUrl}/json`, expect: { body: [{ json: { path: "$.status", equals: "ok" } }] } }), makeCtx()); expect(result.matched).toBe(true); }); test("响应体超过 maxBodyBytes", async () => { - const result = await runHttpCheck( - makeTarget({ - url: `${baseUrl}/large`, - maxBodyBytes: 100, - expect: { body: [{ contains: "x" }] }, - }), - ); + const result = await checker.execute(makeTarget({ url: `${baseUrl}/large`, maxBodyBytes: 100, expect: { body: [{ contains: "x" }] } }), makeCtx()); expect(result.matched).toBe(false); - expect(result.failure).not.toBeNull(); expect(result.failure!.phase).toBe("body"); expect(result.failure!.message).toContain("超过限制"); }); @@ -177,14 +139,8 @@ describe("runHttpCheck 集成", () => { }); try { - const result = await runHttpCheck( - makeTarget({ - url: `http://localhost:${timeoutServer.port}/`, - timeoutMs: 100, - }), - ); + const result = await checker.execute(makeTarget({ url: `http://localhost:${timeoutServer.port}/`, timeoutMs: 100 }), makeCtx(100)); expect(result.matched).toBe(false); - expect(result.failure).not.toBeNull(); expect(result.failure!.message).toContain("超时"); } finally { timeoutServer.stop(); @@ -192,63 +148,33 @@ describe("runHttpCheck 集成", () => { }); test("快速失败:status 失败时不读取 body", async () => { - const result = await runHttpCheck( - makeTarget({ - url: `${baseUrl}/notfound`, - expect: { status: [200], body: [{ contains: "something" }] }, - }), - ); + const result = await checker.execute(makeTarget({ url: `${baseUrl}/notfound`, expect: { status: [200], body: [{ contains: "something" }] } }), makeCtx()); expect(result.matched).toBe(false); 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 () => { - const result = await runHttpCheck( - makeTarget({ - url: `${baseUrl}/ok`, - expect: { status: [200], body: [{ contains: "not-in-body" }] }, - }), - ); + const result = await checker.execute(makeTarget({ url: `${baseUrl}/ok`, expect: { status: [200], body: [{ contains: "not-in-body" }] } }), makeCtx()); expect(result.matched).toBe(false); expect(result.failure!.phase).toBe("body"); }); 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); }); test("POST 请求携带 body", async () => { - const result = await runHttpCheck( - makeTarget({ - url: `${baseUrl}/echo`, - method: "POST", - body: "test-body", - headers: { "content-type": "text/plain" }, - expect: { status: [200], body: [{ json: { path: "$.body", equals: "present" } }] }, - }), - ); + 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()); expect(result.matched).toBe(true); }); - test("仅 contains 规则时不解析 JSON", async () => { - const result = await runHttpCheck( - makeTarget({ - url: `${baseUrl}/ok`, - expect: { body: [{ contains: "hello world" }] }, - }), - ); - expect(result.matched).toBe(true); + test("serialize 返回 URL 和 config JSON", () => { + const target = makeTarget({}); + const s = checker.serialize(target); + expect(s.target).toBe(target.http.url); + const config = JSON.parse(s.config); + expect(config.url).toBe(target.http.url); + expect(config.method).toBe("GET"); }); }); diff --git a/tests/server/checker/runner/registry.test.ts b/tests/server/checker/runner/registry.test.ts new file mode 100644 index 0000000..7b64130 --- /dev/null +++ b/tests/server/checker/runner/registry.test.ts @@ -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"]); + }); +}); diff --git a/tests/server/checker/expect/body.test.ts b/tests/server/checker/runner/shared/body.test.ts similarity index 51% rename from tests/server/checker/expect/body.test.ts rename to tests/server/checker/runner/shared/body.test.ts index 436fc47..379858c 100644 --- a/tests/server/checker/expect/body.test.ts +++ b/tests/server/checker/runner/shared/body.test.ts @@ -1,149 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { - 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); - }); -}); +import { checkBodyExpect } from "../../../../../src/server/checker/runner/shared/body"; describe("checkBodyExpect (BodyRule[])", () => { test("无规则返回匹配成功", () => { @@ -234,11 +90,6 @@ describe("checkBodyExpect (BodyRule[])", () => { expect( checkBodyExpect(html, [{ css: { selector: 'meta[name="version"]', attr: "content", equals: "2.0.1" } }]).matched, ).toBe(true); - expect( - checkBodyExpect(html, [ - { css: { selector: 'meta[name="version"]', attr: "content", match: "\\d+\\.\\d+\\.\\d+" } }, - ]).matched, - ).toBe(true); }); test("css exists 检查", () => { diff --git a/tests/server/checker/runner/shared/duration.test.ts b/tests/server/checker/runner/shared/duration.test.ts new file mode 100644 index 0000000..7b1308e --- /dev/null +++ b/tests/server/checker/runner/shared/duration.test.ts @@ -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"); + }); +}); diff --git a/tests/server/checker/expect/failure.test.ts b/tests/server/checker/runner/shared/failure.test.ts similarity index 97% rename from tests/server/checker/expect/failure.test.ts rename to tests/server/checker/runner/shared/failure.test.ts index 04fca58..c6c755a 100644 --- a/tests/server/checker/expect/failure.test.ts +++ b/tests/server/checker/runner/shared/failure.test.ts @@ -1,5 +1,5 @@ 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", () => { test("短字符串不截断", () => { diff --git a/tests/server/checker/runner/shared/operator.test.ts b/tests/server/checker/runner/shared/operator.test.ts new file mode 100644 index 0000000..2664589 --- /dev/null +++ b/tests/server/checker/runner/shared/operator.test.ts @@ -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); + }); +}); diff --git a/tests/server/checker/runner/shared/text.test.ts b/tests/server/checker/runner/shared/text.test.ts new file mode 100644 index 0000000..97aaee4 --- /dev/null +++ b/tests/server/checker/runner/shared/text.test.ts @@ -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); + }); +}); diff --git a/tests/server/checker/store.test.ts b/tests/server/checker/store.test.ts index 54b577a..806a31d 100644 --- a/tests/server/checker/store.test.ts +++ b/tests/server/checker/store.test.ts @@ -4,6 +4,20 @@ import type { CheckFailure, ResolvedTarget } from "../../../src/server/checker/t import { mkdir, rm } from "node:fs/promises"; import { join } from "node:path"; 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 = { type: "http",