From b8810f11829926e72be48a50b2c7b6f32583d8b8 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Sun, 10 May 2026 22:25:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E4=B8=BA=E5=A4=9A?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=20checker=20=E9=80=9A=E7=94=A8=E6=A1=86?= =?UTF-8?q?=E6=9E=B6=EF=BC=8C=E6=94=AF=E6=8C=81=20HTTP=20=E4=B8=8E?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入 typed target 判别联合,支持 http 与 command 两种 checker - expect 重构为有序规则数组,按配置顺序快速失败并生成结构化 failure - 新增 command runner,支持 exec + args 本地命令执行 - 引入全局并发限制 maxConcurrentChecks 和 size 解析 (KB/MB/GB) - HTTP/command 各自独立 expect pipeline,应用领域默认成功语义 - SQLite schema、API、Dashboard 全链路调整为 checker 通用契约 - 补充完整测试覆盖(192 tests),更新 README 与示例配置 --- README.md | 160 +++++--- openspec/specs/command-checker/spec.md | 82 ++++ openspec/specs/expect-body-checkers/spec.md | 107 ++--- openspec/specs/probe-api/spec.md | 21 +- openspec/specs/probe-config/spec.md | 97 ++++- openspec/specs/probe-dashboard/spec.md | 29 +- openspec/specs/probe-data-store/spec.md | 51 ++- openspec/specs/probe-engine/spec.md | 137 ++++--- probes.example.yaml | 209 +++++++++- scripts/clean.ts | 4 +- scripts/smoke.ts | 4 +- src/server/app.ts | 24 +- src/server/checker/body-expect.ts | 188 --------- src/server/checker/command-runner.ts | 155 +++++++ src/server/checker/config-loader.ts | 167 +++++++- src/server/checker/engine.ts | 58 ++- src/server/checker/expect/body.ts | 302 ++++++++++++++ src/server/checker/expect/command.ts | 91 +++++ src/server/checker/expect/failure.ts | 34 ++ src/server/checker/expect/http.ts | 122 ++++++ src/server/checker/fetcher.ts | 162 ++++---- src/server/checker/size.ts | 22 + src/server/checker/store.ts | 136 ++++--- src/server/checker/types.ts | 138 +++++-- src/server/dev.ts | 2 +- src/shared/api.ts | 27 +- src/web/components/SparklineChart.tsx | 4 +- src/web/components/SummaryCards.tsx | 4 +- src/web/components/TargetDetail.tsx | 21 +- src/web/components/TargetRow.tsx | 12 +- src/web/components/TargetTable.tsx | 6 +- src/web/components/TrendChart.tsx | 10 +- src/web/hooks/useSummary.ts | 3 +- src/web/hooks/useTargets.ts | 3 +- src/web/styles.css | 10 +- tests/server/app.test.ts | 66 ++- tests/server/checker/command-runner.test.ts | 131 ++++++ tests/server/checker/config-loader.test.ts | 381 +++++++++++++++--- tests/server/checker/engine.test.ts | 252 ++++++++---- .../body.test.ts} | 173 +++++--- tests/server/checker/expect/command.test.ts | 168 ++++++++ tests/server/checker/expect/failure.test.ts | 79 ++++ tests/server/checker/expect/http.test.ts | 165 ++++++++ tests/server/checker/fetcher.test.ts | 370 +++++++++++------ tests/server/checker/size.test.ts | 43 ++ tests/server/checker/store.test.ts | 194 ++++++--- 46 files changed, 3562 insertions(+), 1062 deletions(-) create mode 100644 openspec/specs/command-checker/spec.md delete mode 100644 src/server/checker/body-expect.ts create mode 100644 src/server/checker/command-runner.ts create mode 100644 src/server/checker/expect/body.ts create mode 100644 src/server/checker/expect/command.ts create mode 100644 src/server/checker/expect/failure.ts create mode 100644 src/server/checker/expect/http.ts create mode 100644 src/server/checker/size.ts create mode 100644 tests/server/checker/command-runner.test.ts rename tests/server/checker/{body-expect.test.ts => expect/body.test.ts} (50%) create mode 100644 tests/server/checker/expect/command.test.ts create mode 100644 tests/server/checker/expect/failure.test.ts create mode 100644 tests/server/checker/expect/http.test.ts create mode 100644 tests/server/checker/size.test.ts diff --git a/README.md b/README.md index 9223492..20fa2f6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Gateway Checker -基于 Bun + TypeScript 的 HTTP 拨测监控工具。通过 YAML 配置文件定义拨测目标,后端按配置定时并发拨测,结果持久化到本地 SQLite,前端 Dashboard 展示各目标实时状态、可用率、延迟趋势等。 +基于 Bun + TypeScript 的多类型拨测监控工具。通过 YAML 配置文件定义 HTTP 和命令行拨测目标,后端按配置定时并发拨测,结果持久化到本地 SQLite,前端 Dashboard 展示各目标实时状态、可用率、耗时趋势等。 ## 项目结构 @@ -12,11 +12,16 @@ src/ dev.ts 开发期启动入口 server.ts HTTP server 启动 checker/ - types.ts 类型定义 + types.ts 类型定义 config-loader.ts YAML 配置解析与校验 - store.ts SQLite 数据存储 - fetcher.ts HTTP 拨测执行 - engine.ts 调度引擎(按 interval 分组、组内并发) + store.ts SQLite 数据存储 + http-runner.ts HTTP 拨测执行 + command-runner.ts 命令行拨测执行 + http-expect.ts HTTP 响应断言 + command-expect.ts 命令行输出断言 + failure.ts 失败信息类型 + size.ts 大小单位解析 + engine.ts 调度引擎(按 interval 分组、组内并发) shared/ api.ts 前后端共享 TypeScript 类型 web/ Vite + React 前端 Dashboard @@ -52,48 +57,65 @@ bun run dev:web server: host: "127.0.0.1" port: 3000 - dataDir: "./data" + dataDir: "/tmp/probes_data" + +runtime: + maxConcurrentChecks: 20 defaults: - interval: "30s" + interval: "5s" timeout: "10s" - method: "GET" + http: + method: GET + maxBodyBytes: "100MB" + command: + maxOutputBytes: "100MB" targets: - - name: "示例服务" - url: "https://httpbin.org/get" - interval: "60s" - - - name: "POST 检查" - url: "https://httpbin.org/post" - method: "POST" - headers: - Content-Type: "application/json" - body: '{"ping": true}' + - name: "Baidu" + type: http + http: + url: "https://www.baidu.com" expect: status: [200] - maxLatencyMs: 5000 + maxDurationMs: 10000 - - name: "JSON API 监控" - url: "https://httpbin.org/json" + - name: "JSON API 示例" + type: http + http: + url: "https://httpbin.org/json" expect: status: [200] headers: - Content-Type: application/json + Content-Type: + contains: "application/json" body: - contains: "slideshow" - json: - $.slideshow.title: "Sample Slide Show" + - contains: "slideshow" + - json: + path: "$.slideshow.title" + equals: "Sample Slide Show" - - name: "HTML 页面监控" - url: "https://httpbin.org/html" + - name: "HTML 页面示例" + type: http + http: + url: "https://httpbin.org/html" expect: status: [200] body: - css: - "h1": "Herman Melville - Moby-Dick" - xpath: - "/html/body/h1/text()": "Herman Melville - Moby-Dick" + - contains: "Moby-Dick" + - xpath: + path: "/html/body/h1/text()" + equals: "Herman Melville - Moby-Dick" + + - name: "Nginx 进程检查" + type: command + command: + exec: "pgrep" + args: ["nginx"] + expect: + exitCode: [0] + stdout: + - match: "\\d+" ``` ### 配置说明 @@ -102,39 +124,67 @@ targets: - `host`: 监听地址,默认 `127.0.0.1` - `port`: 监听端口,默认 `3000` - `dataDir`: 数据目录,默认 `./data` +- **runtime**: 运行时配置 + - `maxConcurrentChecks`: 最大并发拨测数,默认 `20` - **defaults**: 全局默认值(均可省略) - `interval`: 拨测间隔,默认 `30s` - - `timeout`: 请求超时,默认 `10s` - - `method`: HTTP 方法,默认 `GET` - - `headers`: 全局 headers + - `timeout`: 超时时间,默认 `10s` + - `http`: HTTP 类型默认值 + - `method`: HTTP 方法,默认 `GET` + - `maxBodyBytes`: 响应体最大字节数,默认 `100MB` + - `command`: Command 类型默认值 + - `maxOutputBytes`: 输出最大字节数,默认 `100MB` - **targets**: 拨测目标列表(必填) - `name`: 目标名称(必填,唯一) - - `url`: 目标 URL(必填) - - `method`、`headers`、`body`: 请求参数 + - `type`: 目标类型,`http` 或 `command`(必填) + - `http`: HTTP 拨测配置(type 为 http 时必填) + - `url`: 目标 URL + - `method`、`headers`、`body`: 请求参数 + - `command`: 命令行拨测配置(type 为 command 时必填) + - `exec`: 可执行文件名或路径 + - `args`: 命令行参数列表 - `interval`、`timeout`: 覆盖全局默认值 - `expect`: 期望校验 - - `status`: 可接受的状态码列表 - - `headers`: 响应头校验(键值对,全部匹配) - - `maxLatencyMs`: 最大延迟阈值(毫秒) - - `body`: 响应体校验(可组合使用) + - `status`: 可接受的状态码列表(HTTP) + - `exitCode`: 可接受的退出码列表(Command) + - `headers`: 响应头校验(HTTP,支持 `equals`、`contains` 等操作符) + - `maxDurationMs`: 最大耗时阈值(毫秒) + - `body`: HTTP 响应体校验(数组,可组合使用) - `contains`: 响应体包含的文本 - - `regex`: 响应体匹配的正则表达式 - - `json`: JSONPath 提取值比较(路径 → 期望值) - - `css`: CSS 选择器提取 HTML 元素比较(选择器 → 期望值,可选 `attr` 提取属性) - - `xpath`: XPath 提取 XML/HTML 节点比较(路径 → 期望值) - - body 比较支持操作符:`equals`(默认)、`contains`、`match`(正则)、`empty`、`exists`、`gte`、`lte`、`gt`、`lt` + - `match`: 响应体匹配的正则表达式 + - `json`: JSONPath 提取值比较(`path` + 比较操作符) + - `css`: CSS 选择器提取 HTML 元素比较 + - `xpath`: XPath 提取 XML/HTML 节点比较 + - `stdout` / `stderr`: Command 输出校验(数组,同 body 格式) + - 比较操作符:`equals`(默认)、`contains`、`match`(正则)、`empty`、`exists`、`gte`、`lte`、`gt`、`lt` + +大小说明:`maxBodyBytes` 和 `maxOutputBytes` 支持单位 `KB`、`MB`、`GB`,也可直接使用数字(字节数)。 时长格式支持:`30s`、`5m`、`500ms` ## API 端点 -| 端点 | 说明 | -| --------------------------------------- | ---------------------------------------------------- | -| `GET /health` | 健康检查 | -| `GET /api/summary` | 总览统计(total/up/down/avgLatencyMs/lastCheckTime) | -| `GET /api/targets` | 目标列表及最新状态和统计摘要 | -| `GET /api/targets/:id/history?limit=20` | 指定目标的最近 N 条拨测记录 | -| `GET /api/targets/:id/trend?hours=24` | 指定目标的按小时聚合趋势 | +| 端点 | 说明 | +| --------------------------------------- | ----------------------------------------------------- | +| `GET /health` | 健康检查 | +| `GET /api/summary` | 总览统计(total/up/down/avgDurationMs/lastCheckTime) | +| `GET /api/targets` | 目标列表及最新状态和统计摘要 | +| `GET /api/targets/:id/history?limit=20` | 指定目标的最近 N 条拨测记录 | +| `GET /api/targets/:id/trend?hours=24` | 指定目标的按小时聚合趋势 | + +### 响应字段 + +**SummaryResponse**: `total`、`up`、`down`、`avgDurationMs`、`lastCheckTime` + +**TargetStatus**: `id`、`name`、`type`(http/command)、`target`(URL 或命令摘要)、`interval`、`latestCheck`、`stats`、`sparkline` + +**CheckResult**: `timestamp`、`success`、`matched`、`durationMs`、`statusDetail`、`failure` + +**CheckFailure**: `kind`(error/mismatch)、`phase`、`path`、`expected`、`actual`、`message` + +**TargetStats**: `totalChecks`、`availability`、`avgDurationMs`、`p99DurationMs` + +**TrendPoint**: `hour`、`avgDurationMs`、`availability`、`totalChecks` ## 代码质量 @@ -190,13 +240,13 @@ bun run verify ## 目标状态判定 -两层判定模型: +两层判定模型,适用于 HTTP 和 Command 两种类型: -- **success**: 请求是否成功完成(收到 HTTP 响应) +- **success**: 拨测是否成功完成(HTTP 收到响应 / Command 正常退出) - **matched**: 是否符合 expect 规则(无 expect 时默认为 true) - **UP** = success AND matched - **DOWN** = NOT success OR NOT matched ## 已知限制 -当前不做告警通知、数据自动清理、拨测目标动态增删、认证鉴权和分布式部署。 +当前不做告警通知、数据自动清理、拨测目标动态增删、认证鉴权和分布式部署。Command 类型拨测不支持 Windows 环境。 diff --git a/openspec/specs/command-checker/spec.md b/openspec/specs/command-checker/spec.md new file mode 100644 index 0000000..54d6a86 --- /dev/null +++ b/openspec/specs/command-checker/spec.md @@ -0,0 +1,82 @@ +## Purpose + +TBD + +## Requirements + +### Requirement: command target 配置 +系统 SHALL 支持 `type: command` 的 target 配置,通过 `command.exec` 和 `command.args` 描述本地命令,并使用 command 专用字段配置工作目录、环境变量和输出限制。 + +#### Scenario: 解析 command target +- **WHEN** YAML 中 target 配置 `type: command`、`command.exec: "pgrep"` 和 `command.args: ["nginx"]` +- **THEN** 系统 SHALL 将其解析为 command checker,并保留 exec、args、cwd、env、maxOutputBytes、interval、timeout 和 expect 配置 + +#### Scenario: command target 缺少 exec +- **WHEN** YAML 中 target 配置 `type: command` 但缺少 `command.exec` +- **THEN** 系统 SHALL 以配置错误退出,并提示该 target 缺少 command.exec 字段 + +#### Scenario: cwd 相对配置文件目录解析 +- **WHEN** command target 配置 `command.cwd: "scripts"` 且配置文件位于 `/opt/checker/probes.yaml` +- **THEN** 系统 SHALL 将 cwd 解析为 `/opt/checker/scripts` + +#### Scenario: command 不使用 shell +- **WHEN** command target 配置 `exec` 和 `args` +- **THEN** 系统 MUST 直接执行该程序和参数,不通过 shell 解释整段命令字符串 + +#### Scenario: env 默认继承并允许覆盖 +- **WHEN** command target 配置 `command.env: {LANG: "C"}` 且当前进程环境包含 `PATH` +- **THEN** 系统 SHALL 继承当前进程的全部环境变量,并将 `LANG` 覆盖为 `"C"` + +#### Scenario: 不支持 stdin +- **WHEN** command target 配置并执行命令 +- **THEN** 系统 MUST NOT 向子进程 stdin 写入数据,避免命令因等待输入而阻塞 + +### Requirement: command checker 执行 +系统 SHALL 按 command target 配置执行本地命令,记录执行耗时、退出码、stdout 和 stderr,并在执行失败时产生结构化错误信息。 + +#### Scenario: 命令正常退出 +- **WHEN** command target 执行的进程正常退出且 exit code 为 0 +- **THEN** 系统 SHALL 记录 `success=true`、`durationMs`、`statusDetail="exitCode=0"`,并进入 expect 校验 + +#### Scenario: 命令非零退出 +- **WHEN** command target 执行的进程正常退出但 exit code 为 1 +- **THEN** 系统 SHALL 记录 `success=true` 和 `statusDetail="exitCode=1"`,并由 expect.exitCode 决定 matched 结果 + +#### Scenario: 命令启动失败 +- **WHEN** command target 的 exec 不存在或无法启动 +- **THEN** 系统 SHALL 记录 `success=false`、`matched=false`,并在 failure 中写入 kind=`error`、phase=`exitCode` 和可读错误信息 + +#### Scenario: 命令超时 +- **WHEN** command target 在 timeout 时间内未结束 +- **THEN** 系统 MUST 终止该子进程,记录 `success=false`、`matched=false`,并在 failure 中写入命令超时信息 + +#### Scenario: 命令输出超限 +- **WHEN** command target 的 stdout 和 stderr 合计输出超过 `maxOutputBytes` +- **THEN** 系统 MUST 停止收集输出并终止该检查,记录 `success=false`、`matched=false`,并在 failure 中写入输出超限信息 + +### Requirement: command expect 校验 +系统 SHALL 支持 command 专用 expect,包括 `exitCode`、`stdout` 和 `stderr`,并按 exitCode、duration、stdout、stderr 的阶段顺序快速失败。 + +#### Scenario: 默认 exitCode 成功语义 +- **WHEN** command target 未显式配置 `expect.exitCode` +- **THEN** 系统 SHALL 使用默认 `expect.exitCode: [0]` 进行校验 + +#### Scenario: 显式 exitCode 校验 +- **WHEN** command target 配置 `expect.exitCode: [0, 2]` 且实际 exit code 为 2 +- **THEN** 系统 SHALL 判定 exitCode 阶段通过,并继续后续 expect 阶段 + +#### Scenario: exitCode 不匹配快速失败 +- **WHEN** command target 配置 `expect.exitCode: [0]` 且实际 exit code 为 1 +- **THEN** 系统 SHALL 立即返回 `matched=false`,并在 failure 中写入 phase=`exitCode`、path=`expect.exitCode`、expected 和 actual + +#### Scenario: stdout 按配置顺序校验 +- **WHEN** command target 配置 `expect.stdout` 为两个规则,第一条通过且第二条失败 +- **THEN** 系统 SHALL 先执行第一条 stdout 规则,再执行第二条,并将 failure.path 指向失败的 `expect.stdout[1]` + +#### Scenario: stderr 校验为空 +- **WHEN** command target 配置 `expect.stderr: [{empty: true}]` 且实际 stderr 为空字符串 +- **THEN** 系统 SHALL 判定 stderr 阶段通过 + +#### Scenario: stdout 失败后不检查 stderr +- **WHEN** command target 同时配置 stdout 和 stderr 规则,且 stdout 规则失败 +- **THEN** 系统 SHALL 快速失败并 MUST NOT 继续执行 stderr 规则 diff --git a/openspec/specs/expect-body-checkers/spec.md b/openspec/specs/expect-body-checkers/spec.md index 64a8c13..d24f635 100644 --- a/openspec/specs/expect-body-checkers/spec.md +++ b/openspec/specs/expect-body-checkers/spec.md @@ -5,113 +5,124 @@ ## Requirements ### Requirement: 响应体多种校验方法 -系统 SHALL 支持对 HTTP 响应体进行五种可组合的校验方法:contains(子串)、regex(正则)、json(JSONPath)、css(CSS 选择器)、xpath(XPath),配置在 `expect.body` 分组下。 +系统 SHALL 支持对 HTTP 响应体进行五种可组合的校验方法:contains(子串)、regex(正则)、json(JSONPath)、css(CSS 选择器)、xpath(XPath)。这些方法 MUST 配置在 `expect.body` 有序数组中。 #### Scenario: contains 子串匹配 -- **WHEN** 目标配置 `expect.body.contains: "healthy"`,且响应体包含 `"healthy"` -- **THEN** 系统 SHALL 判定 matched 为 true +- **WHEN** HTTP target 配置 `expect.body: [{contains: "healthy"}]`,且响应体包含 `"healthy"` +- **THEN** 系统 SHALL 判定该 body 规则通过 #### Scenario: contains 不匹配 -- **WHEN** 目标配置 `expect.body.contains: "healthy"`,且响应体不包含该文本 -- **THEN** 系统 SHALL 判定 matched 为 false +- **WHEN** HTTP target 配置 `expect.body: [{contains: "healthy"}]`,且响应体不包含该文本 +- **THEN** 系统 SHALL 判定 matched 为 false,并记录该规则的 failure.path #### Scenario: regex 正则匹配 -- **WHEN** 目标配置 `expect.body.regex: '"status"\\s*:\\s*"ok"'`,且响应体匹配该正则 -- **THEN** 系统 SHALL 判定 matched 为 true +- **WHEN** HTTP target 配置 `expect.body: [{regex: '"status"\\s*:\\s*"ok"'}]`,且响应体匹配该正则 +- **THEN** 系统 SHALL 判定该 body 规则通过 #### Scenario: regex 不匹配 -- **WHEN** 目标配置 `expect.body.regex: '"status"\\s*:\\s*"ok"'`,且响应体不匹配该正则 -- **THEN** 系统 SHALL 判定 matched 为 false +- **WHEN** HTTP target 配置 regex body 规则,且响应体不匹配该正则 +- **THEN** 系统 SHALL 判定 matched 为 false,并记录该规则的 failure.path #### Scenario: json JSONPath 等值匹配 -- **WHEN** 目标配置 `expect.body.json: {"$.status": "ok"}`,且响应 JSON 中 `$.status` 值为 `"ok"` -- **THEN** 系统 SHALL 判定 matched 为 true +- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.status", equals: "ok"}}]`,且响应 JSON 中 `$.status` 值为 `"ok"` +- **THEN** 系统 SHALL 判定该 body 规则通过 #### Scenario: json JSONPath 值不匹配 -- **WHEN** 目标配置 `expect.body.json: {"$.status": "ok"}`,且响应 JSON 中 `$.status` 值为 `"error"` -- **THEN** 系统 SHALL 判定 matched 为 false +- **WHEN** HTTP target 配置 json body 规则,且提取值不符合期望 +- **THEN** 系统 SHALL 判定 matched 为 false,并记录包含 JSONPath 的 failure.path #### Scenario: json 解析失败 -- **WHEN** 目标配置了 `expect.body.json` 但响应体不是合法 JSON +- **WHEN** HTTP target 配置了 json body 规则但响应体不是合法 JSON - **THEN** 系统 SHALL 判定 matched 为 false #### Scenario: css 选择器匹配 -- **WHEN** 目标配置 `expect.body.css: {"div#health": "OK"}`,且 HTML 中存在 `div#health` 元素文本为 `"OK"` -- **THEN** 系统 SHALL 判定 matched 为 true +- **WHEN** HTTP target 配置 `expect.body: [{css: {selector: "div#health", equals: "OK"}}]`,且 HTML 中存在 `div#health` 元素文本为 `"OK"` +- **THEN** 系统 SHALL 判定该 body 规则通过 #### Scenario: css 选择器匹配属性值 -- **WHEN** 目标配置 css 规则带 `attr: "content"` 用于提取属性,且属性值匹配期望 -- **THEN** 系统 SHALL 判定 matched 为 true +- **WHEN** HTTP target 配置 css 规则带 `attr: "content"` 用于提取属性,且属性值匹配期望 +- **THEN** 系统 SHALL 判定该 body 规则通过 #### Scenario: css 选择器无匹配元素 -- **WHEN** 目标配置了 css 选择器但 HTML 中无匹配元素 +- **WHEN** HTTP target 配置了 css 选择器但 HTML 中无匹配元素 - **THEN** 系统 SHALL 判定 matched 为 false #### Scenario: xpath 表达式匹配 -- **WHEN** 目标配置 `expect.body.xpath: {"/root/status/text()": "ok"}`,且 XML 中 `/root/status` 节点文本为 `"ok"` -- **THEN** 系统 SHALL 判定 matched 为 true +- **WHEN** HTTP target 配置 `expect.body: [{xpath: {path: "/root/status/text()", equals: "ok"}}]`,且 XML 中 `/root/status` 节点文本为 `"ok"` +- **THEN** 系统 SHALL 判定该 body 规则通过 #### Scenario: xpath 表达式无匹配节点 -- **WHEN** 目标配置了 xpath 表达式但 XML 中无匹配节点 +- **WHEN** HTTP target 配置了 xpath 表达式但 XML 中无匹配节点 - **THEN** 系统 SHALL 判定 matched 为 false ### Requirement: 多种 body 校验方法 AND 组合 -系统 SHALL 支持同时配置多种 body 校验方法,所有方法均通过时 matched 方为 true。 +系统 SHALL 支持在 `expect.body` 数组中同时配置多种 body 校验方法,所有方法均通过时 matched 方为 true。 #### Scenario: 多种方法全部通过 -- **WHEN** 目标同时配置 `body.contains`、`body.json`、`body.regex`,且全部通过 +- **WHEN** HTTP target 的 `expect.body` 数组依次配置 contains、json、regex,且全部通过 - **THEN** 系统 SHALL 判定 matched 为 true #### Scenario: 多种方法任一失败 -- **WHEN** 目标同时配置 `body.contains` 和 `body.json`,且 `body.contains` 不通过 -- **THEN** 系统 SHALL 判定 matched 为 false,且不再检查 `body.json` +- **WHEN** HTTP target 的 `expect.body` 数组第一条 contains 不通过,后续还有 json 规则 +- **THEN** 系统 SHALL 判定 matched 为 false,且不再检查后续 json 规则 ### Requirement: 操作符系统 -系统 SHALL 支持对 body 校验的提取值使用以下操作符进行比较:equals(默认等值)、contains(子串包含)、match(正则匹配)、empty(空值判断)、exists(存在性判断)、gte/lte/gt/lt(数值比较)。 +系统 SHALL 支持对提取值和文本值使用以下操作符进行比较:equals(默认等值)、contains(子串包含)、match(正则匹配)、empty(空值判断)、exists(存在性判断)、gte/lte/gt/lt(数值比较)。 #### Scenario: 标量值隐式 equals -- **WHEN** jsonPath 配置的期望值为标量(字符串/数字/布尔/null),如 `$.status: ok` -- **THEN** 系统 SHALL 使用 equals 操作符,对提取值做严格相等比较 +- **WHEN** 配置的期望值为标量(字符串/数字/布尔/null),如 `equals: "ok"` +- **THEN** 系统 SHALL 使用 equals 操作符,对实际值做严格相等比较 #### Scenario: 显式 contains 操作符 -- **WHEN** 配置 `$.message: {contains: "success"}`,且提取值包含 `"success"` -- **THEN** 系统 SHALL 判定 matched 为 true +- **WHEN** 配置 `{contains: "success"}`,且实际值包含 `"success"` +- **THEN** 系统 SHALL 判定该规则通过 #### Scenario: 显式 match 操作符 -- **WHEN** 配置 `$.version: {match: '\\d+\\.\\d+\\.\\d+'}`,且提取值匹配该正则 -- **THEN** 系统 SHALL 判定 matched 为 true +- **WHEN** 配置 `{match: '\\d+\\.\\d+\\.\\d+'}`,且实际值匹配该正则 +- **THEN** 系统 SHALL 判定该规则通过 #### Scenario: empty 操作符判断为空 -- **WHEN** 配置 `$.items: {empty: true}`,且提取值为空数组 `[]` -- **THEN** 系统 SHALL 判定 matched 为 true +- **WHEN** 配置 `{empty: true}`,且实际值为空数组 `[]` +- **THEN** 系统 SHALL 判定该规则通过 #### Scenario: empty 操作符判断非空 -- **WHEN** 配置 `$.items: {empty: false}`,且提取值为 `[1, 2]` -- **THEN** 系统 SHALL 判定 matched 为 true +- **WHEN** 配置 `{empty: false}`,且实际值为 `[1, 2]` +- **THEN** 系统 SHALL 判定该规则通过 #### Scenario: exists 操作符判断存在 -- **WHEN** 配置 `$.error: {exists: false}`,且 JSON 中不存在 `error` 字段 -- **THEN** 系统 SHALL 判定 matched 为 true +- **WHEN** 配置 `{exists: false}`,且实际值不存在 +- **THEN** 系统 SHALL 判定该规则通过 #### Scenario: gte 数值比较 -- **WHEN** 配置 `$.count: {gte: 10}`,且提取值为 `15`(数字) -- **THEN** 系统 SHALL 判定 matched 为 true +- **WHEN** 配置 `{gte: 10}`,且实际值为 `15`(数字) +- **THEN** 系统 SHALL 判定该规则通过 #### Scenario: gt/lt 数值比较 -- **WHEN** 配置 `$.latency: {gt: 0, lt: 1000}`,且提取值为 `500` -- **THEN** 系统 SHALL 对同一字段进行多操作符复合比较,全部通过则 matched 为 true +- **WHEN** 配置 `{gt: 0, lt: 1000}`,且实际值为 `500` +- **THEN** 系统 SHALL 对同一字段进行多操作符复合比较,全部通过则该规则通过 ### Requirement: 响应头校验 -系统 SHALL 支持通过 `expect.headers` 配置对响应头进行键值对校验。 +系统 SHALL 支持通过 `expect.headers` 配置对 HTTP 响应头进行键值规则校验,header 名称匹配 MUST 不区分大小写。 #### Scenario: 响应头匹配 -- **WHEN** 目标配置 `expect.headers: {"Content-Type": "application/json"}`,且响应包含该 header 且值匹配 -- **THEN** 系统 SHALL 判定 matched 为 true +- **WHEN** HTTP target 配置 `expect.headers: {"Content-Type": {contains: "application/json"}}`,且响应包含该 header 且值匹配 +- **THEN** 系统 SHALL 判定 headers 阶段通过 #### Scenario: 响应头不匹配 -- **WHEN** 目标配置 `expect.headers: {"Content-Type": "application/json"}`,且响应 header 值为 `"text/html"` +- **WHEN** HTTP target 配置 `expect.headers: {"Content-Type": {equals: "application/json"}}`,且响应 header 值为 `"text/html"` - **THEN** 系统 SHALL 判定 matched 为 false #### Scenario: 响应头缺失 -- **WHEN** 目标配置了某个 header 但响应中不存在该 header +- **WHEN** HTTP target 配置了某个 header 但响应中不存在该 header - **THEN** 系统 SHALL 判定 matched 为 false + +### Requirement: 结构化 expect 失败信息 +系统 SHALL 在任一 expect 规则失败时生成结构化 failure,用于标识失败阶段、规则路径、期望值、实际值和可读错误信息。 + +#### Scenario: body 规则失败信息 +- **WHEN** HTTP target 的 `expect.body[1].json` 规则失败 +- **THEN** failure SHALL 包含 kind=`mismatch`、phase=`body`、path 指向 `expect.body[1]`,并包含 message + +#### Scenario: actual 值截断 +- **WHEN** 失败规则的实际值超过系统允许记录的摘要长度 +- **THEN** 系统 MUST 截断 actual 摘要,而不是持久化完整响应体或命令输出 diff --git a/openspec/specs/probe-api/spec.md b/openspec/specs/probe-api/spec.md index 56240e7..02c5262 100644 --- a/openspec/specs/probe-api/spec.md +++ b/openspec/specs/probe-api/spec.md @@ -9,14 +9,14 @@ #### Scenario: 获取总览统计 - **WHEN** 客户端请求 `GET /api/summary` -- **THEN** 系统 SHALL 返回 JSON 包含 total(总目标数)、up(正常数)、down(异常数)、avgLatencyMs(所有目标平均延迟)、lastCheckTime(最近一次拨测时间) +- **THEN** 系统 SHALL 返回 JSON 包含 total(总目标数)、up(正常数)、down(异常数)、avgDurationMs(所有目标平均耗时)、lastCheckTime(最近一次检查时间) ### Requirement: 目标列表 API -系统 SHALL 提供 `GET /api/targets` 端点,返回所有目标及其最新状态和统计摘要。 +系统 SHALL 提供 `GET /api/targets` 端点,返回所有 typed target 及其最新状态和统计摘要。 #### Scenario: 获取目标列表 - **WHEN** 客户端请求 `GET /api/targets` -- **THEN** 系统 SHALL 返回 JSON 数组,每个元素包含目标基本信息、最近一次拨测结果(timestamp、success、statusCode、latencyMs、error、matched)和统计摘要(totalChecks、availability、avgLatencyMs、p99LatencyMs) +- **THEN** 系统 SHALL 返回 JSON 数组,每个元素包含目标基本信息(id、name、type、target、interval)、最近一次检查结果(timestamp、success、matched、durationMs、statusDetail、failure)和统计摘要(totalChecks、availability、avgDurationMs、p99DurationMs) #### Scenario: 目标无历史记录 - **WHEN** 某目标尚未执行过任何拨测 @@ -27,7 +27,7 @@ #### Scenario: 获取最近历史记录 - **WHEN** 客户端请求 `GET /api/targets/1/history?limit=20` -- **THEN** 系统 SHALL 返回最多 20 条拨测记录,按时间倒序排列 +- **THEN** 系统 SHALL 返回最多 20 条检查记录,按时间倒序排列,且每条包含 success、matched、durationMs、statusDetail 和 failure #### Scenario: 使用默认 limit - **WHEN** 客户端请求 `GET /api/targets/1/history`(未指定 limit) @@ -38,7 +38,7 @@ #### Scenario: 获取 24 小时趋势 - **WHEN** 客户端请求 `GET /api/targets/1/trend?hours=24` -- **THEN** 系统 SHALL 返回按小时分组的聚合数据,每个数据点包含 hour、avgLatencyMs、availability、totalChecks +- **THEN** 系统 SHALL 返回按小时分组的聚合数据,每个数据点包含 hour、avgDurationMs、availability、totalChecks #### Scenario: 使用默认时间范围 - **WHEN** 客户端请求 `GET /api/targets/1/trend`(未指定 hours) @@ -61,3 +61,14 @@ #### Scenario: 无效的 limit 参数 - **WHEN** 客户端请求 `GET /api/targets/1/history?limit=abc` - **THEN** 系统 SHALL 返回 400 状态码和错误信息 + +### Requirement: 失败信息 API 契约 +系统 SHALL 通过 API 返回结构化 failure 信息,供 Dashboard 展示和后续排查。 + +#### Scenario: 返回 expect 不匹配信息 +- **WHEN** 最近一次检查结果包含 failure.kind=`mismatch` +- **THEN** `/api/targets` 和 `/api/targets/:id/history` SHALL 返回该 failure 的 kind、phase、path、expected、actual、message 字段 + +#### Scenario: 无失败信息 +- **WHEN** 检查结果 success=true 且 matched=true +- **THEN** API SHALL 返回 failure 为 null diff --git a/openspec/specs/probe-config/spec.md b/openspec/specs/probe-config/spec.md index 02fbb55..1a99023 100644 --- a/openspec/specs/probe-config/spec.md +++ b/openspec/specs/probe-config/spec.md @@ -5,19 +5,23 @@ ## Requirements ### Requirement: YAML 配置文件格式 -系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、数据目录、拨测默认值和拨测目标列表。 +系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、checker 默认值和 typed target 列表。target MUST 使用 `type` 字段声明 checker 类型,HTTP 领域字段 MUST 放在 `http` 分组,command 领域字段 MUST 放在 `command` 分组。 #### Scenario: 完整配置文件解析 -- **WHEN** 系统启动并读取包含 server、defaults、targets 的 YAML 配置文件 -- **THEN** 系统 SHALL 正确解析所有字段并用于初始化服务 +- **WHEN** 系统启动并读取包含 server、runtime、defaults、targets 的 YAML 配置文件 +- **THEN** 系统 SHALL 正确解析所有字段并用于初始化服务、调度引擎和对应 checker runner -#### Scenario: 最简配置文件解析 -- **WHEN** 系统读取只包含 targets 列表的 YAML 配置文件(省略 server 和 defaults) -- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段(host=127.0.0.1, port=3000, dir=./data, interval=30s, timeout=10s, method=GET) +#### Scenario: 最简 HTTP 配置文件解析 +- **WHEN** 系统读取只包含一个 `type: http` target 和 `http.url` 的 YAML 配置文件(省略 server、runtime、defaults 和 expect) +- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段(host=127.0.0.1, port=3000, dir=./data, interval=30s, timeout=10s, runtime.maxConcurrentChecks=20, http.method=GET, http.maxBodyBytes=100MB) + +#### Scenario: 最简 command 配置文件解析 +- **WHEN** 系统读取只包含一个 `type: command` target 和 `command.exec` 的 YAML 配置文件 +- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段(interval=30s, timeout=10s, command.cwd 为配置文件所在目录, command.maxOutputBytes=100MB) #### Scenario: per-target 配置覆盖全局默认值 -- **WHEN** 某个 target 指定了 interval、timeout 或 method -- **THEN** 该 target SHALL 使用其自身的值,不受 defaults 影响 +- **WHEN** 某个 target 指定 interval、timeout 或对应领域分组中的默认字段 +- **THEN** 该 target SHALL 使用其自身的值,不受 defaults 中对应字段影响 ### Requirement: CLI 参数 系统 SHALL 通过单一命令行参数接受 YAML 配置文件路径。 @@ -38,17 +42,59 @@ 系统 SHALL 在启动时对 YAML 配置进行完整校验,校验失败时以非零状态退出并输出清晰的错误信息。 #### Scenario: target 缺少必填字段 -- **WHEN** YAML 中某个 target 缺少 name 或 url 字段 +- **WHEN** YAML 中某个 target 缺少 name 或 type 字段 - **THEN** 系统 SHALL 以错误退出,提示哪个 target 缺少哪个字段 +#### Scenario: HTTP target 缺少 url +- **WHEN** YAML 中某个 target 配置 `type: http` 但缺少 `http.url` +- **THEN** 系统 SHALL 以错误退出,提示该 target 缺少 http.url 字段 + +#### Scenario: command target 缺少 exec +- **WHEN** YAML 中某个 target 配置 `type: command` 但缺少 `command.exec` +- **THEN** 系统 SHALL 以错误退出,提示该 target 缺少 command.exec 字段 + +#### Scenario: target type 非法 +- **WHEN** YAML 中某个 target 的 type 不是 `http` 或 `command` +- **THEN** 系统 SHALL 以错误退出,提示不支持的 target type + #### Scenario: target name 重复 - **WHEN** YAML 中存在两个 name 相同的 target - **THEN** 系统 SHALL 以错误退出,提示重复的 name #### Scenario: interval 格式非法 -- **WHEN** interval 或 timeout 值不是有效的时长格式(如 `30s`、`5m`) +- **WHEN** interval 或 timeout 值不是有效的时长格式(如 `30s`、`5m`、`500ms`) - **THEN** 系统 SHALL 以错误退出并提示格式错误 +#### Scenario: maxConcurrentChecks 非法 +- **WHEN** runtime.maxConcurrentChecks 不是正整数 +- **THEN** 系统 SHALL 以错误退出并提示 runtime.maxConcurrentChecks 格式错误 + +#### Scenario: size 格式非法 +- **WHEN** maxBodyBytes 或 maxOutputBytes 值不是有效的 size 格式 +- **THEN** 系统 SHALL 以错误退出并提示支持 B、KB、MB、GB 格式 + +### Requirement: size 配置解析 +系统 SHALL 支持使用单位字符串配置读取上限,单位包括 `B`、`KB`、`MB` 和 `GB`。 + +#### Scenario: 解析 MB +- **WHEN** YAML 中配置 `maxBodyBytes: "100MB"` +- **THEN** 系统 SHALL 将其解析为 104857600 bytes + +#### Scenario: 解析 KB +- **WHEN** YAML 中配置 `maxOutputBytes: "512KB"` +- **THEN** 系统 SHALL 将其解析为 524288 bytes + +### Requirement: runtime 并发配置 +系统 SHALL 支持 `runtime.maxConcurrentChecks` 配置全局最大并发检查数。 + +#### Scenario: 使用默认并发限制 +- **WHEN** YAML 中未配置 runtime.maxConcurrentChecks +- **THEN** 系统 SHALL 使用默认值 20 + +#### Scenario: 配置并发限制 +- **WHEN** YAML 中配置 `runtime.maxConcurrentChecks: 5` +- **THEN** 系统 SHALL 将全局最大并发检查数设置为 5 + ### Requirement: YAML 配置使用 Bun 内置解析 系统 SHALL 使用 Bun 内置的 `Bun.YAML.parse()` 解析配置文件,不引入外部 YAML 解析库。 @@ -57,21 +103,28 @@ - **THEN** 系统 SHALL 调用 `Bun.YAML.parse()` 将内容解析为配置对象 ### Requirement: expect 配置增强 -系统 SHALL 支持增强的 expect 配置格式,包括 `headers` 响应头校验和 `body` 分组下的多种校验方法(contains、regex、json、css、xpath)。 +系统 SHALL 支持 typed target 的领域专用 expect 配置,包括 HTTP 的 `status`、`headers`、`body` 和 command 的 `exitCode`、`stdout`、`stderr`。内容类 expect MUST 使用数组表达配置顺序。 -#### Scenario: 解析增强的 expect 配置 -- **WHEN** YAML 配置文件中 target 的 expect 包含 headers、body 分组及内部方法 -- **THEN** 系统 SHALL 正确解析并存储为 ResolvedTarget 的 expect 字段 +#### Scenario: 解析 HTTP expect 配置 +- **WHEN** YAML 配置文件中 HTTP target 的 expect 包含 status、headers、body 规则数组及内部方法 +- **THEN** 系统 SHALL 正确解析并存储为 HTTP target 的 expect 字段 -#### Scenario: 解析仅含 body.contains 的最简配置 -- **WHEN** YAML 中 target 配置 `expect.body.contains: "healthy"` -- **THEN** 系统 SHALL 正确解析,功能等价于旧版 `expect.bodyContains` +#### Scenario: 解析 command expect 配置 +- **WHEN** YAML 配置文件中 command target 的 expect 包含 exitCode、stdout 和 stderr 规则数组 +- **THEN** 系统 SHALL 正确解析并存储为 command target 的 expect 字段 + +#### Scenario: 解析 body 有序规则数组 +- **WHEN** YAML 中 HTTP target 配置 `expect.body` 为 contains、json、regex 三个数组项 +- **THEN** 系统 SHALL 保留数组顺序,供执行阶段按配置顺序快速失败 + +#### Scenario: 不配置 HTTP status +- **WHEN** HTTP target 未配置 `expect.status` +- **THEN** 系统 SHALL 在执行 expect 时使用默认 `status: [200]` 语义 + +#### Scenario: 不配置 command exitCode +- **WHEN** command target 未配置 `expect.exitCode` +- **THEN** 系统 SHALL 在执行 expect 时使用默认 `exitCode: [0]` 语义 #### Scenario: 不配置 expect - **WHEN** target 未配置任何 expect 规则 - **THEN** 系统 SHALL 正常处理,expect 字段为 undefined - -#### Scenario: 旧版 bodyContains 字段不再支持 -- **WHEN** YAML 中使用 `expect.bodyContains: "xxx"` 格式 -- **THEN** 该字段 SHALL 被忽略(系统仅识别 `expect.body.contains`) -- **Migration**: 将配置文件中 `expect.bodyContains: "xxx"` 改为 `expect.body.contains: "xxx"` diff --git a/openspec/specs/probe-dashboard/spec.md b/openspec/specs/probe-dashboard/spec.md index 2e53459..c963d86 100644 --- a/openspec/specs/probe-dashboard/spec.md +++ b/openspec/specs/probe-dashboard/spec.md @@ -5,22 +5,22 @@ ## Requirements ### Requirement: 总览统计卡片 -Dashboard SHALL 在页面顶部展示总览统计卡片,包含总目标数、正常数、异常数和平均延迟。 +Dashboard SHALL 在页面顶部展示总览统计卡片,包含总目标数、正常数、异常数和平均耗时。 #### Scenario: 展示统计卡片 - **WHEN** 用户打开 Dashboard 页面 -- **THEN** 页面顶部 SHALL 显示 4 个统计卡片:全部目标数、正常目标数、异常目标数、所有目标平均延迟 +- **THEN** 页面顶部 SHALL 显示 4 个统计卡片:全部目标数、正常目标数、异常目标数、所有目标平均耗时 #### Scenario: 统计数据自动刷新 - **WHEN** 页面处于打开状态 - **THEN** 统计卡片 SHALL 每 5-10 秒自动刷新数据 ### Requirement: 目标列表表格 -Dashboard SHALL 展示所有拨测目标的列表表格,包含名称、URL、当前状态、最新延迟和迷你趋势线。 +Dashboard SHALL 展示所有 checker target 的列表表格,包含名称、类型、目标摘要、当前状态、最新耗时、最近失败原因和迷你趋势线。 #### Scenario: 展示目标列表 - **WHEN** 用户打开 Dashboard 页面 -- **THEN** 页面 SHALL 显示表格,每行包含目标名称、URL、状态指示圆点(● UP / ● DOWN)、最新延迟值、迷你 Sparkline 趋势线 +- **THEN** 页面 SHALL 显示表格,每行包含目标名称、类型、目标摘要、状态指示圆点(UP / DOWN)、最新耗时值、最近失败原因摘要、迷你 Sparkline 趋势线 #### Scenario: 状态指示圆点 - **WHEN** 目标最近一次拨测 success=true 且 matched=true @@ -33,7 +33,7 @@ Dashboard SHALL 支持在目标列表中展开某行,显示该目标的详细 #### Scenario: 展开目标详情 - **WHEN** 用户点击目标列表中的某一行 -- **THEN** 该行下方 SHALL 展开详情面板,包含:可用率百分比、平均延迟、P99 延迟、24 小时延迟趋势折线图、最近 5-10 条拨测记录列表 +- **THEN** 该行下方 SHALL 展开详情面板,包含:可用率百分比、平均耗时、P99 耗时、24 小时耗时趋势折线图、最近 5-10 条检查记录列表、领域状态详情和失败信息 #### Scenario: 收起目标详情 - **WHEN** 用户再次点击已展开的目标行 @@ -44,22 +44,33 @@ Dashboard SHALL 支持在目标列表中展开某行,显示该目标的详细 - **THEN** 系统 SHALL 此时请求该目标的趋势数据,而非页面加载时预加载所有目标的趋势数据 ### Requirement: 历史记录展示 -Dashboard SHALL 在目标详情面板中展示最近的拨测记录,包含时间、状态码、延迟和成功/失败标记。 +Dashboard SHALL 在目标详情面板中展示最近的检查记录,包含时间、领域状态详情、耗时、成功/失败标记和失败信息。 #### Scenario: 展示历史记录 - **WHEN** 用户展开目标详情面板 -- **THEN** 面板 SHALL 显示最近拨测记录列表,每条包含时间戳、HTTP 状态码(或错误信息)、延迟毫秒数、成功/失败图标 +- **THEN** 面板 SHALL 显示最近检查记录列表,每条包含时间戳、statusDetail(如 HTTP 200 或 exitCode=1)、耗时毫秒数、UP/DOWN 标记和 failure.message(如存在) ### Requirement: 趋势图可视化 Dashboard SHALL 使用 recharts 库渲染趋势图,包括目标列表中的迷你 Sparkline 和详情面板中的完整折线图。 #### Scenario: 表格行内迷你趋势线 - **WHEN** 目标列表表格渲染 -- **THEN** 每行 SHALL 包含一个基于 recharts 的迷你折线图,展示最近的延迟趋势 +- **THEN** 每行 SHALL 包含一个基于 recharts 的迷你折线图,展示最近的耗时趋势 #### Scenario: 详情面板完整趋势图 - **WHEN** 用户展开目标详情面板 -- **THEN** 面板 SHALL 展示基于 recharts 的完整折线图,X 轴为时间(小时),Y 轴为平均延迟,并标注可用率 +- **THEN** 面板 SHALL 展示基于 recharts 的完整折线图,X 轴为时间(小时),Y 轴为平均耗时,并标注可用率 + +### Requirement: checker 类型展示 +Dashboard SHALL 在列表和详情中明确展示 target 的 checker 类型。 + +#### Scenario: 展示 HTTP 类型 +- **WHEN** 目标 type 为 `http` +- **THEN** Dashboard SHALL 在类型列显示 HTTP,并将目标摘要显示为 URL + +#### Scenario: 展示 command 类型 +- **WHEN** 目标 type 为 `command` +- **THEN** Dashboard SHALL 在类型列显示 Command,并将目标摘要显示为命令摘要 ### Requirement: 页面加载与错误状态 Dashboard SHALL 正确处理加载状态和 API 错误。 diff --git a/openspec/specs/probe-data-store/spec.md b/openspec/specs/probe-data-store/spec.md index c77ba0c..b69fc8b 100644 --- a/openspec/specs/probe-data-store/spec.md +++ b/openspec/specs/probe-data-store/spec.md @@ -5,11 +5,11 @@ ## Requirements ### Requirement: SQLite 数据库初始化 -系统 SHALL 使用 Bun 内置 `bun:sqlite` 模块在配置的数据目录下创建 SQLite 数据库文件,并以 WAL 模式运行。 +系统 SHALL 使用 Bun 内置 `bun:sqlite` 模块在配置的数据目录下创建 SQLite 数据库文件,并以 WAL 模式运行。数据库 schema MUST 支持 typed checker target 和结构化检查结果。 #### Scenario: 首次启动创建数据库 - **WHEN** 指定的数据目录下不存在数据库文件 -- **THEN** 系统 SHALL 创建数据库文件并初始化 targets 和 check_results 表 +- **THEN** 系统 SHALL 创建数据库文件并初始化包含 type、target、config、duration_ms、status_detail、failure 等字段的 targets 和 check_results 表 #### Scenario: 数据目录不存在 - **WHEN** 配置的数据目录路径不存在 @@ -20,22 +20,26 @@ - **THEN** 系统 SHALL 直接打开数据库,不重新建表 ### Requirement: targets 表同步 -系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表。 +系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示摘要、领域配置、调度配置和 expect 配置。 #### Scenario: 首次同步目标 -- **WHEN** 数据库为空且 YAML 中定义了 N 个目标 -- **THEN** 系统 SHALL 将所有目标插入 targets 表 +- **WHEN** 数据库为空且 YAML 中定义了 N 个 typed target +- **THEN** 系统 SHALL 将所有目标插入 targets 表,包含 name、type、target、config、interval_ms、timeout_ms 和 expect #### Scenario: 配置变更后重新同步 - **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启 - **THEN** 系统 SHALL 根据 name 字段匹配:新增的插入、删除的移除、修改的更新 ### Requirement: check_results 表追加写入 -系统 SHALL 将每次拨测结果追加写入 check_results 表,不更新或删除已有记录。 +系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。 -#### Scenario: 写入拨测结果 -- **WHEN** 一次拨测完成 -- **THEN** 系统 SHALL 插入一条包含 target_id、timestamp、success、status_code、latency_ms、error、matched 的记录 +#### Scenario: 写入检查结果 +- **WHEN** 一次 checker 执行完成 +- **THEN** 系统 SHALL 插入一条包含 target_id、timestamp、success、matched、duration_ms、status_detail、failure 的记录 + +#### Scenario: 写入结构化失败信息 +- **WHEN** checker 执行失败或 expect 不匹配 +- **THEN** 系统 SHALL 将首个失败原因序列化写入 failure 字段 ### Requirement: 时间范围查询索引 系统 SHALL 在 check_results 表上创建 (target_id, timestamp) 复合索引,加速按目标和时间范围的查询。 @@ -45,16 +49,35 @@ - **THEN** 系统 SHALL 使用索引快速定位,无需全表扫描 ### Requirement: 聚合查询支持 -数据存储 SHALL 支持按时间段聚合查询,用于计算可用率、平均延迟、P99 延迟等统计指标。 +数据存储 SHALL 支持按时间段聚合查询,用于计算可用率、平均耗时、P99 耗时等统计指标。 #### Scenario: 计算目标可用率 - **WHEN** 查询某目标在指定时间范围内的可用率 - **THEN** 系统 SHALL 返回 UP (success=true AND matched=true) 的记录数占总记录数的百分比 -#### Scenario: 计算目标平均延迟 -- **WHEN** 查询某目标在指定时间范围内的平均延迟 -- **THEN** 系统 SHALL 返回 latency_ms 的平均值(仅计算 success=true 的记录) +#### Scenario: 计算目标平均耗时 +- **WHEN** 查询某目标在指定时间范围内的平均耗时 +- **THEN** 系统 SHALL 返回 duration_ms 的平均值(仅计算 success=true 的记录) #### Scenario: 按小时聚合趋势数据 - **WHEN** 查询某目标在指定时间范围内的趋势数据 -- **THEN** 系统 SHALL 返回按小时分组的聚合数据,包括每小时的平均延迟和可用率 +- **THEN** 系统 SHALL 返回按小时分组的聚合数据,包括每小时的平均耗时和可用率 + +### Requirement: 目标展示摘要持久化 +数据存储 SHALL 为每个 target 持久化一个领域无关的展示摘要字段 `target`。 + +#### Scenario: HTTP target 展示摘要 +- **WHEN** 同步 HTTP target +- **THEN** targets.target SHALL 存储该 target 的 URL + +#### Scenario: command target 展示摘要 +- **WHEN** 同步 command target +- **THEN** targets.target SHALL 存储由 exec 和 args 组成的命令摘要 + +#### Scenario: HTTP target config 序列化 +- **WHEN** 同步 HTTP target +- **THEN** targets.config SHALL 存储 JSON,包含 url、method、headers、body、maxBodyBytes + +#### Scenario: command target config 序列化 +- **WHEN** 同步 command target +- **THEN** targets.config SHALL 存储 JSON,包含 exec、args、cwd、env、maxOutputBytes diff --git a/openspec/specs/probe-engine/spec.md b/openspec/specs/probe-engine/spec.md index bab53db..cf9ff58 100644 --- a/openspec/specs/probe-engine/spec.md +++ b/openspec/specs/probe-engine/spec.md @@ -16,18 +16,22 @@ - **THEN** 系统 SHALL 创建两个独立定时器,分别按各自频率调度 ### Requirement: 组内并发拨测 -系统 SHALL 在每次调度 tick 时,使用 `Promise.all` 并发执行同组内所有目标的拨测。 +系统 SHALL 在每次调度 tick 时并发执行同组内目标的检查,但实际同时运行的检查数 MUST 受全局 `runtime.maxConcurrentChecks` 限制。 #### Scenario: 同组目标并发执行 -- **WHEN** 调度器触发一次 tick,该组有 3 个目标 -- **THEN** 系统 SHALL 同时发起 3 个 HTTP 请求,而非顺序执行 +- **WHEN** 调度器触发一次 tick,该组有 3 个目标,且全局并发余量至少为 3 +- **THEN** 系统 SHALL 同时执行 3 个 checker,而非顺序执行 #### Scenario: 单个目标失败不影响同组其他目标 -- **WHEN** 同组中某个目标的拨测请求超时或失败 -- **THEN** 其他目标的拨测 SHALL 正常完成并记录结果 +- **WHEN** 同组中某个目标的检查请求超时或失败 +- **THEN** 其他目标的检查 SHALL 正常完成并记录结果 + +#### Scenario: 全局并发限制生效 +- **WHEN** 调度器同时触发 10 个目标且 runtime.maxConcurrentChecks 为 3 +- **THEN** 系统 MUST 同时最多运行 3 个检查,其余检查等待并发槽位释放 ### Requirement: HTTP 拨测执行 -系统 SHALL 对每个目标执行 HTTP 请求,支持 GET、POST、PUT、DELETE、PATCH、HEAD 方法,并携带配置的 headers 和 body。 +系统 SHALL 对 `type: http` 的目标执行 HTTP 请求,支持 GET、POST、PUT、DELETE、PATCH、HEAD 方法,并携带 `http.headers` 和 `http.body`。 #### Scenario: 执行 GET 请求 - **WHEN** 目标配置 method 为 GET @@ -41,82 +45,97 @@ - **WHEN** 目标配置了 headers(如 Authorization) - **THEN** 系统 SHALL 在请求中包含所有配置的 headers -### Requirement: 请求超时控制 -系统 SHALL 对每次拨测请求实施超时控制,超时时间使用目标配置的 timeout 值。 +#### Scenario: HTTP body 读取上限 +- **WHEN** HTTP response body 超过该 target 的 maxBodyBytes +- **THEN** 系统 MUST 停止读取并记录 `success=false`、`matched=false` 和结构化输出超限错误 -#### Scenario: 请求超时 -- **WHEN** 拨测请求在 timeout 时间内未收到响应 +### Requirement: 请求超时控制 +系统 SHALL 对每次 checker 执行实施超时控制,超时时间使用目标配置的 timeout 值。 + +#### Scenario: HTTP 请求超时 +- **WHEN** HTTP 请求在 timeout 时间内未收到响应 - **THEN** 系统 SHALL 中止该请求,记录为失败并标注超时错误 +#### Scenario: command 执行超时 +- **WHEN** command 进程在 timeout 时间内未退出 +- **THEN** 系统 MUST 终止该子进程,记录为失败并标注超时错误 + #### Scenario: 请求在超时前完成 -- **WHEN** 拨测请求在 timeout 时间内收到响应 -- **THEN** 系统 SHALL 正常记录响应结果 +- **WHEN** checker 在超时前完成执行 +- **THEN** 系统 SHALL 正常记录执行结果并进入 expect 校验 ### Requirement: expect 校验 -系统 SHALL 在拨测完成后根据目标的 expect 配置校验响应,校验结果记入 check result。 +系统 SHALL 在 checker 执行完成后根据目标类型的 expect 配置校验观测结果,校验结果和首个失败原因记入 check result。 -#### Scenario: 校验状态码 -- **WHEN** 目标配置了 `expect.status: [200, 201]` +#### Scenario: HTTP 默认状态码 +- **WHEN** HTTP target 未配置 `expect.status` +- **THEN** 系统 SHALL 按默认 `status: [200]` 校验响应状态码 + +#### Scenario: 校验 HTTP 状态码 +- **WHEN** HTTP target 配置了 `expect.status: [200, 201]` - **THEN** 系统 SHALL 检查响应状态码是否在列表中,将匹配结果记录到 matched 字段 -#### Scenario: 校验响应头 -- **WHEN** 目标配置了 `expect.headers: {"Content-Type": "application/json"}` -- **THEN** 系统 SHALL 检查响应头是否包含指定键值对,全部匹配时将 matched 设为 true +#### Scenario: 校验 HTTP 响应头 +- **WHEN** HTTP target 配置了 `expect.headers: {"Content-Type": {contains: "application/json"}}` +- **THEN** 系统 SHALL 检查响应头是否符合指定规则,全部匹配时继续后续阶段 -#### Scenario: 校验响应体包含 -- **WHEN** 目标配置了 `expect.body.contains: "healthy"` -- **THEN** 系统 SHALL 检查响应体是否包含该文本,将匹配结果记录到 matched 字段 +#### Scenario: 校验 HTTP 响应体 +- **WHEN** HTTP target 配置了有序 `expect.body` 规则数组 +- **THEN** 系统 SHALL 按数组顺序执行 body 规则,任一失败立即记录 failure 并停止后续规则 -#### Scenario: 校验响应体正则 -- **WHEN** 目标配置了 `expect.body.regex: '"status"\\s*:\\s*"ok"'` -- **THEN** 系统 SHALL 检查响应体是否匹配该正则,将匹配结果记录到 matched 字段 +#### Scenario: command 默认 exitCode +- **WHEN** command target 未配置 `expect.exitCode` +- **THEN** 系统 SHALL 按默认 `exitCode: [0]` 校验命令退出码 -#### Scenario: 校验 JSON 响应 -- **WHEN** 目标配置了 `expect.body.json: {"$.status": "ok"}` -- **THEN** 系统 SHALL 解析 JSON 并检查 JSONPath 对应值是否符合期望,将匹配结果记录到 matched 字段 +#### Scenario: 校验 command stdout +- **WHEN** command target 配置了有序 `expect.stdout` 规则数组 +- **THEN** 系统 SHALL 按数组顺序执行 stdout 规则,任一失败立即记录 failure 并停止后续规则 -#### Scenario: 校验 HTML 响应(CSS 选择器) -- **WHEN** 目标配置了 `expect.body.css: {"div#health": "OK"}` -- **THEN** 系统 SHALL 解析 HTML 并用 CSS 选择器提取元素文本进行比较,将匹配结果记录到 matched 字段 - -#### Scenario: 校验 HTML/XML 响应(XPath) -- **WHEN** 目标配置了 `expect.body.xpath: {"/root/status/text()": "ok"}` -- **THEN** 系统 SHALL 解析文档并用 XPath 提取节点文本进行比较,将匹配结果记录到 matched 字段 - -#### Scenario: 校验延迟阈值 -- **WHEN** 目标配置了 `expect.maxLatencyMs: 3000` -- **THEN** 系统 SHALL 检查实际延迟是否超过阈值,将匹配结果记录到 matched 字段 - -#### Scenario: 无 expect 配置 -- **WHEN** 目标未配置任何 expect 规则 -- **THEN** 系统 SHALL 将 matched 字段设为 true +#### Scenario: 校验耗时阈值 +- **WHEN** 目标配置了 `expect.maxDurationMs` +- **THEN** 系统 SHALL 检查实际 durationMs 是否超过阈值,将匹配结果记录到 matched 字段 #### Scenario: 多条 expect 规则 -- **WHEN** 目标同时配置了 status、headers、body.contains、body.json 和 maxLatencyMs -- **THEN** 系统 SHALL 所有规则全部通过时 matched 为 true,任一不通过则为 false - -#### Scenario: 多种 body 方法 AND 组合 -- **WHEN** 目标在 body 分组下配置了 contains、json、css 多种方法 -- **THEN** 系统 SHALL 按 contains → regex → json → css → xpath 顺序执行,任一失败立即返回 false +- **WHEN** 目标同时配置状态、duration、元数据和内容规则 +- **THEN** 系统 SHALL 所有规则全部通过时 matched 为 true,任一不通过则为 false 并记录首个失败原因 ### Requirement: Body 校验按需解析 -系统 SHALL 仅在配置了对应 body 校验方法时才解析响应体为对应格式,避免不必要的解析开销。 +系统 SHALL 仅在 HTTP target 配置了 body 校验且 status、duration、headers 阶段均通过时才读取并解析响应体,避免不必要的读取和解析开销。 + +#### Scenario: status 失败时不读取 body +- **WHEN** HTTP target 的 status 阶段不匹配 +- **THEN** 系统 SHALL 立即返回 matched=false,且 MUST NOT 读取 response body #### Scenario: 仅配置 contains 时不解析 JSON -- **WHEN** 目标仅配置 `expect.body.contains` 而未配置 json/css/xpath +- **WHEN** HTTP target 仅配置 body contains 规则而未配置 json/css/xpath 规则 - **THEN** 系统 SHALL 不执行 JSON.parse 或 HTML/XML 解析 #### Scenario: 配置 json 时解析 JSON 失败 -- **WHEN** 目标配置了 `expect.body.json` 但响应体不是合法 JSON -- **THEN** 系统 SHALL 判定 matched 为 false +- **WHEN** HTTP target 配置了 body json 规则但响应体不是合法 JSON +- **THEN** 系统 SHALL 判定 matched 为 false,并记录 json 规则对应的 failure.path ### Requirement: 拨测结果记录 -系统 SHALL 在每次拨测完成后,将结果写入 SQLite 数据存储,包含 target_id、timestamp、success、status_code、latency_ms、error、matched 字段。 +系统 SHALL 在每次 checker 完成后,将结果写入 SQLite 数据存储,包含 target_id、timestamp、success、matched、duration_ms、status_detail、failure 字段。 -#### Scenario: 成功拨测结果记录 -- **WHEN** 拨测请求成功完成(收到 HTTP 响应) -- **THEN** 系统 SHALL 记录 success=true、status_code、latency_ms、matched +#### Scenario: 成功检查结果记录 +- **WHEN** checker 成功执行且 expect 全部匹配 +- **THEN** 系统 SHALL 记录 success=true、matched=true、duration_ms、status_detail,failure 为 null -#### Scenario: 失败拨测结果记录 -- **WHEN** 拨测请求失败(网络错误、超时等) -- **THEN** 系统 SHALL 记录 success=false、error 信息,status_code 和 latency_ms 为 null +#### Scenario: 执行失败结果记录 +- **WHEN** checker 执行失败(网络错误、超时、命令启动失败、输出超限等) +- **THEN** 系统 SHALL 记录 success=false、matched=false、failure.kind="error" 和具体错误信息 + +#### Scenario: expect 不匹配结果记录 +- **WHEN** checker 执行成功但 expect 不匹配 +- **THEN** 系统 SHALL 记录 success=true、matched=false、failure.kind="mismatch" 和具体不匹配信息 + +### Requirement: runner 选择 +系统 SHALL 根据 target.type 选择对应 runner 执行检查。 + +#### Scenario: 选择 HTTP runner +- **WHEN** target.type 为 `http` +- **THEN** 系统 SHALL 使用 HTTP runner 执行该目标 + +#### Scenario: 选择 command runner +- **WHEN** target.type 为 `command` +- **THEN** 系统 SHALL 使用 command runner 执行该目标 diff --git a/probes.example.yaml b/probes.example.yaml index c24d393..f784474 100644 --- a/probes.example.yaml +++ b/probes.example.yaml @@ -3,34 +3,207 @@ server: port: 3000 dataDir: "/tmp/probes_data" +runtime: + maxConcurrentChecks: 20 + defaults: - interval: "5s" + interval: "30s" timeout: "10s" - method: "GET" + http: + method: GET + maxBodyBytes: "10MB" + command: + maxOutputBytes: "1MB" targets: - - name: "Baidu" - url: "https://www.baidu.com" - expect: - status: [200] - maxLatencyMs: 10000 + # ========== HTTP targets ========== - - name: "JSON API 示例" - url: "https://httpbin.org/json" + - name: "Baidu 首页可用" + type: http + http: + url: "https://www.baidu.com" expect: status: [200] + maxDurationMs: 5000 + + - name: "JSON API — 完整流水线" + type: http + interval: "1m" + timeout: "15s" + http: + url: "https://httpbin.org/json" headers: - Content-Type: application/json + Accept: "application/json" + expect: + headers: + Content-Type: + contains: "application/json" + maxDurationMs: 8000 body: - contains: "slideshow" - json: - $.slideshow.title: "Sample Slide Show" + - json: + path: "$.slideshow.title" + equals: "Sample Slide Show" + - json: + path: "$.slideshow.slides[0].title" + contains: "Wake" + - json: + path: "$.slideshow.slides[0].type" + equals: "all" + - regex: '"title"' - - name: "HTML 页面示例" - url: "https://httpbin.org/html" + - name: "HTML 页面 — CSS 选择器" + type: http + http: + url: "https://httpbin.org/html" + expect: + body: + - css: + selector: "h1" + contains: "Moby-Dick" + - css: + selector: "body" + exists: true + + - name: "HTML 页面 — XPath 提取节点文本" + type: http + http: + url: "https://httpbin.org/html" + expect: + body: + - xpath: + path: "/html/body/h1/text()" + contains: "Melville" + + - name: "POST 接口测试" + type: http + http: + url: "https://httpbin.org/post" + method: POST + headers: + Content-Type: "application/json" + body: '{"action":"check","version":1}' expect: status: [200] body: - contains: "Moby-Dick" - xpath: - "/html/body/h1/text()": "Herman Melville - Moby-Dick" + - json: + path: "$.json.action" + equals: "check" + - json: + path: "$.json.version" + gte: 1 + + - name: "请求头验证" + type: http + http: + url: "https://httpbin.org/headers" + headers: + X-Custom-Header: "gateway-checker" + expect: + status: [200] + body: + - json: + path: "$.headers.X-Custom-Header" + equals: "gateway-checker" + + - name: "响应头自定义校验" + type: http + http: + url: "https://httpbin.org/response-headers" + headers: + accept: "application/json" + expect: + body: + - json: + path: "$.Content-Type" + equals: "application/json" + + - name: "多状态码允许" + type: http + http: + url: "https://httpbin.org/status/200" + expect: + status: [200, 201, 204] + + # ========== Command targets ========== + + - name: "uname 输出匹配" + type: command + command: + exec: "uname" + args: ["-s"] + expect: + exitCode: [0] + stdout: + - match: "^[A-Z][a-z]+$" + + - name: "echo 自定义文本输出" + type: command + command: + exec: "echo" + args: ["check ok"] + expect: + stdout: + - equals: "check ok\n" + maxDurationMs: 3000 + + - name: "ls 目录无 stderr" + type: command + command: + exec: "ls" + args: ["/tmp"] + cwd: "/" + expect: + exitCode: [0] + stderr: + - empty: true + + - name: "date 输出包含年份" + type: command + command: + exec: "date" + args: ["+%Y"] + expect: + stdout: + - match: "^20\\d{2}\n?$" + + - name: "wc 行数计数" + type: command + command: + exec: "wc" + args: ["-l"] + cwd: "/etc" + env: + LANG: "C" + expect: + stdout: + - match: "\\d+" + + - name: "hostname 非空输出" + type: command + command: + exec: "hostname" + expect: + stdout: + - match: ".+" + + - name: "多规则 stdout 顺序校验" + type: command + interval: "5m" + command: + exec: "echo" + args: ["version: 2.0.1, status: healthy"] + expect: + stdout: + - contains: "version:" + - match: "\\d+\\.\\d+\\.\\d+" + - contains: "healthy" + + - name: "stderr 内容检查" + type: command + command: + exec: "ls" + args: ["/nonexistent-path-checker-test"] + expect: + exitCode: [0, 1, 2] + stderr: + - contains: "No such file" diff --git a/scripts/clean.ts b/scripts/clean.ts index 87f493a..b13968a 100644 --- a/scripts/clean.ts +++ b/scripts/clean.ts @@ -1,4 +1,4 @@ -import { readdir, rm } from "node:fs/promises"; +import { rm } from "node:fs/promises"; import { resolve } from "node:path"; const root = resolve(import.meta.dir, ".."); @@ -9,7 +9,7 @@ const patterns: Array<{ glob: string; desc: string }> = [ ]; for (const { glob, desc } of patterns) { - const entries = await Array.fromAsync(new Bun.Glob(glob).scan({ root, dot: true })); + const entries = await Array.fromAsync(new Bun.Glob(glob).scan({ cwd: root, dot: true })); if (entries.length === 0) continue; for (const entry of entries) { const full = resolve(root, entry); diff --git a/scripts/smoke.ts b/scripts/smoke.ts index 8c3f7da..54acb21 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -16,7 +16,9 @@ writeFileSync( configPath, `targets: - name: "httpbin" - url: "https://httpbin.org/get" + type: http + http: + url: "https://httpbin.org/get" interval: "5m" timeout: "15s" expect: diff --git a/src/server/app.ts b/src/server/app.ts index a4afe6b..11962b6 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -1,5 +1,6 @@ import type { ApiErrorResponse, + CheckFailure, CheckResult, HealthResponse, RuntimeMode, @@ -136,7 +137,7 @@ function handleTrend(idStr: string, url: URL, method: string, store: ProbeStore, const trend: TrendPoint[] = store.getTrend(id, hours).map((row) => ({ hour: row.hour, - avgLatencyMs: row.avgLatencyMs, + avgDurationMs: row.avgDurationMs, availability: Math.round(row.availability * 100) / 100, totalChecks: row.totalChecks, })); @@ -150,7 +151,7 @@ function createSummaryResponse(store: ProbeStore): SummaryResponse { total: summary.total, up: summary.up, down: summary.down, - avgLatencyMs: summary.avgLatencyMs, + avgDurationMs: summary.avgDurationMs, lastCheckTime: summary.lastCheckTime, }; } @@ -165,29 +166,34 @@ function createTargetsResponse(store: ProbeStore): TargetStatus[] { return { id: target.id, name: target.name, - url: target.url, - method: target.method, + type: target.type, + target: target.target, interval: formatDuration(target.interval_ms), latestCheck: latest ? mapCheckResult(latest) : null, sparkline: store.getSparkline(target.id), stats: { totalChecks: stats.totalChecks, availability: stats.availability, - avgLatencyMs: stats.avgLatencyMs, - p99LatencyMs: stats.p99LatencyMs, + avgDurationMs: stats.avgDurationMs, + p99DurationMs: stats.p99DurationMs, }, }; }); } function mapCheckResult(row: StoredCheckResult): CheckResult { + let failure: CheckFailure | null = null; + if (row.failure) { + failure = JSON.parse(row.failure) as CheckFailure; + } + return { timestamp: row.timestamp, success: row.success === 1, - statusCode: row.status_code, - latencyMs: row.latency_ms, - error: row.error, matched: row.matched === 1, + durationMs: row.duration_ms, + statusDetail: row.status_detail, + failure, }; } diff --git a/src/server/checker/body-expect.ts b/src/server/checker/body-expect.ts deleted file mode 100644 index 1477b1d..0000000 --- a/src/server/checker/body-expect.ts +++ /dev/null @@ -1,188 +0,0 @@ -import type { BodyExpectConfig, CssExpect, ExpectOperator, ExpectValue } from "./types"; -import * as cheerio from "cheerio"; -import * as xpath from "xpath"; -import { DOMParser } from "@xmldom/xmldom"; - -const isObject = (v: unknown): v is Record => v !== null && typeof v === "object" && !Array.isArray(v); - -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 (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 = - actual === null || - actual === undefined || - actual === "" || - (Array.isArray(actual) && actual.length === 0) || - (typeof actual === "object" && Object.keys(actual as object).length === 0); - 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; -} - -function checkExpectValue(actual: unknown, expected: ExpectValue): boolean { - if (isObject(expected)) { - return applyOperator(actual, expected as ExpectOperator); - } - return applyOperator(actual, { equals: expected as string | number | boolean | null }); -} - -function checkBodyContains(body: string, contains: string): boolean { - return body.includes(contains); -} - -function checkBodyRegex(body: string, regex: string): boolean { - return new RegExp(regex).test(body); -} - -function checkBodyJson(body: string, rules: Record): boolean { - let json: unknown; - try { - json = JSON.parse(body); - } catch { - return false; - } - - for (const [path, expected] of Object.entries(rules)) { - const actual = evaluateJsonPath(json, path); - if (!checkExpectValue(actual, expected)) return false; - } - - return true; -} - -function checkBodyCss(body: string, rules: Record): boolean { - let $: cheerio.CheerioAPI; - try { - $ = cheerio.load(body); - } catch { - return false; - } - - for (const [selector, expected] of Object.entries(rules)) { - if (!checkCssRule($, selector, expected)) return false; - } - - return true; -} - -function checkCssRule($: cheerio.CheerioAPI, selector: string, expected: CssExpect): boolean { - if (!isObject(expected)) { - const el = $(selector); - return el.length > 0 && el.text() === String(expected); - } - - const rule = expected as ExpectOperator & { attr?: string }; - const { attr, ...operators } = rule; - const opKeys = Object.keys(operators); - - if (opKeys.length === 0) { - if (attr !== undefined) { - return $(selector).attr(attr) !== undefined; - } - return $(selector).length > 0; - } - - if (operators.exists === true) { - return $(selector).length > 0; - } - if (operators.exists === false) { - return $(selector).length === 0; - } - - const el = $(selector); - if (el.length === 0) return false; - - const actual = attr ? el.attr(attr) : el.text(); - return applyOperator(actual ?? "", operators); -} - -function checkBodyXpath(body: string, rules: Record): boolean { - let doc: ReturnType; - try { - doc = new DOMParser().parseFromString(body, "text/xml"); - } catch { - return false; - } - - for (const [path, expected] of Object.entries(rules)) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const nodes = xpath.select(path, doc as any); - if (!nodes || !Array.isArray(nodes) || nodes.length === 0) return false; - - const node = nodes[0]!; - const actual = node.nodeValue ?? (node as unknown as Element).textContent ?? ""; - - if (!checkExpectValue(actual, expected)) return false; - } - - return true; -} - -export function checkBodyExpect(body: string, config?: BodyExpectConfig): boolean { - if (!config) return true; - - if (config.contains !== undefined && !checkBodyContains(body, config.contains)) return false; - if (config.regex !== undefined && !checkBodyRegex(body, config.regex)) return false; - if (config.json !== undefined && !checkBodyJson(body, config.json)) return false; - if (config.css !== undefined && !checkBodyCss(body, config.css)) return false; - if (config.xpath !== undefined && !checkBodyXpath(body, config.xpath)) return false; - - return true; -} diff --git a/src/server/checker/command-runner.ts b/src/server/checker/command-runner.ts new file mode 100644 index 0000000..a680504 --- /dev/null +++ b/src/server/checker/command-runner.ts @@ -0,0 +1,155 @@ +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, + stdout: "pipe", + stderr: "pipe", + }); + } catch (error) { + const durationMs = Math.round(performance.now() - start); + return { + targetName: target.name, + timestamp, + success: false, + matched: false, + durationMs, + statusDetail: null, + failure: errorFailure("exitCode", "spawn", error instanceof 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, + success: false, + 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, + success: false, + matched: false, + durationMs, + statusDetail: `exitCode=${exitCode}`, + failure: errorFailure("exitCode", "output", `输出超过限制 ${target.command.maxOutputBytes} 字节`), + }; + } + + if (timedOut) { + return { + targetName: target.name, + timestamp, + success: false, + 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, + success: expectResult.matched, + 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 f0938f8..f814e0e 100644 --- a/src/server/checker/config-loader.ts +++ b/src/server/checker/config-loader.ts @@ -1,16 +1,39 @@ -import type { ProbeConfig, ResolvedTarget } from "./types"; +import type { + CommandDefaultsConfig, + CommandTargetConfig, + DefaultsConfig, + HttpDefaultsConfig, + HttpExpectConfig, + HttpTargetConfig, + ProbeConfig, + ResolvedCommandTarget, + ResolvedHttpTarget, + ResolvedTarget, + RuntimeConfig, + TargetConfig, + TargetType, +} from "./types"; +import { parseSize } from "./size"; +import { resolve } from "node:path"; +import { dirname } from "node:path"; 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_METHOD = "GET"; +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; port: number; dataDir: string; + configDir: string; + maxConcurrentChecks: number; targets: ResolvedTarget[]; } @@ -30,7 +53,9 @@ export async function loadConfig(configPath: string): Promise { validateConfig(raw); + const configDir = dirname(resolve(configPath)); const server = raw.server ?? {}; + const runtime = raw.runtime ?? {}; const defaults = raw.defaults ?? {}; const host = server.host ?? DEFAULT_HOST; @@ -41,23 +66,102 @@ export async function loadConfig(configPath: string): Promise { throw new Error(`无效端口号: ${port},需要 0-65535 之间的整数`); } + const maxConcurrentChecks = validateRuntime(runtime); const defaultIntervalMs = parseDuration(defaults.interval ?? DEFAULT_INTERVAL); const defaultTimeoutMs = parseDuration(defaults.timeout ?? DEFAULT_TIMEOUT); - const defaultMethod = defaults.method ?? DEFAULT_METHOD; - const defaultHeaders = defaults.headers ?? {}; - const targets: ResolvedTarget[] = raw.targets.map((target) => ({ + const targets: ResolvedTarget[] = raw.targets.map((target) => + resolveTarget(target, defaults, defaultIntervalMs, defaultTimeoutMs, configDir), + ); + + return { host, port, dataDir, configDir, maxConcurrentChecks, targets }; +} + +function validateRuntime(runtime: RuntimeConfig): number { + if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS; + + if ( + typeof runtime.maxConcurrentChecks !== "number" || + !Number.isInteger(runtime.maxConcurrentChecks) || + runtime.maxConcurrentChecks <= 0 + ) { + throw new Error("runtime.maxConcurrentChecks 必须为正整数"); + } + + return runtime.maxConcurrentChecks; +} + +function resolveTarget( + target: TargetConfig, + defaults: DefaultsConfig, + defaultIntervalMs: number, + defaultTimeoutMs: number, + configDir: string, +): ResolvedTarget { + const intervalMs = parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL); + const timeoutMs = parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT); + + if (target.type === "http") { + return resolveHttpTarget(target, defaults.http, intervalMs, timeoutMs); + } + + return resolveCommandTarget(target, defaults.command, intervalMs, timeoutMs, configDir); +} + +function resolveHttpTarget( + target: TargetConfig & { type: "http"; http: HttpTargetConfig }, + httpDefaults: HttpDefaultsConfig | undefined, + intervalMs: number, + timeoutMs: number, +): ResolvedHttpTarget { + const maxBodyBytes = parseSize(target.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES); + + return { + type: "http", name: target.name, - url: target.url, - method: target.method ?? defaultMethod, - headers: { ...defaultHeaders, ...(target.headers ?? {}) }, - body: target.body, - intervalMs: parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL), - timeoutMs: parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT), - expect: target.expect, - })); + 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, + }; +} - return { host, port, dataDir, targets }; +function resolveCommandTarget( + target: TargetConfig & { type: "command"; command: CommandTargetConfig }, + commandDefaults: CommandDefaultsConfig | undefined, + intervalMs: number, + timeoutMs: number, + configDir: 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, + command: { + exec: target.command.exec, + args: target.command.args ?? [], + cwd: resolvedCwd, + env, + maxOutputBytes, + }, + intervalMs, + timeoutMs, + expect: target.expect as import("./types").CommandExpectConfig | undefined, + }; } function validateConfig(config: ProbeConfig): void { @@ -68,21 +172,41 @@ function validateConfig(config: ProbeConfig): void { const names = new Set(); for (let i = 0; i < config.targets.length; i++) { - const target = config.targets[i]!; + const raw = config.targets[i] as unknown as Record; - if (!target.name || typeof target.name !== "string" || target.name.trim() === "") { + const name = raw["name"]; + if (!name || typeof name !== "string" || (name as string).trim() === "") { throw new Error(`第 ${i + 1} 个 target 缺少 name 字段`); } - if (!target.url || typeof target.url !== "string" || target.url.trim() === "") { - throw new Error(`target "${target.name}" 缺少 url 字段`); + const type = raw["type"]; + if (!type || typeof type !== "string") { + throw new Error(`target "${name}" 缺少 type 字段`); } - if (names.has(target.name)) { - throw new Error(`target name 重复: "${target.name}"`); + if (!SUPPORTED_TYPES.includes(type as TargetType)) { + throw new Error(`target "${name}" 使用不支持的 type: "${type}",支持: ${SUPPORTED_TYPES.join(", ")}`); } - names.add(target.name); + 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 (names.has(name as string)) { + throw new Error(`target name 重复: "${name}"`); + } + + names.add(name as string); } } @@ -90,7 +214,6 @@ const DURATION_REGEX = /^(\d+(?:\.\d+)?)(ms|s|m)$/; export function parseDuration(value: string): number { const match = DURATION_REGEX.exec(value); - if (!match) { throw new Error(`无效的时长格式: "${value}",支持格式如 "30s"、"5m"、"500ms"`); } diff --git a/src/server/checker/engine.ts b/src/server/checker/engine.ts index 75efd30..dd472fe 100644 --- a/src/server/checker/engine.ts +++ b/src/server/checker/engine.ts @@ -1,15 +1,21 @@ import type { CheckResult, ResolvedTarget } from "./types"; import type { ProbeStore } from "./store"; -import { fetchTarget } from "./fetcher"; +import { runHttpCheck } from "./fetcher"; +import { runCommandCheck } from "./command-runner"; export class ProbeEngine { private timers: ReturnType[] = []; private store: ProbeStore; + private targets: ResolvedTarget[]; private targetNameToId: Map = new Map(); + private maxConcurrentChecks: number; + private running = 0; + private queue: Array<() => void> = []; - constructor(store: ProbeStore, targets: ResolvedTarget[]) { + constructor(store: ProbeStore, targets: ResolvedTarget[], maxConcurrentChecks?: number) { this.store = store; this.targets = targets; + this.maxConcurrentChecks = maxConcurrentChecks ?? 10; this.refreshCache(); } @@ -46,8 +52,36 @@ export class ProbeEngine { return groups; } + private async acquire(): Promise { + if (this.running < this.maxConcurrentChecks) { + this.running++; + return; + } + return new Promise((resolve) => { + this.queue.push(resolve); + }); + } + + private release(): void { + const next = this.queue.shift(); + if (next) { + next(); + } else { + this.running--; + } + } + private async probeGroup(targets: ResolvedTarget[]): Promise { - const results = await Promise.allSettled(targets.map((t) => this.probeOne(t))); + const results = await Promise.allSettled( + targets.map(async (target) => { + await this.acquire(); + try { + return await this.runCheck(target); + } finally { + this.release(); + } + }), + ); for (const result of results) { if (result.status === "fulfilled") { @@ -56,23 +90,27 @@ export class ProbeEngine { } } - private async probeOne(target: ResolvedTarget): Promise { - return fetchTarget(target); + private async runCheck(target: ResolvedTarget): Promise { + switch (target.type) { + case "http": + return runHttpCheck(target); + case "command": + return runCommandCheck(target); + } } private writeResult(result: CheckResult): void { const targetId = this.targetNameToId.get(result.targetName); - if (!targetId) return; this.store.insertCheckResult({ targetId, timestamp: result.timestamp, success: result.success, - statusCode: result.statusCode, - latencyMs: result.latencyMs, - error: result.error, matched: result.matched, + durationMs: result.durationMs, + statusDetail: result.statusDetail, + failure: result.failure, }); } @@ -82,6 +120,4 @@ export class ProbeEngine { this.targetNameToId.set(target.name, target.id); } } - - private targets: ResolvedTarget[]; } diff --git a/src/server/checker/expect/body.ts b/src/server/checker/expect/body.ts new file mode 100644 index 0000000..25d240a --- /dev/null +++ b/src/server/checker/expect/body.ts @@ -0,0 +1,302 @@ +import type { BodyRule, CheckFailure, CssRule, ExpectOperator, ExpectValue, JsonRule, XpathRule } from "../types"; +import * as cheerio from "cheerio"; +import * as xpath from "xpath"; +import { DOMParser } from "@xmldom/xmldom"; +import { mismatchFailure, errorFailure } from "./failure"; + +const isObject = (v: unknown): v is Record => v !== null && typeof v === "object" && !Array.isArray(v); + +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 (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 = + actual === null || + actual === undefined || + actual === "" || + (Array.isArray(actual) && actual.length === 0) || + (typeof actual === "object" && Object.keys(actual as object).length === 0); + 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 (isObject(expected)) { + return applyOperator(actual, expected as ExpectOperator); + } + return applyOperator(actual, { equals: expected as string | number | boolean | null }); +} + +function checkJsonRule( + body: string, + rule: JsonRule, + rulePath: string, +): { matched: boolean; failure: CheckFailure | null } { + const { path, ...operators } = rule; + const fullPath = `${rulePath}.json(${path})`; + + let json: unknown; + try { + json = JSON.parse(body); + } catch { + return { + matched: false, + failure: errorFailure("body", fullPath, "body is not valid JSON"), + }; + } + + const actual = evaluateJsonPath(json, path); + const opKeys = Object.keys(operators); + + if (opKeys.length === 0) { + if (actual === undefined) { + return { + matched: false, + failure: mismatchFailure("body", fullPath, "defined", actual, `path ${path} is undefined`), + }; + } + return { matched: true, failure: null }; + } + + const matched = applyOperator(actual, operators); + if (!matched) { + return { + matched: false, + failure: mismatchFailure("body", fullPath, operators, actual, `json path ${path} mismatch`), + }; + } + return { matched: true, failure: null }; +} + +function checkCssRule( + body: string, + rule: CssRule, + rulePath: string, +): { matched: boolean; failure: CheckFailure | null } { + const { selector, attr, ...operators } = rule; + const fullPath = `${rulePath}.css(${selector}${attr ? `@${attr}` : ""})`; + + let $: cheerio.CheerioAPI; + try { + $ = cheerio.load(body); + } catch { + return { + matched: false, + failure: errorFailure("body", fullPath, "failed to parse HTML"), + }; + } + + const el = $(selector); + const opKeys = Object.keys(operators); + + if (opKeys.length === 0) { + if (attr !== undefined) { + if (el.attr(attr) === undefined) { + return { + matched: false, + failure: mismatchFailure("body", fullPath, "attr present", undefined, `attribute ${attr} not found`), + }; + } + return { matched: true, failure: null }; + } + if (el.length === 0) { + return { + matched: false, + failure: mismatchFailure("body", fullPath, "element found", "no match", `selector ${selector} not found`), + }; + } + return { matched: true, failure: null }; + } + + if (operators.exists === true) { + if (el.length === 0) { + return { + matched: false, + failure: mismatchFailure("body", fullPath, true, false, `selector ${selector} not found`), + }; + } + return { matched: true, failure: null }; + } + if (operators.exists === false) { + if (el.length > 0) { + return { + matched: false, + failure: mismatchFailure("body", fullPath, false, true, `selector ${selector} exists`), + }; + } + return { matched: true, failure: null }; + } + + if (el.length === 0) { + return { + matched: false, + failure: mismatchFailure("body", fullPath, "element found", "no match", `selector ${selector} not found`), + }; + } + + const actual = attr ? el.attr(attr) : el.text(); + const matched = applyOperator(actual ?? "", operators); + if (!matched) { + return { + matched: false, + failure: mismatchFailure("body", fullPath, operators, actual, `css selector ${selector} mismatch`), + }; + } + return { matched: true, failure: null }; +} + +function checkXpathRule( + body: string, + rule: XpathRule, + rulePath: string, +): { matched: boolean; failure: CheckFailure | null } { + const { path, ...operators } = rule; + const fullPath = `${rulePath}.xpath(${path})`; + + let doc: ReturnType; + try { + doc = new DOMParser().parseFromString(body, "text/xml"); + } catch { + return { + matched: false, + failure: errorFailure("body", fullPath, "failed to parse XML/HTML"), + }; + } + + const nodes = xpath.select(path, doc as unknown as Node); + if (!nodes || !Array.isArray(nodes) || nodes.length === 0) { + return { + matched: false, + failure: mismatchFailure("body", fullPath, "node found", "no match", `xpath ${path} not found`), + }; + } + + const node = nodes[0]!; + const actual = node.nodeValue ?? (node as unknown as Element).textContent ?? ""; + const opKeys = Object.keys(operators); + + if (opKeys.length === 0) { + return { matched: true, failure: null }; + } + + const matched = applyOperator(actual, operators); + if (!matched) { + return { + matched: false, + failure: mismatchFailure("body", fullPath, operators, actual, `xpath ${path} mismatch`), + }; + } + return { matched: true, failure: null }; +} + +function checkSingleBodyRule( + body: string, + rule: BodyRule, + index: number, +): { matched: boolean; failure: CheckFailure | null } { + const rulePath = `body[${index}]`; + + if ("contains" in rule) { + const matched = body.includes(rule.contains); + if (!matched) { + return { + matched: false, + failure: mismatchFailure("body", rulePath, rule.contains, body, `body does not contain "${rule.contains}"`), + }; + } + return { matched: true, failure: null }; + } + + if ("regex" in rule) { + const matched = new RegExp(rule.regex).test(body); + if (!matched) { + return { + matched: false, + failure: mismatchFailure("body", rulePath, `/${rule.regex}/`, body, `body does not match /${rule.regex}/`), + }; + } + return { matched: true, failure: null }; + } + + if ("json" in rule) { + return checkJsonRule(body, rule.json, rulePath); + } + + if ("css" in rule) { + return checkCssRule(body, rule.css, rulePath); + } + + if ("xpath" in rule) { + return checkXpathRule(body, rule.xpath, rulePath); + } + + return { matched: true, failure: null }; +} + +export function checkBodyExpect(body: string, rules?: BodyRule[]): { matched: boolean; failure: CheckFailure | null } { + if (!rules || rules.length === 0) return { matched: true, failure: null }; + + for (let i = 0; i < rules.length; i++) { + const result = checkSingleBodyRule(body, rules[i]!, i); + if (!result.matched) return result; + } + + return { matched: true, failure: null }; +} diff --git a/src/server/checker/expect/command.ts b/src/server/checker/expect/command.ts new file mode 100644 index 0000000..c939023 --- /dev/null +++ b/src/server/checker/expect/command.ts @@ -0,0 +1,91 @@ +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/failure.ts b/src/server/checker/expect/failure.ts new file mode 100644 index 0000000..fdf47b2 --- /dev/null +++ b/src/server/checker/expect/failure.ts @@ -0,0 +1,34 @@ +import type { CheckFailure } from "../types"; + +export function truncateActual(value: unknown, maxLen = 200): unknown { + if (value === undefined || value === null) return value; + const str = String(value); + if (str.length <= maxLen) return value; + return str.slice(0, maxLen) + "..."; +} + +export function mismatchFailure( + phase: CheckFailure["phase"], + path: string, + expected: unknown, + actual: unknown, + message: string, +): CheckFailure { + return { + kind: "mismatch", + phase, + path, + expected, + actual: truncateActual(actual), + message, + }; +} + +export function errorFailure(phase: CheckFailure["phase"], path: string, message: string): CheckFailure { + return { + kind: "error", + phase, + path, + message, + }; +} diff --git a/src/server/checker/expect/http.ts b/src/server/checker/expect/http.ts new file mode 100644 index 0000000..4f737f2 --- /dev/null +++ b/src/server/checker/expect/http.ts @@ -0,0 +1,122 @@ +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 index 90ddb44..ff4c3f6 100644 --- a/src/server/checker/fetcher.ts +++ b/src/server/checker/fetcher.ts @@ -1,52 +1,6 @@ -import type { CheckResult, ExpectConfig, ResolvedTarget } from "./types"; -import { checkBodyExpect } from "./body-expect"; - -export async function fetchTarget(target: ResolvedTarget): 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.url, { - method: target.method, - headers: target.headers, - body: target.method !== "GET" && target.method !== "HEAD" ? target.body : undefined, - signal: controller.signal, - }); - - const latencyMs = Math.round(performance.now() - start); - const body = await response.text(); - const responseHeaders = headersToRecord(response.headers); - - const matched = checkExpect(response.status, body, latencyMs, responseHeaders, target.expect); - - return { - targetName: target.name, - timestamp, - success: true, - statusCode: response.status, - latencyMs, - error: null, - matched, - }; - } catch (error) { - const isTimeout = error instanceof DOMException && error.name === "AbortError"; - - return { - targetName: target.name, - timestamp, - success: false, - statusCode: null, - latencyMs: null, - error: isTimeout ? `请求超时 (${target.timeoutMs}ms)` : error instanceof Error ? error.message : String(error), - matched: false, - }; - } finally { - clearTimeout(timeoutId); - } -} +import type { CheckResult, ResolvedHttpTarget } from "./types"; +import { checkHttpExpect } from "./expect/http"; +import { errorFailure } from "./expect/failure"; function headersToRecord(headers: Headers): Record { const result: Record = {}; @@ -56,35 +10,95 @@ function headersToRecord(headers: Headers): Record { return result; } -export function checkExpect( - statusCode: number, - body: string, - latencyMs: number, - responseHeaders: Record, - expect?: ExpectConfig, -): boolean { - if (!expect) return true; +export async function runHttpCheck(target: ResolvedHttpTarget): Promise { + const timestamp = new Date().toISOString(); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), target.timeoutMs); - if (expect.status && !expect.status.includes(statusCode)) { - return false; - } + try { + const start = performance.now(); - if (expect.headers) { - for (const [key, expectedValue] of Object.entries(expect.headers)) { - const actualValue = responseHeaders[key.toLowerCase()]; - if (!actualValue || actualValue !== expectedValue) { - return false; - } + 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 = headersToRecord(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, + success: preBodyResult.matched, + matched: preBodyResult.matched, + durationMs, + statusDetail: `HTTP ${statusCode}`, + failure: preBodyResult.failure, + }; } - } - if (!checkBodyExpect(body, expect.body)) { - return false; - } + const bodyBuffer = await response.arrayBuffer(); + clearTimeout(timeoutId); - if (expect.maxLatencyMs !== undefined && latencyMs > expect.maxLatencyMs) { - return false; - } + if (bodyBuffer.byteLength > target.http.maxBodyBytes) { + return { + targetName: target.name, + timestamp, + success: false, + matched: false, + durationMs, + statusDetail: `HTTP ${statusCode}`, + failure: errorFailure( + "body", + "body", + `响应体大小 ${bodyBuffer.byteLength} 超过限制 ${target.http.maxBodyBytes}`, + ), + }; + } - return true; + const body = new TextDecoder().decode(bodyBuffer); + const fullObs = { statusCode, headers: responseHeaders, body, durationMs }; + const fullResult = checkHttpExpect(fullObs, target.expect); + + return { + targetName: target.name, + timestamp, + success: fullResult.matched, + 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, + success: false, + matched: false, + durationMs: null, + statusDetail: null, + failure: errorFailure( + "status", + "request", + isTimeout ? `请求超时 (${target.timeoutMs}ms)` : error instanceof Error ? error.message : String(error), + ), + }; + } } diff --git a/src/server/checker/size.ts b/src/server/checker/size.ts new file mode 100644 index 0000000..9033353 --- /dev/null +++ b/src/server/checker/size.ts @@ -0,0 +1,22 @@ +const SIZE_REGEX = /^(\d+(?:\.\d+)?)(B|KB|MB|GB)$/; + +export function parseSize(value: string | number): number { + if (typeof value === "number") return value; + + const match = SIZE_REGEX.exec(value); + if (!match) { + throw new Error(`无效的 size 格式: "${value}",支持格式如 "100MB"、"512KB"、"1GB"、"1024B"`); + } + + const num = parseFloat(match[1]!); + const unit = match[2]!; + + if (unit === "B") return num; + if (unit === "KB") return num * 1024; + if (unit === "MB") return num * 1024 * 1024; + return num * 1024 * 1024 * 1024; +} + +export const DEFAULT_MAX_BODY_BYTES = parseSize("100MB"); +export const DEFAULT_MAX_OUTPUT_BYTES = parseSize("100MB"); +export const DEFAULT_MAX_CONCURRENT_CHECKS = 20; diff --git a/src/server/checker/store.ts b/src/server/checker/store.ts index 2e4fac6..7cc3284 100644 --- a/src/server/checker/store.ts +++ b/src/server/checker/store.ts @@ -1,16 +1,15 @@ import { Database } from "bun:sqlite"; import { mkdirSync as fsMkdirSync } from "node:fs"; import { dirname } from "node:path"; -import type { ResolvedTarget, StoredCheckResult, StoredTarget } from "./types"; +import type { CheckFailure, ResolvedTarget, StoredCheckResult, StoredTarget } from "./types"; const CREATE_TARGETS_TABLE = ` CREATE TABLE IF NOT EXISTS targets ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, - url TEXT NOT NULL, - method TEXT NOT NULL DEFAULT 'GET', - headers TEXT NOT NULL DEFAULT '{}', - body TEXT, + type TEXT NOT NULL, + target TEXT NOT NULL, + config TEXT NOT NULL DEFAULT '{}', interval_ms INTEGER NOT NULL, timeout_ms INTEGER NOT NULL, expect TEXT @@ -23,10 +22,10 @@ CREATE TABLE IF NOT EXISTS check_results ( target_id INTEGER NOT NULL, timestamp TEXT NOT NULL, success INTEGER NOT NULL, - status_code INTEGER, - latency_ms REAL, - error TEXT, matched INTEGER NOT NULL, + duration_ms REAL, + status_detail TEXT, + failure TEXT, FOREIGN KEY (target_id) REFERENCES targets(id) ) `; @@ -59,40 +58,24 @@ export class ProbeStore { const configNames = new Set(targets.map((t) => t.name)); const insertStmt = this.db.prepare( - "INSERT INTO targets (name, url, method, headers, body, interval_ms, timeout_ms, expect) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO targets (name, type, target, config, interval_ms, timeout_ms, expect) VALUES (?, ?, ?, ?, ?, ?, ?)", ); const updateStmt = this.db.prepare( - "UPDATE targets SET url = ?, method = ?, headers = ?, body = ?, interval_ms = ?, timeout_ms = ?, expect = ? WHERE id = ?", + "UPDATE targets SET type = ?, target = ?, config = ?, interval_ms = ?, timeout_ms = ?, expect = ? WHERE id = ?", ); const deleteStmt = this.db.prepare("DELETE FROM targets WHERE id = ?"); const tx = this.db.transaction(() => { - for (const target of targets) { - const headers = JSON.stringify(target.headers); - const expect = target.expect ? JSON.stringify(target.expect) : null; + for (const t of targets) { + const type = t.type; + const target = buildTargetDisplay(t); + const config = buildTargetConfig(t); + const expect = t.expect ? JSON.stringify(t.expect) : null; - if (existingMap.has(target.name)) { - updateStmt.run( - target.url, - target.method, - headers, - target.body ?? null, - target.intervalMs, - target.timeoutMs, - expect, - existingMap.get(target.name)!, - ); + if (existingMap.has(t.name)) { + updateStmt.run(type, target, config, t.intervalMs, t.timeoutMs, expect, existingMap.get(t.name)!); } else { - insertStmt.run( - target.name, - target.url, - target.method, - headers, - target.body ?? null, - target.intervalMs, - target.timeoutMs, - expect, - ); + insertStmt.run(t.name, type, target, config, t.intervalMs, t.timeoutMs, expect); } } @@ -120,24 +103,24 @@ export class ProbeStore { targetId: number; timestamp: string; success: boolean; - statusCode: number | null; - latencyMs: number | null; - error: string | null; matched: boolean; + durationMs: number | null; + statusDetail: string | null; + failure: CheckFailure | null; }): void { if (this.closed) return; this.db .prepare( - "INSERT INTO check_results (target_id, timestamp, success, status_code, latency_ms, error, matched) VALUES (?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO check_results (target_id, timestamp, success, matched, duration_ms, status_detail, failure) VALUES (?, ?, ?, ?, ?, ?, ?)", ) .run( result.targetId, result.timestamp, result.success ? 1 : 0, - result.statusCode, - result.latencyMs, - result.error, result.matched ? 1 : 0, + result.durationMs, + result.statusDetail, + result.failure ? JSON.stringify(result.failure) : null, ); } @@ -156,30 +139,30 @@ export class ProbeStore { getTargetStats(targetId: number): { totalChecks: number; availability: number; - avgLatencyMs: number | null; - p99LatencyMs: number | null; + avgDurationMs: number | null; + p99DurationMs: number | null; } { const row = this.db .prepare( `SELECT COUNT(*) as totalChecks, COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount, - AVG(CASE WHEN success = 1 THEN latency_ms END) as avgLatencyMs + AVG(CASE WHEN success = 1 THEN duration_ms END) as avgDurationMs FROM check_results WHERE target_id = ?`, ) - .get(targetId) as { totalChecks: number; upCount: number; avgLatencyMs: number | null }; + .get(targetId) as { totalChecks: number; upCount: number; avgDurationMs: number | null }; const p99Row = this.db .prepare( - `SELECT latency_ms as p99LatencyMs + `SELECT duration_ms as p99DurationMs FROM check_results WHERE target_id = ? AND success = 1 - ORDER BY latency_ms DESC + ORDER BY duration_ms DESC LIMIT 1 OFFSET (SELECT COUNT(*) FROM check_results WHERE target_id = ? AND success = 1) * 99 / 100`, ) - .get(targetId, targetId) as { p99LatencyMs: number | null } | undefined; + .get(targetId, targetId) as { p99DurationMs: number | null } | undefined; const totalChecks = row.totalChecks; const availability = totalChecks > 0 ? (row.upCount / totalChecks) * 100 : 0; @@ -187,8 +170,8 @@ export class ProbeStore { return { totalChecks, availability: Math.round(availability * 100) / 100, - avgLatencyMs: row.avgLatencyMs !== null ? Math.round(row.avgLatencyMs * 100) / 100 : null, - p99LatencyMs: p99Row?.p99LatencyMs ?? null, + avgDurationMs: row.avgDurationMs !== null ? Math.round(row.avgDurationMs * 100) / 100 : null, + p99DurationMs: p99Row?.p99DurationMs ?? null, }; } @@ -197,7 +180,7 @@ export class ProbeStore { hours = 24, ): Array<{ hour: string; - avgLatencyMs: number | null; + avgDurationMs: number | null; availability: number; totalChecks: number; }> { @@ -205,7 +188,7 @@ export class ProbeStore { .prepare( `SELECT strftime('%Y-%m-%dT%H:00:00', timestamp) as hour, - AVG(CASE WHEN success = 1 THEN latency_ms END) as avgLatencyMs, + AVG(CASE WHEN success = 1 THEN duration_ms END) as avgDurationMs, CASE WHEN COUNT(*) > 0 THEN (SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END) * 100.0 / COUNT(*)) ELSE 0 END as availability, COUNT(*) as totalChecks FROM check_results @@ -215,7 +198,7 @@ export class ProbeStore { ) .all(targetId, hours) as Array<{ hour: string; - avgLatencyMs: number | null; + avgDurationMs: number | null; availability: number; totalChecks: number; }>; @@ -225,14 +208,14 @@ export class ProbeStore { total: number; up: number; down: number; - avgLatencyMs: number | null; + avgDurationMs: number | null; lastCheckTime: string | null; } { const targets = this.getTargets(); let up = 0; let down = 0; - let totalLatency = 0; - let latencyCount = 0; + let totalDuration = 0; + let durationCount = 0; let lastCheckTime: string | null = null; for (const target of targets) { @@ -245,9 +228,9 @@ export class ProbeStore { down++; } - if (latest.latency_ms !== null) { - totalLatency += latest.latency_ms; - latencyCount++; + if (latest.duration_ms !== null) { + totalDuration += latest.duration_ms; + durationCount++; } if (!lastCheckTime || latest.timestamp > lastCheckTime) { @@ -262,7 +245,7 @@ export class ProbeStore { total: targets.length, up, down, - avgLatencyMs: latencyCount > 0 ? Math.round((totalLatency / latencyCount) * 100) / 100 : null, + avgDurationMs: durationCount > 0 ? Math.round((totalDuration / durationCount) * 100) / 100 : null, lastCheckTime, }; } @@ -270,10 +253,10 @@ export class ProbeStore { getSparkline(targetId: number, limit = 20): number[] { const rows = this.db .prepare( - "SELECT latency_ms FROM check_results WHERE target_id = ? AND success = 1 ORDER BY timestamp DESC LIMIT ?", + "SELECT duration_ms FROM check_results WHERE target_id = ? AND success = 1 ORDER BY timestamp DESC LIMIT ?", ) - .all(targetId, limit) as Array<{ latency_ms: number }>; - return rows.map((r) => r.latency_ms).reverse(); + .all(targetId, limit) as Array<{ duration_ms: number }>; + return rows.map((r) => r.duration_ms).reverse(); } close(): void { @@ -282,6 +265,33 @@ 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(" ")}`; +} + +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, + }); +} + function ensureDir(dir: string): void { try { fsMkdirSync(dir, { recursive: true }); diff --git a/src/server/checker/types.ts b/src/server/checker/types.ts index fd34882..cb53ac8 100644 --- a/src/server/checker/types.ts +++ b/src/server/checker/types.ts @@ -1,5 +1,8 @@ +export type TargetType = "http" | "command"; + export interface ProbeConfig { server?: ServerConfig; + runtime?: RuntimeConfig; defaults?: DefaultsConfig; targets: TargetConfig[]; } @@ -10,19 +13,49 @@ export interface ServerConfig { dataDir?: string; } +export interface RuntimeConfig { + maxConcurrentChecks?: number; +} + +export interface HttpDefaultsConfig { + method?: string; + headers?: Record; + maxBodyBytes?: string; +} + +export interface CommandDefaultsConfig { + cwd?: string; + maxOutputBytes?: string; +} + export interface DefaultsConfig { interval?: string; timeout?: string; - method?: string; - headers?: Record; + http?: HttpDefaultsConfig; + command?: CommandDefaultsConfig; } -export interface TargetConfig { - name: string; +export interface HttpTargetConfig { url: string; method?: string; headers?: Record; body?: string; + maxBodyBytes?: string; +} + +export interface CommandTargetConfig { + exec: string; + args?: string[]; + cwd?: string; + env?: Record; + maxOutputBytes?: string; +} + +export type TargetConfig = BaseTargetConfig & + ({ type: "http"; http: HttpTargetConfig } | { type: "command"; command: CommandTargetConfig }); + +interface BaseTargetConfig { + name: string; interval?: string; timeout?: string; expect?: ExpectConfig; @@ -42,51 +75,100 @@ export interface ExpectOperator { export type ExpectValue = string | number | boolean | null | ExpectOperator; -export type CssExpect = ExpectValue | (ExpectOperator & { attr?: string }); +export type TextRule = ExpectOperator; -export interface BodyExpectConfig { - contains?: string; - regex?: string; - json?: Record; - css?: Record; - xpath?: Record; -} +export type JsonRule = { path: string } & ExpectOperator; -export interface ExpectConfig { +export type CssRule = { selector: string; attr?: string } & ExpectOperator; + +export type XpathRule = { path: string } & ExpectOperator; + +export type BodyRule = + | { contains: string } + | { regex: string } + | { json: JsonRule } + | { css: CssRule } + | { xpath: XpathRule }; + +export type HeaderExpect = string | ExpectOperator; + +export interface HttpExpectConfig { status?: number[]; - maxLatencyMs?: number; - headers?: Record; - body?: BodyExpectConfig; + maxDurationMs?: number; + headers?: Record; + body?: BodyRule[]; } -export interface ResolvedTarget { +export interface CommandExpectConfig { + exitCode?: number[]; + maxDurationMs?: number; + stdout?: TextRule[]; + stderr?: TextRule[]; +} + +export type ExpectConfig = HttpExpectConfig | CommandExpectConfig; + +export interface ResolvedHttpTarget { + type: "http"; name: string; + http: ResolvedHttpConfig; + intervalMs: number; + timeoutMs: number; + expect?: HttpExpectConfig; +} + +export interface ResolvedHttpConfig { url: string; method: string; headers: Record; body?: string; + maxBodyBytes: number; +} + +export interface ResolvedCommandTarget { + type: "command"; + name: string; + command: ResolvedCommandConfig; intervalMs: number; timeoutMs: number; - expect?: ExpectConfig; + expect?: CommandExpectConfig; +} + +export interface ResolvedCommandConfig { + exec: string; + args: string[]; + cwd: string; + env: Record; + maxOutputBytes: number; +} + +export type ResolvedTarget = ResolvedHttpTarget | ResolvedCommandTarget; + +export interface CheckFailure { + kind: "error" | "mismatch"; + phase: "status" | "duration" | "headers" | "body" | "exitCode" | "stdout" | "stderr"; + path: string; + expected?: unknown; + actual?: unknown; + message: string; } export interface CheckResult { targetName: string; timestamp: string; success: boolean; - statusCode: number | null; - latencyMs: number | null; - error: string | null; matched: boolean; + durationMs: number | null; + statusDetail: string | null; + failure: CheckFailure | null; } export interface StoredTarget { id: number; name: string; - url: string; - method: string; - headers: string; - body: string | null; + type: TargetType; + target: string; + config: string; interval_ms: number; timeout_ms: number; expect: string | null; @@ -97,8 +179,8 @@ export interface StoredCheckResult { target_id: number; timestamp: string; success: number; - status_code: number | null; - latency_ms: number | null; - error: string | null; matched: number; + duration_ms: number | null; + status_detail: string | null; + failure: string | null; } diff --git a/src/server/dev.ts b/src/server/dev.ts index 11bef7a..845fe6f 100644 --- a/src/server/dev.ts +++ b/src/server/dev.ts @@ -11,7 +11,7 @@ async function main() { const store = new ProbeStore(`${config.dataDir}/probe.db`); store.syncTargets(config.targets); - const engine = new ProbeEngine(store, config.targets); + const engine = new ProbeEngine(store, config.targets, config.maxConcurrentChecks); engine.start(); startServer({ diff --git a/src/shared/api.ts b/src/shared/api.ts index 89523aa..e4c7b9c 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -15,15 +15,15 @@ export interface SummaryResponse { total: number; up: number; down: number; - avgLatencyMs: number | null; + avgDurationMs: number | null; lastCheckTime: string | null; } export interface TargetStatus { id: number; name: string; - url: string; - method: string; + type: string; + target: string; interval: string; latestCheck: CheckResult | null; stats: TargetStats; @@ -33,22 +33,31 @@ export interface TargetStatus { export interface TargetStats { totalChecks: number; availability: number; - avgLatencyMs: number | null; - p99LatencyMs: number | null; + avgDurationMs: number | null; + p99DurationMs: number | null; } export interface CheckResult { timestamp: string; success: boolean; - statusCode: number | null; - latencyMs: number | null; - error: string | null; matched: boolean; + durationMs: number | null; + statusDetail: string | null; + failure: CheckFailure | null; +} + +export interface CheckFailure { + kind: "error" | "mismatch"; + phase: string; + path: string; + expected?: unknown; + actual?: unknown; + message: string; } export interface TrendPoint { hour: string; - avgLatencyMs: number | null; + avgDurationMs: number | null; availability: number; totalChecks: number; } diff --git a/src/web/components/SparklineChart.tsx b/src/web/components/SparklineChart.tsx index 462cbbe..2b944de 100644 --- a/src/web/components/SparklineChart.tsx +++ b/src/web/components/SparklineChart.tsx @@ -1,7 +1,7 @@ import { Line, LineChart, ResponsiveContainer } from "recharts"; interface SparklineChartProps { - data: Array<{ latency: number }>; + data: Array<{ duration: number }>; } export function SparklineChart({ data }: SparklineChartProps) { @@ -12,7 +12,7 @@ export function SparklineChart({ data }: SparklineChartProps) { return ( - + ); diff --git a/src/web/components/SummaryCards.tsx b/src/web/components/SummaryCards.tsx index ca3c9fb..3e1ead5 100644 --- a/src/web/components/SummaryCards.tsx +++ b/src/web/components/SummaryCards.tsx @@ -17,8 +17,8 @@ export function SummaryCards({ summary, loading }: SummaryCardsProps) { { label: "正常", value: summary.up, className: "card-up" }, { label: "异常", value: summary.down, className: "card-down" }, { - label: "平均延迟", - value: summary.avgLatencyMs !== null ? `${Math.round(summary.avgLatencyMs)}ms` : "-", + label: "平均耗时", + value: summary.avgDurationMs !== null ? `${Math.round(summary.avgDurationMs)}ms` : "-", className: "card-latency", }, ]; diff --git a/src/web/components/TargetDetail.tsx b/src/web/components/TargetDetail.tsx index a9f37b9..af3316c 100644 --- a/src/web/components/TargetDetail.tsx +++ b/src/web/components/TargetDetail.tsx @@ -26,8 +26,9 @@ export function TargetDetail({ target }: TargetDetailProps) { }, [target.id]); useEffect(() => { - void fetchTrend(); - void fetchHistory(); + fetchTrend(); + // eslint-disable-next-line react-hooks/set-state-in-effect + fetchHistory(); }, [fetchTrend, fetchHistory]); const { stats } = target; @@ -49,15 +50,15 @@ export function TargetDetail({ target }: TargetDetailProps) {
- 平均延迟 + 平均耗时 - {stats.avgLatencyMs !== null ? `${Math.round(stats.avgLatencyMs)}ms` : "-"} + {stats.avgDurationMs !== null ? `${Math.round(stats.avgDurationMs)}ms` : "-"}
- P99 延迟 + P99 耗时 - {stats.p99LatencyMs !== null ? `${Math.round(stats.p99LatencyMs)}ms` : "-"} + {stats.p99DurationMs !== null ? `${Math.round(stats.p99DurationMs)}ms` : "-"}
@@ -79,9 +80,11 @@ export function TargetDetail({ target }: TargetDetailProps) { {item.success && item.matched ? "UP" : "DOWN"} {new Date(item.timestamp).toLocaleString("zh-CN")} - {item.statusCode && {item.statusCode}} - {item.latencyMs !== null && {Math.round(item.latencyMs)}ms} - {item.error && {item.error}} + {item.statusDetail && {item.statusDetail}} + {item.durationMs !== null && ( + {Math.round(item.durationMs)}ms + )} + {item.failure?.message && {item.failure.message}} ))} diff --git a/src/web/components/TargetRow.tsx b/src/web/components/TargetRow.tsx index e9f03f6..808a02d 100644 --- a/src/web/components/TargetRow.tsx +++ b/src/web/components/TargetRow.tsx @@ -11,7 +11,7 @@ interface TargetRowProps { export function TargetRow({ target, expanded, onToggle }: TargetRowProps) { const isUp = target.latestCheck?.success && target.latestCheck?.matched; - const sparklineData = target.sparkline.map((latency) => ({ latency })); + const sparklineData = target.sparkline.map((duration) => ({ duration })); return ( @@ -19,11 +19,11 @@ export function TargetRow({ target, expanded, onToggle }: TargetRowProps) { {target.name} - {target.url} - {target.method} - - {target.latestCheck?.latencyMs !== null && target.latestCheck?.latencyMs !== undefined - ? `${Math.round(target.latestCheck.latencyMs)}ms` + {target.target} + {target.type === "http" ? "HTTP" : "Command"} + + {target.latestCheck?.durationMs !== null && target.latestCheck?.durationMs !== undefined + ? `${Math.round(target.latestCheck.durationMs)}ms` : "-"} diff --git a/src/web/components/TargetTable.tsx b/src/web/components/TargetTable.tsx index e529c23..cb4d519 100644 --- a/src/web/components/TargetTable.tsx +++ b/src/web/components/TargetTable.tsx @@ -25,9 +25,9 @@ export function TargetTable({ targets, loading }: TargetTableProps) { 状态 名称 - URL - 方法 - 延迟 + 目标 + 类型 + 耗时 趋势 diff --git a/src/web/components/TrendChart.tsx b/src/web/components/TrendChart.tsx index db10ff6..0964907 100644 --- a/src/web/components/TrendChart.tsx +++ b/src/web/components/TrendChart.tsx @@ -27,7 +27,7 @@ export function TrendChart({ data, loading }: TrendChartProps) { { const num = Number(value); const nameStr = String(name); - if (nameStr === "avgLatencyMs") return [`${Math.round(num)}ms`, "平均延迟"]; + if (nameStr === "avgDurationMs") return [`${Math.round(num)}ms`, "平均耗时"]; if (nameStr === "availability") return [`${num.toFixed(1)}%`, "可用率"]; return [String(value), nameStr]; }} /> { - void fetchSummary(); + // eslint-disable-next-line react-hooks/set-state-in-effect + fetchSummary(); const timer = setInterval(fetchSummary, intervalMs); return () => { clearInterval(timer); diff --git a/src/web/hooks/useTargets.ts b/src/web/hooks/useTargets.ts index a5485c0..08b4f49 100644 --- a/src/web/hooks/useTargets.ts +++ b/src/web/hooks/useTargets.ts @@ -29,7 +29,8 @@ export function useTargets(intervalMs = 8000) { }, []); useEffect(() => { - void fetchTargets(); + // eslint-disable-next-line react-hooks/set-state-in-effect + fetchTargets(); const timer = setInterval(fetchTargets, intervalMs); return () => { clearInterval(timer); diff --git a/src/web/styles.css b/src/web/styles.css index 0129c34..78955cb 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -141,7 +141,7 @@ body { font-weight: 600; } -.col-url { +.col-target { color: #61728a; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.82rem; @@ -151,12 +151,12 @@ body { white-space: nowrap; } -.col-method { - width: 64px; +.col-type { + width: 80px; text-align: center; } -.col-latency { +.col-duration { width: 80px; text-align: right; font-variant-numeric: tabular-nums; @@ -317,7 +317,7 @@ body { grid-template-columns: repeat(2, 1fr); } - .col-method, + .col-type, .col-sparkline { display: none; } diff --git a/tests/server/app.test.ts b/tests/server/app.test.ts index 861ad71..85b9eec 100644 --- a/tests/server/app.test.ts +++ b/tests/server/app.test.ts @@ -26,18 +26,27 @@ describe("API 路由", () => { store = new ProbeStore(join(tempDir, "test.db")); store.syncTargets([ { + type: "http", name: "test-a", - url: "http://a.com", - method: "GET", - headers: {}, + http: { + url: "http://a.com", + method: "GET", + headers: {}, + maxBodyBytes: 104857600, + }, intervalMs: 30000, timeoutMs: 10000, }, { + type: "command", name: "test-b", - url: "http://b.com", - method: "POST", - headers: {}, + command: { + exec: "echo", + args: ["hello"], + cwd: "/tmp", + env: {}, + maxOutputBytes: 104857600, + }, intervalMs: 60000, timeoutMs: 5000, }, @@ -48,19 +57,26 @@ describe("API 路由", () => { targetId: targets[0]!.id, timestamp: "2025-01-01T00:00:00.000Z", success: true, - statusCode: 200, - latencyMs: 150, - error: null, matched: true, + durationMs: 150, + statusDetail: "200 OK", + failure: null, }); store.insertCheckResult({ targetId: targets[0]!.id, timestamp: "2025-01-01T00:00:30.000Z", success: false, - statusCode: null, - latencyMs: null, - error: "timeout", matched: false, + durationMs: null, + statusDetail: null, + failure: { + kind: "error", + phase: "status", + path: "$.status", + expected: 200, + actual: 500, + message: "状态码不匹配", + }, }); fetchHandler = createFetchHandler({ mode: "test", staticAssets, store }); @@ -88,6 +104,7 @@ describe("API 路由", () => { expect(body.up).toBeGreaterThanOrEqual(0); expect(body.down).toBeGreaterThanOrEqual(0); expect(body.up + body.down).toBe(2); + expect(body.avgDurationMs).toBeDefined(); }); test("/api/targets 返回目标列表", async () => { @@ -96,12 +113,23 @@ describe("API 路由", () => { expect(response.status).toBe(200); expect(body).toHaveLength(2); - expect(body[0]!.name).toBe("test-a"); - expect(body[0]!.latestCheck).not.toBeNull(); - expect(body[0]!.latestCheck!.success).toBe(false); - expect(body[0]!.sparkline).toBeDefined(); - expect(Array.isArray(body[0]!.sparkline)).toBe(true); - expect(body[1]!.latestCheck).toBeNull(); + + const tA = body.find((t) => t.name === "test-a")!; + expect(tA.type).toBe("http"); + expect(tA.target).toBe("http://a.com"); + expect(tA.latestCheck).not.toBeNull(); + expect(tA.latestCheck!.success).toBe(false); + expect(tA.latestCheck!.matched).toBe(false); + expect(tA.latestCheck!.failure).not.toBeNull(); + expect(tA.sparkline).toBeDefined(); + expect(Array.isArray(tA.sparkline)).toBe(true); + expect(tA.stats.avgDurationMs).toBeDefined(); + expect(tA.stats.p99DurationMs).toBeDefined(); + + const tB = body.find((t) => t.name === "test-b")!; + expect(tB.type).toBe("command"); + expect(tB.target).toBe("exec echo hello"); + expect(tB.latestCheck).toBeNull(); }); test("/api/targets/:id/history 返回历史记录", async () => { @@ -111,6 +139,8 @@ describe("API 路由", () => { expect(response.status).toBe(200); expect(body).toHaveLength(2); + expect(body[0].failure).not.toBeNull(); + expect(body[0].failure.kind).toBe("error"); }); test("/api/targets/:id/history 支持 limit 参数", async () => { diff --git a/tests/server/checker/command-runner.test.ts b/tests/server/checker/command-runner.test.ts new file mode 100644 index 0000000..23f327f --- /dev/null +++ b/tests/server/checker/command-runner.test.ts @@ -0,0 +1,131 @@ +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", + 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.success).toBe(true); + 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.success).toBe(false); + 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.success).toBe(true); + 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.success).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.success).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.success).toBe(true); + }); + + test("stdout 匹配 expect", async () => { + const result = await runCommandCheck( + makeTarget({ exec: "echo", args: ["hello"] }, { expect: { stdout: [{ contains: "hello" }] } }), + ); + expect(result.success).toBe(true); + expect(result.matched).toBe(true); + }); + + test("stdout 不匹配 expect", async () => { + const result = await runCommandCheck( + makeTarget({ exec: "echo", args: ["hello"] }, { expect: { stdout: [{ contains: "nonexistent" }] } }), + ); + expect(result.success).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.success).toBe(true); + expect(result.matched).toBe(true); + }); + + test("输出超过 maxOutputBytes", async () => { + const result = await runCommandCheck( + makeTarget({ + exec: "bash", + args: ["-c", "yes | head -1000"], + maxOutputBytes: 10, + }), + ); + expect(result.success).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.success).toBe(true); + expect(result.statusDetail).toBe("exitCode=0"); + }); + + test("不使用 shell,通配符不被展开", async () => { + const result = await runCommandCheck( + makeTarget({ exec: "echo", args: ["*"] }, { expect: { stdout: [{ contains: "*" }] } }), + ); + expect(result.success).toBe(true); + }); + + test("不提供 stdin,等待输入的命令会阻塞超时", async () => { + const result = await runCommandCheck(makeTarget({ exec: "bash", args: ["-c", "read line"] }, { timeoutMs: 500 })); + expect(result.success).toBe(false); + }); +}); diff --git a/tests/server/checker/config-loader.test.ts b/tests/server/checker/config-loader.test.ts index b1681e7..4f412fd 100644 --- a/tests/server/checker/config-loader.test.ts +++ b/tests/server/checker/config-loader.test.ts @@ -55,6 +55,66 @@ describe("loadConfig", () => { await rm(tempDir, { recursive: true, force: true }); }); + test("解析最简 HTTP 配置", async () => { + const configPath = join(tempDir, "minimal-http.yaml"); + await writeFile( + configPath, + `targets: + - name: "test" + type: http + http: + url: "http://example.com" +`, + ); + + const config = await loadConfig(configPath); + expect(config.host).toBe("127.0.0.1"); + expect(config.port).toBe(3000); + expect(config.dataDir).toBe("./data"); + expect(config.maxConcurrentChecks).toBe(20); + expect(config.targets).toHaveLength(1); + const t = config.targets[0]!; + expect(t.type).toBe("http"); + if (t.type === "http") { + expect(t.name).toBe("test"); + expect(t.http.url).toBe("http://example.com"); + expect(t.http.method).toBe("GET"); + expect(t.http.headers).toEqual({}); + expect(t.http.maxBodyBytes).toBe(104857600); + expect(t.intervalMs).toBe(30000); + expect(t.timeoutMs).toBe(10000); + } + }); + + test("解析最简 command 配置", async () => { + const subdir = join(tempDir, "subdir"); + await mkdir(subdir, { recursive: true }); + const configPath = join(subdir, "cmd.yaml"); + await writeFile( + configPath, + `targets: + - name: "check-nginx" + type: command + command: + exec: "pgrep" + args: ["nginx"] +`, + ); + + const config = await loadConfig(configPath); + expect(config.targets).toHaveLength(1); + const t = config.targets[0]!; + expect(t.type).toBe("command"); + if (t.type === "command") { + expect(t.name).toBe("check-nginx"); + expect(t.command.exec).toBe("pgrep"); + expect(t.command.args).toEqual(["nginx"]); + expect(t.command.cwd).toBe(subdir); + expect(t.command.maxOutputBytes).toBe(104857600); + expect(t.command.env.PATH).toBeDefined(); + } + }); + test("解析完整配置", async () => { const configPath = join(tempDir, "full.yaml"); await writeFile( @@ -63,13 +123,36 @@ describe("loadConfig", () => { host: "0.0.0.0" port: 8080 dataDir: "./my-data" +runtime: + maxConcurrentChecks: 5 defaults: interval: "15s" timeout: "5s" - method: "POST" + http: + method: "POST" + headers: + Authorization: "Bearer token" + maxBodyBytes: "50MB" + command: + cwd: "/tmp" + maxOutputBytes: "10MB" targets: - - name: "test" - url: "http://example.com" + - name: "http-target" + type: http + interval: "1m" + http: + url: "http://example.com" + expect: + status: [200] + body: + - contains: "ok" + - name: "cmd-target" + type: command + command: + exec: "ls" + args: ["/tmp"] + expect: + exitCode: [0] `, ); @@ -77,39 +160,27 @@ targets: expect(config.host).toBe("0.0.0.0"); expect(config.port).toBe(8080); expect(config.dataDir).toBe("./my-data"); - expect(config.targets).toHaveLength(1); - expect(config.targets[0]).toEqual({ - name: "test", - url: "http://example.com", - method: "POST", - headers: {}, - body: undefined, - intervalMs: 15000, - timeoutMs: 5000, - expect: undefined, - }); - }); - - test("解析最简配置(只有 targets)", async () => { - const configPath = join(tempDir, "minimal.yaml"); - await writeFile( - configPath, - `targets: - - name: "t1" - url: "http://a.com" - - name: "t2" - url: "http://b.com" - interval: "1m" -`, - ); - - const config = await loadConfig(configPath); - expect(config.host).toBe("127.0.0.1"); - expect(config.port).toBe(3000); - expect(config.dataDir).toBe("./data"); + expect(config.maxConcurrentChecks).toBe(5); expect(config.targets).toHaveLength(2); - expect(config.targets[0]!.intervalMs).toBe(30000); - expect(config.targets[1]!.intervalMs).toBe(60000); + + const http = config.targets[0]!; + expect(http.type).toBe("http"); + if (http.type === "http") { + expect(http.http.url).toBe("http://example.com"); + expect(http.http.method).toBe("POST"); + expect(http.http.headers).toEqual({ Authorization: "Bearer token" }); + expect(http.http.maxBodyBytes).toBe(52428800); + expect(http.intervalMs).toBe(60000); + expect(http.timeoutMs).toBe(5000); + } + + const cmd = config.targets[1]!; + expect(cmd.type).toBe("command"); + if (cmd.type === "command") { + expect(cmd.command.exec).toBe("ls"); + expect(cmd.command.args).toEqual(["/tmp"]); + expect(cmd.command.maxOutputBytes).toBe(10485760); + } }); test("per-target 覆盖 defaults", async () => { @@ -119,26 +190,29 @@ targets: `defaults: interval: "30s" timeout: "10s" - method: "GET" - headers: - Authorization: "Bearer token" + http: + method: "GET" + maxBodyBytes: "10MB" targets: - name: "override-all" - url: "http://example.com" - method: "POST" + type: http interval: "5m" timeout: "30s" - headers: - X-Custom: "value" + http: + url: "http://example.com" + method: "POST" + maxBodyBytes: "1MB" `, ); const config = await loadConfig(configPath); - const target = config.targets[0]!; - expect(target.method).toBe("POST"); - expect(target.intervalMs).toBe(300000); - expect(target.timeoutMs).toBe(30000); - expect(target.headers).toEqual({ Authorization: "Bearer token", "X-Custom": "value" }); + const t = config.targets[0]!; + if (t.type === "http") { + expect(t.http.method).toBe("POST"); + expect(t.intervalMs).toBe(300000); + expect(t.timeoutMs).toBe(30000); + expect(t.http.maxBodyBytes).toBe(1048576); + } }); test("配置文件不存在抛出错误", async () => { @@ -150,23 +224,63 @@ targets: await writeFile( configPath, `targets: - - url: "http://example.com" + - type: http + http: + url: "http://example.com" `, ); - await expect(loadConfig(configPath)).rejects.toThrow("缺少 name 字段"); }); - test("target 缺少 url 抛出错误", async () => { + test("target 缺少 type 抛出错误", async () => { + const configPath = join(tempDir, "no-type.yaml"); + await writeFile( + configPath, + `targets: + - name: "test" + http: + url: "http://example.com" +`, + ); + await expect(loadConfig(configPath)).rejects.toThrow("缺少 type 字段"); + }); + + test("HTTP target 缺少 url 抛出错误", async () => { const configPath = join(tempDir, "no-url.yaml"); await writeFile( configPath, `targets: - name: "test" + type: http + http: {} `, ); + await expect(loadConfig(configPath)).rejects.toThrow("缺少 http.url 字段"); + }); - await expect(loadConfig(configPath)).rejects.toThrow("缺少 url 字段"); + test("command target 缺少 exec 抛出错误", async () => { + const configPath = join(tempDir, "no-exec.yaml"); + await writeFile( + configPath, + `targets: + - name: "test" + type: command + command: {} +`, + ); + await expect(loadConfig(configPath)).rejects.toThrow("缺少 command.exec 字段"); + }); + + test("非法 target type 抛出错误", async () => { + const configPath = join(tempDir, "bad-type.yaml"); + await writeFile( + configPath, + `targets: + - name: "test" + type: dns +`, + ); + await expect(loadConfig(configPath)).rejects.toThrow("不支持的 type"); }); test("target name 重复抛出错误", async () => { @@ -175,19 +289,21 @@ targets: configPath, `targets: - name: "dup" - url: "http://a.com" + type: http + http: + url: "http://a.com" - name: "dup" - url: "http://b.com" + type: http + http: + url: "http://b.com" `, ); - await expect(loadConfig(configPath)).rejects.toThrow("target name 重复"); }); test("targets 为空数组抛出错误", async () => { const configPath = join(tempDir, "empty-targets.yaml"); await writeFile(configPath, `targets: []`); - await expect(loadConfig(configPath)).rejects.toThrow("至少一个 target"); }); @@ -199,33 +315,168 @@ targets: port: 99999 targets: - name: "t" - url: "http://a.com" + type: http + http: + url: "http://a.com" `, ); - await expect(loadConfig(configPath)).rejects.toThrow("无效端口号"); }); + test("非法 maxConcurrentChecks 抛出错误", async () => { + const configPath = join(tempDir, "bad-concurrency.yaml"); + await writeFile( + configPath, + `runtime: + maxConcurrentChecks: -1 +targets: + - name: "t" + type: http + http: + url: "http://a.com" +`, + ); + await expect(loadConfig(configPath)).rejects.toThrow("maxConcurrentChecks 必须为正整数"); + }); + + test("非法 size 格式抛出错误", async () => { + const configPath = join(tempDir, "bad-size.yaml"); + await writeFile( + configPath, + `defaults: + http: + maxBodyBytes: "100TB" +targets: + - name: "t" + type: http + http: + url: "http://a.com" +`, + ); + await expect(loadConfig(configPath)).rejects.toThrow("无效的 size 格式"); + }); + + test("非法 interval 格式抛出错误", async () => { + const configPath = join(tempDir, "bad-interval.yaml"); + await writeFile( + configPath, + `targets: + - name: "t" + type: http + interval: "30x" + http: + url: "http://a.com" +`, + ); + await expect(loadConfig(configPath)).rejects.toThrow("无效的时长格式"); + }); + test("解析 expect 配置", async () => { const configPath = join(tempDir, "expect.yaml"); await writeFile( configPath, `targets: - name: "with-expect" - url: "http://example.com" + type: http + http: + url: "http://example.com" expect: status: [200, 201] body: - contains: "ok" - maxLatencyMs: 3000 + - contains: "ok" + - json: + path: "$.status" + equals: "ok" + maxDurationMs: 3000 `, ); const config = await loadConfig(configPath); - expect(config.targets[0]!.expect).toEqual({ - status: [200, 201], - body: { contains: "ok" }, - maxLatencyMs: 3000, - }); + const t = config.targets[0]!; + if (t.type === "http") { + expect(t.expect).toEqual({ + status: [200, 201], + body: [{ contains: "ok" }, { json: { path: "$.status", equals: "ok" } }], + maxDurationMs: 3000, + }); + } + }); + + test("解析 command expect 配置", async () => { + const configPath = join(tempDir, "cmd-expect.yaml"); + await writeFile( + configPath, + `targets: + - name: "cmd-with-expect" + type: command + command: + exec: "mycheck" + expect: + exitCode: [0, 2] + stdout: + - contains: "ok" + - match: "done" + stderr: + - empty: true + maxDurationMs: 5000 +`, + ); + + const config = await loadConfig(configPath); + const t = config.targets[0]!; + if (t.type === "command") { + expect(t.expect).toEqual({ + exitCode: [0, 2], + stdout: [{ contains: "ok" }, { match: "done" }], + stderr: [{ empty: true }], + maxDurationMs: 5000, + }); + } + }); + + test("command cwd 相对配置文件目录", async () => { + const subdir = join(tempDir, "cwd-test"); + await mkdir(subdir, { recursive: true }); + const configPath = join(subdir, "cwd.yaml"); + await writeFile( + configPath, + `targets: + - name: "cwd-test" + type: command + command: + exec: "ls" + cwd: "scripts" +`, + ); + + const config = await loadConfig(configPath); + const t = config.targets[0]!; + if (t.type === "command") { + expect(t.command.cwd).toBe(join(subdir, "scripts")); + } + }); + + test("command env 覆盖", async () => { + const configPath = join(tempDir, "env.yaml"); + await writeFile( + configPath, + `targets: + - name: "env-test" + type: command + command: + exec: "echo" + env: + LANG: "C" + CUSTOM_VAR: "test" +`, + ); + + const config = await loadConfig(configPath); + const t = config.targets[0]!; + if (t.type === "command") { + expect(t.command.env.LANG).toBe("C"); + expect(t.command.env.CUSTOM_VAR).toBe("test"); + expect(t.command.env.PATH).toBeDefined(); + } }); }); diff --git a/tests/server/checker/engine.test.ts b/tests/server/checker/engine.test.ts index b65b50a..80c6a67 100644 --- a/tests/server/checker/engine.test.ts +++ b/tests/server/checker/engine.test.ts @@ -1,96 +1,204 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { ProbeStore } from "../../../src/server/checker/store"; +import { describe, expect, test } from "bun:test"; import { ProbeEngine } from "../../../src/server/checker/engine"; -import type { ResolvedTarget } from "../../../src/server/checker/types"; -import { mkdir, rm } from "node:fs/promises"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; +import type { ProbeStore } from "../../../src/server/checker/store"; +import type { ResolvedCommandTarget, ResolvedHttpTarget, ResolvedTarget } from "../../../src/server/checker/types"; + +function createMockStore(targetNames: string[]) { + let nextId = 1; + const targets = targetNames.map((name) => ({ id: nextId++, name })); + const results: Array> = []; + + return { + getTargets() { + return targets.map(({ id, name }) => ({ + id, + name, + type: "command" as const, + target: "", + config: "", + interval_ms: 60000, + timeout_ms: 5000, + expect: null, + })); + }, + insertCheckResult(result: Record) { + results.push(result); + }, + _results: results, + }; +} + +function makeCommandTarget(name: string, overrides?: Partial): ResolvedCommandTarget { + return { + type: "command", + name, + command: { + exec: "echo", + args: ["hello"], + cwd: "/tmp", + env: {}, + maxOutputBytes: 1024 * 1024, + }, + intervalMs: 60000, + timeoutMs: 5000, + ...overrides, + }; +} describe("ProbeEngine", () => { - let tempDir: string; - let store: ProbeStore; - - const target: ResolvedTarget = { - name: "httpbin", - url: "https://httpbin.org/get", - method: "GET", - headers: {}, - intervalMs: 60000, - timeoutMs: 10000, - }; - - beforeAll(async () => { - tempDir = join(tmpdir(), `gc-engine-test-${Date.now()}`); - await mkdir(tempDir, { recursive: true }); - store = new ProbeStore(join(tempDir, "test.db")); - store.syncTargets([target]); - }); - - afterAll(async () => { - store.close(); - await rm(tempDir, { recursive: true, force: true }); - }); - - test("groupByInterval 分组逻辑", () => { - const targets: ResolvedTarget[] = [ - { name: "a", url: "http://a.com", method: "GET", headers: {}, intervalMs: 30000, timeoutMs: 10000 }, - { name: "b", url: "http://b.com", method: "GET", headers: {}, intervalMs: 30000, timeoutMs: 10000 }, - { name: "c", url: "http://c.com", method: "GET", headers: {}, intervalMs: 60000, timeoutMs: 10000 }, - ]; - - const engine = new ProbeEngine(store, targets); - engine.start(); - engine.stop(); - - // 只要能启动和停止不出错就行 - expect(true).toBe(true); - }); - - test("engine start/stop 不抛错", () => { - const engine = new ProbeEngine(store, [target]); + test("start/stop 不抛错", () => { + const mockStore = createMockStore(["test"]) as unknown as ProbeStore; + const targets: ResolvedTarget[] = [makeCommandTarget("test")]; + const engine = new ProbeEngine(mockStore, targets); engine.start(); engine.stop(); expect(true).toBe(true); }); - test("单次拨测写入数据库", async () => { - const engine = new ProbeEngine(store, [target]); - // 手动调用 probeGroup 不启动 timer + test("单次 probeGroup 执行 command 检查", async () => { + const target = makeCommandTarget("cmd-echo"); + const mockStore = createMockStore(["cmd-echo"]) as unknown as ProbeStore; + const engine = new ProbeEngine(mockStore, [target]); + const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise }).probeGroup.bind( engine, ); await probeGroup([target]); - const dbTargets = store.getTargets(); - const latest = store.getLatestCheck(dbTargets[0]!.id); - expect(latest).not.toBeNull(); - expect(latest!.success === 1 || latest!.success === 0).toBe(true); + const results = (mockStore as unknown as { _results: Array> })._results; + expect(results.length).toBe(1); + expect(results[0]!.success).toBe(true); + expect(results[0]!.matched).toBe(true); + expect(results[0]!.statusDetail).toBe("exitCode=0"); }); - test("单目标失败隔离", async () => { - const badTarget: ResolvedTarget = { - name: "bad-target", - url: "http://127.0.0.1:1/impossible", - method: "GET", - headers: {}, - intervalMs: 60000, - timeoutMs: 2000, - }; + test("多个目标并发执行", async () => { + const targetA = makeCommandTarget("echo-a", { + command: { exec: "echo", args: ["a"], cwd: "/tmp", env: {}, maxOutputBytes: 1024 * 1024 }, + }); + const targetB = makeCommandTarget("echo-b", { + command: { exec: "echo", args: ["b"], cwd: "/tmp", env: {}, maxOutputBytes: 1024 * 1024 }, + }); - store.syncTargets([target, badTarget]); + const mockStore = createMockStore(["echo-a", "echo-b"]) as unknown as ProbeStore; + const engine = new ProbeEngine(mockStore, [targetA, targetB]); - const engine = new ProbeEngine(store, [target, badTarget]); const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise }).probeGroup.bind( engine, ); - await probeGroup([target, badTarget]); + await probeGroup([targetA, targetB]); - const dbTargets = store.getTargets(); - const goodResult = store.getLatestCheck(dbTargets.find((t) => t.name === "httpbin")!.id); - const badResult = store.getLatestCheck(dbTargets.find((t) => t.name === "bad-target")!.id); + const results = (mockStore as unknown as { _results: Array> })._results; + expect(results.length).toBe(2); + }); - expect(goodResult).not.toBeNull(); - expect(badResult).not.toBeNull(); - expect(badResult!.success).toBe(0); + test("失败目标不阻塞其他目标", async () => { + const badTarget = makeCommandTarget("bad-cmd", { + command: { exec: "false", args: [], cwd: "/tmp", env: {}, maxOutputBytes: 1024 * 1024 }, + }); + const goodTarget = makeCommandTarget("good-cmd"); + + const mockStore = createMockStore(["bad-cmd", "good-cmd"]) as unknown as ProbeStore; + const engine = new ProbeEngine(mockStore, [badTarget, goodTarget]); + + const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise }).probeGroup.bind( + engine, + ); + await probeGroup([badTarget, goodTarget]); + + const results = (mockStore as unknown as { _results: Array> })._results; + expect(results.length).toBe(2); + + const badResult = results.find((r) => r.success === false); + const goodResult = results.find((r) => r.success === true); + expect(badResult).toBeDefined(); + expect(goodResult).toBeDefined(); + }); + + test("并发限制 maxConcurrentChecks", async () => { + const targets = Array.from({ length: 5 }, (_, i) => + makeCommandTarget(`cmd-${i}`, { + command: { exec: "echo", args: [String(i)], cwd: "/tmp", env: {}, maxOutputBytes: 1024 * 1024 }, + }), + ); + + const mockStore = createMockStore(targets.map((t) => t.name)) as unknown as ProbeStore; + const engine = new ProbeEngine(mockStore, targets, 2); + + const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise }).probeGroup.bind( + engine, + ); + await probeGroup(targets); + + const results = (mockStore as unknown as { _results: Array> })._results; + expect(results.length).toBe(5); + for (const r of results) { + expect(r.success).toBe(true); + } + }); + + test("groupByInterval 按间隔分组", () => { + const targetA = makeCommandTarget("a", { intervalMs: 30000 }); + const targetB = makeCommandTarget("b", { intervalMs: 30000 }); + const targetC = makeCommandTarget("c", { intervalMs: 60000 }); + + const mockStore = createMockStore(["a", "b", "c"]) as unknown as ProbeStore; + const engine = new ProbeEngine(mockStore, [targetA, targetB, targetC]); + engine.start(); + engine.stop(); + expect(true).toBe(true); + }); + + test("未注册的 targetName 不写入结果", async () => { + const target = makeCommandTarget("unknown-target"); + const mockStore = createMockStore(["other-name"]) as unknown as ProbeStore; + const engine = new ProbeEngine(mockStore, [target]); + + const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise }).probeGroup.bind( + engine, + ); + await probeGroup([target]); + + const results = (mockStore as unknown as { _results: Array> })._results; + expect(results.length).toBe(0); + }); + + test("HTTP 目标运行", async () => { + const httpServer = Bun.serve({ + port: 0, + fetch() { + return new Response("ok"); + }, + }); + + try { + const httpTarget: ResolvedHttpTarget = { + type: "http", + name: "http-test", + http: { + url: `http://localhost:${httpServer.port}/`, + method: "GET", + headers: {}, + maxBodyBytes: 1024 * 1024, + }, + intervalMs: 60000, + timeoutMs: 5000, + }; + + const mockStore = createMockStore(["http-test"]) as unknown as ProbeStore; + const engine = new ProbeEngine(mockStore, [httpTarget]); + + const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise }).probeGroup.bind( + engine, + ); + await probeGroup([httpTarget]); + + const results = (mockStore as unknown as { _results: Array> })._results; + expect(results.length).toBe(1); + expect(results[0]!.success).toBe(true); + expect(results[0]!.statusDetail).toBe("HTTP 200"); + } finally { + httpServer.stop(); + } }); }); diff --git a/tests/server/checker/body-expect.test.ts b/tests/server/checker/expect/body.test.ts similarity index 50% rename from tests/server/checker/body-expect.test.ts rename to tests/server/checker/expect/body.test.ts index fddef1c..436fc47 100644 --- a/tests/server/checker/body-expect.test.ts +++ b/tests/server/checker/expect/body.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { applyOperator, checkBodyExpect, evaluateJsonPath } from "../../../src/server/checker/body-expect"; +import { + applyOperator, + checkBodyExpect, + checkExpectValue, + evaluateJsonPath, +} from "../../../../src/server/checker/expect/body"; describe("evaluateJsonPath", () => { const obj = { @@ -125,106 +130,158 @@ describe("applyOperator", () => { }); }); -describe("checkBodyExpect", () => { - test("无 body config 返回 true", () => { - expect(checkBodyExpect("anything", undefined)).toBe(true); +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("contains 匹配", () => { - expect(checkBodyExpect("hello world", { contains: "hello" })).toBe(true); - expect(checkBodyExpect("hello world", { contains: "missing" })).toBe(false); + test("对象作为操作符", () => { + expect(checkExpectValue(42, { gte: 10 })).toBe(true); + expect(checkExpectValue(42, { gte: 100 })).toBe(false); + expect(checkExpectValue("hello", { contains: "ell" })).toBe(true); + }); +}); + +describe("checkBodyExpect (BodyRule[])", () => { + test("无规则返回匹配成功", () => { + const r = checkBodyExpect("anything"); + expect(r.matched).toBe(true); + expect(r.failure).toBeNull(); }); - test("regex 匹配", () => { - expect(checkBodyExpect("status: ok", { regex: "ok" })).toBe(true); - expect(checkBodyExpect("status: error", { regex: "ok" })).toBe(false); + test("空规则数组返回匹配成功", () => { + const r = checkBodyExpect("anything", []); + expect(r.matched).toBe(true); + expect(r.failure).toBeNull(); }); - test("json 简单等值匹配", () => { + test("contains 规则匹配成功", () => { + const r = checkBodyExpect("hello world", [{ contains: "hello" }]); + expect(r.matched).toBe(true); + expect(r.failure).toBeNull(); + }); + + test("contains 规则匹配失败", () => { + const r = checkBodyExpect("hello world", [{ contains: "missing" }]); + expect(r.matched).toBe(false); + expect(r.failure).not.toBeNull(); + expect(r.failure!.kind).toBe("mismatch"); + expect(r.failure!.phase).toBe("body"); + expect(r.failure!.path).toBe("body[0]"); + }); + + test("regex 规则匹配成功", () => { + const r = checkBodyExpect("status: ok", [{ regex: "ok" }]); + expect(r.matched).toBe(true); + }); + + test("regex 规则匹配失败", () => { + const r = checkBodyExpect("status: error", [{ regex: "^ok$" }]); + expect(r.matched).toBe(false); + expect(r.failure!.path).toBe("body[0]"); + }); + + test("json 等值匹配成功", () => { const body = JSON.stringify({ status: "ok", code: 0 }); - expect(checkBodyExpect(body, { json: { "$.status": "ok" } })).toBe(true); - expect(checkBodyExpect(body, { json: { "$.code": 0 } })).toBe(true); - expect(checkBodyExpect(body, { json: { "$.status": "error" } })).toBe(false); + const r = checkBodyExpect(body, [{ json: { path: "$.status", equals: "ok" } }]); + expect(r.matched).toBe(true); + }); + + test("json 等值匹配失败", () => { + const body = JSON.stringify({ status: "ok" }); + const r = checkBodyExpect(body, [{ json: { path: "$.status", equals: "error" } }]); + expect(r.matched).toBe(false); + expect(r.failure!.kind).toBe("mismatch"); }); test("json 操作符匹配", () => { - const body = JSON.stringify({ count: 42, version: "v2.1.0", message: "success" }); - expect(checkBodyExpect(body, { json: { "$.count": { gte: 10 } } })).toBe(true); - expect(checkBodyExpect(body, { json: { "$.version": { match: "\\d+\\.\\d+\\.\\d+" } } })).toBe(true); - expect(checkBodyExpect(body, { json: { "$.message": { contains: "success" } } })).toBe(true); - expect(checkBodyExpect(body, { json: { "$.count": { gte: 100 } } })).toBe(false); + const body = JSON.stringify({ count: 42, version: "v2.1.0" }); + expect(checkBodyExpect(body, [{ json: { path: "$.count", gte: 10 } }]).matched).toBe(true); + expect(checkBodyExpect(body, [{ json: { path: "$.version", match: "\\d+\\.\\d+\\.\\d+" } }]).matched).toBe(true); + expect(checkBodyExpect(body, [{ json: { path: "$.count", gte: 100 } }]).matched).toBe(false); }); test("json 路径不存在", () => { const body = JSON.stringify({ status: "ok" }); - expect(checkBodyExpect(body, { json: { "$.notExist": "value" } })).toBe(false); + const r = checkBodyExpect(body, [{ json: { path: "$.notExist", equals: "value" } }]); + expect(r.matched).toBe(false); }); test("json 解析失败", () => { - expect(checkBodyExpect("not json", { json: { "$.status": "ok" } })).toBe(false); + const r = checkBodyExpect("not json", [{ json: { path: "$.status", equals: "ok" } }]); + expect(r.matched).toBe(false); + expect(r.failure!.kind).toBe("error"); }); - test("css textContent 匹配", () => { + test("css 文本内容匹配", () => { const html = "
OK
1.0"; - expect(checkBodyExpect(html, { css: { "div#health": "OK" } })).toBe(true); - expect(checkBodyExpect(html, { css: { "span.ver": "1.0" } })).toBe(true); - expect(checkBodyExpect(html, { css: { "div#health": "ERROR" } })).toBe(false); + expect(checkBodyExpect(html, [{ css: { selector: "div#health", equals: "OK" } }]).matched).toBe(true); + expect(checkBodyExpect(html, [{ css: { selector: "span.ver", equals: "1.0" } }]).matched).toBe(true); + expect(checkBodyExpect(html, [{ css: { selector: "div#health", equals: "ERROR" } }]).matched).toBe(false); }); test("css 选择器无匹配元素", () => { const html = "
OK
"; - expect(checkBodyExpect(html, { css: { "span.missing": "OK" } })).toBe(false); + const r = checkBodyExpect(html, [{ css: { selector: "span.missing", equals: "OK" } }]); + expect(r.matched).toBe(false); }); test("css attr 提取", () => { - const html = ''; - expect(checkBodyExpect(html, { css: { 'meta[name="version"]': { attr: "content", equals: "2.0.1" } } })).toBe(true); + const html = ''; expect( - checkBodyExpect(html, { css: { 'meta[name="version"]': { attr: "content", match: "\\d+\\.\\d+\\.\\d+" } } }), + 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); - expect(checkBodyExpect(html, { css: { 'link[rel="icon"]': { attr: "href", contains: "favicon" } } })).toBe(true); }); test("css exists 检查", () => { const html = "
OK
"; - expect(checkBodyExpect(html, { css: { "div#test": { exists: true } } })).toBe(true); - expect(checkBodyExpect(html, { css: { "span#missing": { exists: false } } })).toBe(true); - expect(checkBodyExpect(html, { css: { "div#test": { exists: false } } })).toBe(false); + expect(checkBodyExpect(html, [{ css: { selector: "div#test", exists: true } }]).matched).toBe(true); + expect(checkBodyExpect(html, [{ css: { selector: "span#missing", exists: false } }]).matched).toBe(true); + expect(checkBodyExpect(html, [{ css: { selector: "div#test", exists: false } }]).matched).toBe(false); }); test("xpath 节点文本匹配", () => { const xml = "ok200"; - expect(checkBodyExpect(xml, { xpath: { "/root/status/text()": "ok" } })).toBe(true); - expect(checkBodyExpect(xml, { xpath: { "/root/status/text()": "error" } })).toBe(false); + expect(checkBodyExpect(xml, [{ xpath: { path: "/root/status/text()", equals: "ok" } }]).matched).toBe(true); + expect(checkBodyExpect(xml, [{ xpath: { path: "/root/status/text()", equals: "error" } }]).matched).toBe(false); }); test("xpath 无匹配节点", () => { const xml = "ok"; - expect(checkBodyExpect(xml, { xpath: { "/root/missing/text()": "ok" } })).toBe(false); + const r = checkBodyExpect(xml, [{ xpath: { path: "/root/missing/text()", equals: "ok" } }]); + expect(r.matched).toBe(false); }); - test("xpath 包含匹配", () => { - const html = "
success
"; - expect(checkBodyExpect(html, { xpath: { "//div[@id='msg']/text()": "success" } })).toBe(true); - }); - - test("多种 body 方法 AND 组合", () => { - const body = JSON.stringify({ status: "healthy", count: 5 }); - expect( - checkBodyExpect(body, { - contains: "healthy", - json: { "$.status": "healthy", "$.count": { gte: 1 } }, - }), - ).toBe(true); - }); - - test("多种 body 方法部分失败", () => { + test("规则数组按顺序检查,第一条失败立即返回", () => { const body = JSON.stringify({ status: "error" }); - expect( - checkBodyExpect(body, { - contains: "healthy", - json: { "$.status": "error" }, - }), - ).toBe(false); + const r = checkBodyExpect(body, [{ contains: "healthy" }, { json: { path: "$.status", equals: "error" } }]); + expect(r.matched).toBe(false); + expect(r.failure!.path).toBe("body[0]"); + }); + + test("多条规则全部通过", () => { + const body = JSON.stringify({ status: "healthy", count: 5 }); + const r = checkBodyExpect(body, [ + { contains: "healthy" }, + { json: { path: "$.status", equals: "healthy" } }, + { json: { path: "$.count", gte: 1 } }, + ]); + expect(r.matched).toBe(true); + expect(r.failure).toBeNull(); + }); + + test("第二条规则失败返回正确索引", () => { + const body = JSON.stringify({ status: "ok" }); + const r = checkBodyExpect(body, [{ contains: "ok" }, { json: { path: "$.status", equals: "error" } }]); + expect(r.matched).toBe(false); + expect(r.failure!.path).toContain("body[1]"); }); }); diff --git a/tests/server/checker/expect/command.test.ts b/tests/server/checker/expect/command.test.ts new file mode 100644 index 0000000..ff0a919 --- /dev/null +++ b/tests/server/checker/expect/command.test.ts @@ -0,0 +1,168 @@ +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/failure.test.ts b/tests/server/checker/expect/failure.test.ts new file mode 100644 index 0000000..04fca58 --- /dev/null +++ b/tests/server/checker/expect/failure.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test"; +import { truncateActual, mismatchFailure, errorFailure } from "../../../../src/server/checker/expect/failure"; + +describe("truncateActual", () => { + test("短字符串不截断", () => { + expect(truncateActual("hello")).toBe("hello"); + }); + + test("恰好等于限制长度不截断", () => { + const str = "a".repeat(200); + expect(truncateActual(str)).toBe(str); + }); + + test("超过限制长度截断并加省略号", () => { + const str = "a".repeat(300); + const result = truncateActual(str) as string; + expect(result.length).toBe(203); + expect(result.endsWith("...")).toBe(true); + expect(result.startsWith("a".repeat(200))).toBe(true); + }); + + test("自定义最大长度", () => { + const str = "abcdefghij"; + const result = truncateActual(str, 5) as string; + expect(result).toBe("abcde..."); + }); + + test("null 不截断", () => { + expect(truncateActual(null)).toBe(null); + }); + + test("undefined 不截断", () => { + expect(truncateActual(undefined)).toBe(undefined); + }); + + test("数字转换为字符串后判断", () => { + expect(truncateActual(42)).toBe(42); + expect(truncateActual(123456789, 3) as string).toBe("123..."); + }); +}); + +describe("mismatchFailure", () => { + test("返回正确的 mismatch 结构", () => { + const f = mismatchFailure("status", "status", [200], 500, "status mismatch"); + expect(f).toEqual({ + kind: "mismatch", + phase: "status", + path: "status", + expected: [200], + actual: 500, + message: "status mismatch", + }); + }); + + test("自动截断过长的 actual", () => { + const longStr = "x".repeat(300); + const f = mismatchFailure("body", "body[0]", "short", longStr, "too long"); + expect((f.actual as string).endsWith("...")).toBe(true); + expect((f.actual as string).length).toBe(203); + }); +}); + +describe("errorFailure", () => { + test("返回正确的 error 结构", () => { + const f = errorFailure("body", "body[0].json($.x)", "body is not valid JSON"); + expect(f).toEqual({ + kind: "error", + phase: "body", + path: "body[0].json($.x)", + message: "body is not valid JSON", + }); + }); + + test("不含 expected 和 actual 字段", () => { + const f = errorFailure("headers", "headers.x", "header missing"); + expect(f).not.toHaveProperty("expected"); + expect(f).not.toHaveProperty("actual"); + }); +}); diff --git a/tests/server/checker/expect/http.test.ts b/tests/server/checker/expect/http.test.ts new file mode 100644 index 0000000..6d9edd5 --- /dev/null +++ b/tests/server/checker/expect/http.test.ts @@ -0,0 +1,165 @@ +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/fetcher.test.ts b/tests/server/checker/fetcher.test.ts index afac775..9e7c312 100644 --- a/tests/server/checker/fetcher.test.ts +++ b/tests/server/checker/fetcher.test.ts @@ -1,118 +1,258 @@ -import { describe, expect, test } from "bun:test"; -import { checkExpect } from "../../../src/server/checker/fetcher"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { runHttpCheck } from "../../../src/server/checker/fetcher"; -const emptyHeaders: Record = {}; - -describe("checkExpect", () => { - test("无 expect 配置时 matched 为 true", () => { - expect(checkExpect(200, "ok", 100, emptyHeaders, undefined)).toBe(true); - }); - - test("status 匹配", () => { - expect(checkExpect(200, "", 100, emptyHeaders, { status: [200, 201] })).toBe(true); - expect(checkExpect(201, "", 100, emptyHeaders, { status: [200, 201] })).toBe(true); - expect(checkExpect(404, "", 100, emptyHeaders, { status: [200, 201] })).toBe(false); - }); - - test("headers 匹配", () => { - const headers = { "content-type": "application/json", "x-custom": "test" }; - expect(checkExpect(200, "", 100, headers, { headers: { "Content-Type": "application/json" } })).toBe(true); - expect(checkExpect(200, "", 100, headers, { headers: { "Content-Type": "text/html" } })).toBe(false); - expect(checkExpect(200, "", 100, headers, { headers: { "X-Missing": "test" } })).toBe(false); - }); - - test("body.contains 匹配", () => { - expect(checkExpect(200, "hello world", 100, emptyHeaders, { body: { contains: "hello" } })).toBe(true); - expect(checkExpect(200, "hello world", 100, emptyHeaders, { body: { contains: "missing" } })).toBe(false); - }); - - test("body.regex 匹配", () => { - expect(checkExpect(200, "status: ok", 100, emptyHeaders, { body: { regex: "status.*ok" } })).toBe(true); - expect(checkExpect(200, "status: error", 100, emptyHeaders, { body: { regex: "status.*ok" } })).toBe(false); - }); - - test("body.json 匹配", () => { - expect( - checkExpect(200, JSON.stringify({ status: "ok" }), 100, emptyHeaders, { body: { json: { "$.status": "ok" } } }), - ).toBe(true); - expect( - checkExpect(200, JSON.stringify({ status: "error" }), 100, emptyHeaders, { - body: { json: { "$.status": "ok" } }, - }), - ).toBe(false); - }); - - test("body.json 解析失败", () => { - expect(checkExpect(200, "not json", 100, emptyHeaders, { body: { json: { "$.status": "ok" } } })).toBe(false); - }); - - test("body 多种方法 AND 组合", () => { - expect( - checkExpect(200, "healthy", 100, emptyHeaders, { - body: { - contains: "healthy", - regex: "healthy", - }, - }), - ).toBe(true); - - expect( - checkExpect(200, "healthy", 100, emptyHeaders, { - body: { - contains: "healthy", - regex: "unhealthy", - }, - }), - ).toBe(false); - }); - - test("maxLatencyMs 匹配", () => { - expect(checkExpect(200, "", 100, emptyHeaders, { maxLatencyMs: 200 })).toBe(true); - expect(checkExpect(200, "", 300, emptyHeaders, { maxLatencyMs: 200 })).toBe(false); - expect(checkExpect(200, "", 200, emptyHeaders, { maxLatencyMs: 200 })).toBe(true); - }); - - test("多条 expect 全部通过", () => { - expect( - checkExpect(200, "healthy", 100, emptyHeaders, { - status: [200], - body: { contains: "healthy" }, - maxLatencyMs: 200, - }), - ).toBe(true); - }); - - test("多条 expect 部分失败", () => { - expect( - checkExpect(200, "healthy", 500, emptyHeaders, { - status: [200], - body: { contains: "healthy" }, - maxLatencyMs: 200, - }), - ).toBe(false); - }); - - test("status + headers + body + maxLatencyMs 全组合", () => { - const headers = { "content-type": "application/json" }; - expect( - checkExpect(200, JSON.stringify({ status: "ok" }), 100, headers, { - status: [200], - headers: { "Content-Type": "application/json" }, - body: { contains: "ok", json: { "$.status": "ok" } }, - maxLatencyMs: 200, - }), - ).toBe(true); - }); - - test("全组合中 headers 失败", () => { - const headers = { "content-type": "text/html" }; - expect( - checkExpect(200, JSON.stringify({ status: "ok" }), 100, headers, { - status: [200], - headers: { "Content-Type": "application/json" }, - body: { contains: "ok", json: { "$.status": "ok" } }, - maxLatencyMs: 200, - }), - ).toBe(false); +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(); + }); +}); + +describe("runHttpCheck 集成", () => { + let server: ReturnType; + let baseUrl: string; + + beforeAll(() => { + server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url); + switch (url.pathname) { + case "/ok": + return new Response("hello world", { + headers: { "content-type": "text/plain", "x-custom": "test-value" }, + }); + case "/json": + return new Response(JSON.stringify({ status: "ok" }), { + headers: { "content-type": "application/json" }, + }); + case "/echo": + return new Response(JSON.stringify({ method: req.method, body: req.body ? "present" : "empty" }), { + headers: { "content-type": "application/json" }, + }); + case "/large": + 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"); + } + }, + }); + baseUrl = `http://localhost:${server.port}`; + }); + + afterAll(() => { + server.stop(); + }); + + function makeTarget(overrides: { + url?: string; + method?: string; + body?: string; + headers?: Record; + expect?: Record; + maxBodyBytes?: number; + timeoutMs?: number; + }) { + return { + type: "http" as const, + name: "test-http", + http: { + url: overrides.url ?? `${baseUrl}/ok`, + method: overrides.method ?? "GET", + headers: overrides.headers ?? ({} as Record), + 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, + }; + } + + test("成功请求 200", async () => { + const result = await runHttpCheck(makeTarget({ url: `${baseUrl}/ok` })); + expect(result.success).toBe(true); + expect(result.matched).toBe(true); + expect(result.statusDetail).toBe("HTTP 200"); + expect(result.durationMs).not.toBeNull(); + expect(result.failure).toBeNull(); + }); + + test("404 不匹配默认 status [200]", async () => { + const result = await runHttpCheck(makeTarget({ url: `${baseUrl}/notfound` })); + expect(result.success).toBe(false); + 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] }, + }), + ); + expect(result.success).toBe(true); + expect(result.matched).toBe(true); + }); + + test("headers 检查通过", async () => { + const result = await runHttpCheck( + makeTarget({ + url: `${baseUrl}/ok`, + expect: { headers: { "x-custom": "test-value" } }, + }), + ); + expect(result.success).toBe(true); + expect(result.matched).toBe(true); + }); + + test("headers 检查失败", async () => { + const result = await runHttpCheck( + makeTarget({ + url: `${baseUrl}/ok`, + expect: { headers: { "x-custom": "wrong-value" } }, + }), + ); + expect(result.success).toBe(false); + expect(result.failure!.phase).toBe("headers"); + }); + + test("body contains 检查", async () => { + const result = await runHttpCheck( + makeTarget({ + url: `${baseUrl}/ok`, + expect: { body: [{ contains: "hello" }] }, + }), + ); + expect(result.success).toBe(true); + expect(result.matched).toBe(true); + }); + + test("body contains 失败", async () => { + const result = await runHttpCheck( + makeTarget({ + url: `${baseUrl}/ok`, + expect: { body: [{ contains: "nonexistent" }] }, + }), + ); + expect(result.success).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" } }] }, + }), + ); + expect(result.success).toBe(true); + }); + + test("响应体超过 maxBodyBytes", async () => { + const result = await runHttpCheck( + makeTarget({ + url: `${baseUrl}/large`, + maxBodyBytes: 100, + expect: { body: [{ contains: "x" }] }, + }), + ); + expect(result.success).toBe(false); + expect(result.failure).not.toBeNull(); + expect(result.failure!.phase).toBe("body"); + expect(result.failure!.message).toContain("超过限制"); + }); + + test("请求超时", async () => { + const timeoutServer = Bun.serve({ + port: 0, + async fetch() { + await new Promise((resolve) => setTimeout(resolve, 10000)); + return new Response("late"); + }, + }); + + try { + const result = await runHttpCheck( + makeTarget({ + url: `http://localhost:${timeoutServer.port}/`, + timeoutMs: 100, + }), + ); + expect(result.success).toBe(false); + expect(result.failure).not.toBeNull(); + expect(result.failure!.message).toContain("超时"); + } finally { + timeoutServer.stop(); + } + }); + + test("快速失败:status 失败时不读取 body", async () => { + const result = await runHttpCheck( + makeTarget({ + url: `${baseUrl}/notfound`, + expect: { status: [200], body: [{ contains: "something" }] }, + }), + ); + expect(result.success).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.success).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" }] }, + }), + ); + expect(result.success).toBe(false); + expect(result.failure!.phase).toBe("body"); + }); + + test("无 expect 时默认检查 status 200", async () => { + const result = await runHttpCheck(makeTarget({ url: `${baseUrl}/ok`, expect: undefined })); + expect(result.success).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" } }] }, + }), + ); + expect(result.success).toBe(true); + }); + + test("仅 contains 规则时不解析 JSON", async () => { + const result = await runHttpCheck( + makeTarget({ + url: `${baseUrl}/ok`, + expect: { body: [{ contains: "hello world" }] }, + }), + ); + expect(result.success).toBe(true); }); }); diff --git a/tests/server/checker/size.test.ts b/tests/server/checker/size.test.ts new file mode 100644 index 0000000..b57255c --- /dev/null +++ b/tests/server/checker/size.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; +import { parseSize, DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_OUTPUT_BYTES } from "../../../src/server/checker/size"; + +describe("parseSize", () => { + test("解析 B", () => { + expect(parseSize("1024B")).toBe(1024); + expect(parseSize("0B")).toBe(0); + }); + + test("解析 KB", () => { + expect(parseSize("1KB")).toBe(1024); + expect(parseSize("512KB")).toBe(524288); + }); + + test("解析 MB", () => { + expect(parseSize("1MB")).toBe(1048576); + expect(parseSize("100MB")).toBe(104857600); + }); + + test("解析 GB", () => { + expect(parseSize("1GB")).toBe(1073741824); + }); + + test("解析小数", () => { + expect(parseSize("1.5MB")).toBe(1572864); + }); + + test("数字直接返回", () => { + expect(parseSize(2048)).toBe(2048); + }); + + test("无效格式抛出错误", () => { + expect(() => parseSize("100")).toThrow("无效的 size 格式"); + expect(() => parseSize("100MBB")).toThrow("无效的 size 格式"); + expect(() => parseSize("abc")).toThrow("无效的 size 格式"); + expect(() => parseSize("")).toThrow("无效的 size 格式"); + }); + + test("默认值", () => { + expect(DEFAULT_MAX_BODY_BYTES).toBe(104857600); + expect(DEFAULT_MAX_OUTPUT_BYTES).toBe(104857600); + }); +}); diff --git a/tests/server/checker/store.test.ts b/tests/server/checker/store.test.ts index 9e666d5..4b949df 100644 --- a/tests/server/checker/store.test.ts +++ b/tests/server/checker/store.test.ts @@ -1,34 +1,42 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { ProbeStore } from "../../../src/server/checker/store"; -import type { ResolvedTarget } from "../../../src/server/checker/types"; +import type { CheckFailure, ResolvedTarget } from "../../../src/server/checker/types"; import { mkdir, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; +const httpTarget: ResolvedTarget = { + type: "http", + name: "test-http", + http: { + url: "https://example.com/health", + method: "GET", + headers: { Accept: "application/json" }, + maxBodyBytes: 104857600, + }, + intervalMs: 30000, + timeoutMs: 10000, + expect: { status: [200], maxDurationMs: 3000 }, +}; + +const commandTarget: ResolvedTarget = { + type: "command", + name: "test-cmd", + command: { + exec: "ping", + args: ["-c", "1", "localhost"], + cwd: "/tmp", + env: {}, + maxOutputBytes: 104857600, + }, + intervalMs: 60000, + timeoutMs: 5000, +}; + describe("ProbeStore", () => { let tempDir: string; let store: ProbeStore; - const target1: ResolvedTarget = { - name: "test-a", - url: "http://a.com", - method: "GET", - headers: {}, - intervalMs: 30000, - timeoutMs: 10000, - }; - - const target2: ResolvedTarget = { - name: "test-b", - url: "http://b.com", - method: "POST", - headers: { "Content-Type": "application/json" }, - body: '{"ping": true}', - intervalMs: 60000, - timeoutMs: 5000, - expect: { status: [200], maxLatencyMs: 3000 }, - }; - beforeAll(async () => { tempDir = join(tmpdir(), `gc-store-test-${Date.now()}`); await mkdir(tempDir, { recursive: true }); @@ -44,42 +52,62 @@ describe("ProbeStore", () => { expect(store.getTargets()).toHaveLength(0); }); - test("同步新增 targets", () => { - store.syncTargets([target1, target2]); + test("同步 http 和 command targets", () => { + store.syncTargets([httpTarget, commandTarget]); const targets = store.getTargets(); expect(targets).toHaveLength(2); - expect(targets[0]!.name).toBe("test-a"); - expect(targets[1]!.name).toBe("test-b"); + expect(targets[0]!.name).toBe("test-http"); + expect(targets[1]!.name).toBe("test-cmd"); }); - test("同步后 target 字段正确", () => { - const targets = store.getTargets(); - const t2 = targets.find((t) => t.name === "test-b")!; - expect(t2.url).toBe("http://b.com"); - expect(t2.method).toBe("POST"); - expect(JSON.parse(t2.headers)).toEqual({ "Content-Type": "application/json" }); - expect(t2.body).toBe('{"ping": true}'); - expect(t2.interval_ms).toBe(60000); - expect(t2.expect).toBe(JSON.stringify({ status: [200], maxLatencyMs: 3000 })); + test("http target 字段正确", () => { + const t = store.getTargets().find((t) => t.name === "test-http")!; + expect(t.type).toBe("http"); + expect(t.target).toBe("https://example.com/health"); + const config = JSON.parse(t.config); + expect(config.url).toBe("https://example.com/health"); + expect(config.method).toBe("GET"); + expect(config.headers).toEqual({ Accept: "application/json" }); + expect(config.maxBodyBytes).toBe(104857600); + expect(t.interval_ms).toBe(30000); + expect(t.timeout_ms).toBe(10000); + expect(JSON.parse(t.expect!)).toEqual({ status: [200], maxDurationMs: 3000 }); + }); + + test("command target 字段正确", () => { + const t = store.getTargets().find((t) => t.name === "test-cmd")!; + expect(t.type).toBe("command"); + expect(t.target).toBe("exec ping -c 1 localhost"); + const config = JSON.parse(t.config); + expect(config.exec).toBe("ping"); + expect(config.args).toEqual(["-c", "1", "localhost"]); + expect(config.cwd).toBe("/tmp"); + expect(config.maxOutputBytes).toBe(104857600); + expect(t.interval_ms).toBe(60000); + expect(t.timeout_ms).toBe(5000); + expect(t.expect).toBeNull(); }); test("同步更新已有 target", () => { - store.syncTargets([{ ...target1, url: "http://a-v2.com" }, target2]); - const targets = store.getTargets(); - const t1 = targets.find((t) => t.name === "test-a")!; - expect(t1.url).toBe("http://a-v2.com"); - expect(targets).toHaveLength(2); + const updated: ResolvedTarget = { + ...httpTarget, + http: { ...httpTarget.http, url: "https://example.com/v2" }, + }; + store.syncTargets([updated, commandTarget]); + const t = store.getTargets().find((t) => t.name === "test-http")!; + expect(t.target).toBe("https://example.com/v2"); + expect(store.getTargets()).toHaveLength(2); }); test("同步删除 target", () => { - store.syncTargets([target1]); + store.syncTargets([httpTarget]); const targets = store.getTargets(); expect(targets).toHaveLength(1); - expect(targets[0]!.name).toBe("test-a"); + expect(targets[0]!.name).toBe("test-http"); }); test("重新同步回来", () => { - store.syncTargets([target1, target2]); + store.syncTargets([httpTarget, commandTarget]); expect(store.getTargets()).toHaveLength(2); }); @@ -87,14 +115,15 @@ describe("ProbeStore", () => { const targets = store.getTargets(); const found = store.getTargetById(targets[0]!.id); expect(found).toBeDefined(); - expect(found!.name).toBe("test-a"); + expect(found!.name).toBe("test-http"); }); test("getTargetById 不存在", () => { expect(store.getTargetById(99999)).toBeNull(); }); - test("写入和查询 check result", () => { + test("写入 check result 并查询", () => { + store.syncTargets([httpTarget, commandTarget]); const targets = store.getTargets(); const t1Id = targets[0]!.id; @@ -102,30 +131,39 @@ describe("ProbeStore", () => { targetId: t1Id, timestamp: "2025-01-01T00:00:00.000Z", success: true, - statusCode: 200, - latencyMs: 150, - error: null, matched: true, + durationMs: 150.5, + statusDetail: "200 OK", + failure: null, }); store.insertCheckResult({ targetId: t1Id, timestamp: "2025-01-01T00:00:30.000Z", success: true, - statusCode: 200, - latencyMs: 300, - error: null, matched: true, + durationMs: 300, + statusDetail: "200 OK", + failure: null, }); + const failure: CheckFailure = { + kind: "error", + phase: "duration", + path: "$.maxDurationMs", + expected: 3000, + actual: 5000, + message: "请求耗时 5000ms 超过限制 3000ms", + }; + store.insertCheckResult({ targetId: t1Id, timestamp: "2025-01-01T00:01:00.000Z", success: false, - statusCode: null, - latencyMs: null, - error: "timeout", matched: false, + durationMs: null, + statusDetail: null, + failure, }); const history = store.getHistory(t1Id, 10); @@ -134,7 +172,11 @@ describe("ProbeStore", () => { const latest = store.getLatestCheck(t1Id)!; expect(latest.success).toBe(0); - expect(latest.error).toBe("timeout"); + expect(latest.failure).not.toBeNull(); + const parsedFailure = JSON.parse(latest.failure!) as CheckFailure; + expect(parsedFailure.kind).toBe("error"); + expect(parsedFailure.phase).toBe("duration"); + expect(parsedFailure.message).toBe("请求耗时 5000ms 超过限制 3000ms"); }); test("getHistory 默认 limit=20", () => { @@ -144,12 +186,12 @@ describe("ProbeStore", () => { for (let i = 0; i < 25; i++) { store.insertCheckResult({ targetId: t1Id, - timestamp: `2025-01-01T00:${String(i).padStart(2, "0")}:00.000Z`, + timestamp: `2025-01-01T01:${String(i).padStart(2, "0")}:00.000Z`, success: true, - statusCode: 200, - latencyMs: 100 + i, - error: null, matched: true, + durationMs: 100 + i, + statusDetail: "200 OK", + failure: null, }); } @@ -157,7 +199,7 @@ describe("ProbeStore", () => { expect(history).toHaveLength(20); }); - test("getTargetStats 计算可用率和延迟", () => { + test("getTargetStats 计算可用率和 duration", () => { const targets = store.getTargets(); const t1Id = targets[0]!.id; @@ -165,17 +207,19 @@ describe("ProbeStore", () => { expect(stats.totalChecks).toBeGreaterThan(0); expect(stats.availability).toBeGreaterThanOrEqual(0); expect(stats.availability).toBeLessThanOrEqual(100); - expect(stats.avgLatencyMs).not.toBeNull(); + expect(stats.avgDurationMs).not.toBeNull(); + expect(typeof stats.avgDurationMs).toBe("number"); }); test("无记录目标的 stats", () => { const targets = store.getTargets(); - const t2Id = targets.find((t) => t.name === "test-b")!.id; + const t2Id = targets.find((t) => t.name === "test-cmd")!.id; const stats = store.getTargetStats(t2Id); expect(stats.totalChecks).toBe(0); expect(stats.availability).toBe(0); - expect(stats.avgLatencyMs).toBeNull(); + expect(stats.avgDurationMs).toBeNull(); + expect(stats.p99DurationMs).toBeNull(); }); test("getSummary 返回总览统计", () => { @@ -183,6 +227,7 @@ describe("ProbeStore", () => { expect(summary.total).toBe(2); expect(summary.up + summary.down).toBe(2); expect(summary.lastCheckTime).not.toBeNull(); + expect(summary.avgDurationMs).not.toBeNull(); }); test("getTrend 返回趋势数据", () => { @@ -191,5 +236,30 @@ describe("ProbeStore", () => { const trend = store.getTrend(t1Id, 24); expect(Array.isArray(trend)).toBe(true); + if (trend.length > 0) { + expect(trend[0]!.hour).toBeDefined(); + expect(trend[0]!.avgDurationMs).toBeDefined(); + expect(trend[0]!.availability).toBeGreaterThanOrEqual(0); + expect(trend[0]!.totalChecks).toBeGreaterThan(0); + } + }); + + test("getSparkline 返回 duration 数组", () => { + const targets = store.getTargets(); + const t1Id = targets[0]!.id; + + const sparkline = store.getSparkline(t1Id); + expect(Array.isArray(sparkline)).toBe(true); + expect(sparkline.length).toBeGreaterThan(0); + for (const val of sparkline) { + expect(typeof val).toBe("number"); + } + }); + + test("关闭后操作不报错", () => { + const closedStore = new ProbeStore(join(tempDir, "closed.db")); + closedStore.close(); + expect(closedStore.getTargets()).toHaveLength(0); + expect(closedStore.getTargetById(1)).toBeNull(); }); });