1
0

feat: 增强 HTTP checker 鲁棒性 — 严格配置校验、完整耗时、流式body、重定向与编码完善

启动期校验: 新增 validate.ts 对 HTTP config/expect/body rule/operator 全方位严格校验
执行语义: body 改为 Web Stream 流式超限中止,durationMs 覆盖完整执行
错误归属: status/header 失败不读 body,phase 分层 request/body,early duration skip body
重定向: 跟随前释放 body,POST/303 改 GET 清理 header,跨 origin 剥离敏感 header
编码: 支持 quoted charset,未知编码返回结构化解码错误
文档: README match→regex+durationMs,DEVELOPMENT 执行流程与错误归属
测试: +63 测试覆盖全部新增场景,325 pass 0 fail
规格: 同步 probe-config/probe-engine/expect-body-checkers 3 个 delta spec
This commit is contained in:
2026-05-13 08:00:05 +08:00
parent 2fd0f206be
commit bce0f8e7a8
14 changed files with 1543 additions and 104 deletions

View File

@@ -52,6 +52,7 @@ src/
http/ HTTP Checker 子包
runner.ts HttpCheckerresolve/execute/serialize
expect.ts HTTP 专用断言status/headers
validate.ts HTTP 配置与 expect 启动期校验
command/ Command Checker 子包
runner.ts CommandCheckerresolve/execute/serialize
expect.ts Command 专用断言exitCode
@@ -183,11 +184,13 @@ export function handleXxx(params, store: ProbeStore, method: string, mode: Runti
**HTTP 校验流程**
```
runHttpCheck → 收集观测(statusCode/headers/body/durationMs)
checkHttpExpect → status → duration → headers → body(可选)
runHttpCheck → 收集观测(statusCode/headers)
status → headers → (early duration)body(按需) → (final duration)
→ 首个失败即停止,返回 CheckFailure
```
HTTP checker 的 `durationMs` 覆盖完整执行(含重定向、响应体读取、解码和 expect 校验。status 或 headers 失败时不读取 body进入 body 前若已超过 `maxDurationMs`,直接返回 duration failure。
**Command 校验流程**
```
@@ -199,7 +202,7 @@ runCommandCheck → 收集观测(exitCode/stdout/stderr/durationMs)
**Body 规则类型**
- `contains`:文本包含匹配
- `regex`:正则表达式匹配
- `regex`:正则表达式匹配注意body 正则字段为 `regex`,不是 `match`
- `json`JSONPath 提取 + 操作符比较(使用 `es-toolkit/isPlainObject` 区分纯值和操作符)
- `css`cheerio CSS 选择器 + 操作符比较
- `xpath`XPath 节点提取 + 操作符比较
@@ -210,7 +213,7 @@ runCommandCheck → 收集观测(exitCode/stdout/stderr/durationMs)
- **API 错误**`{ error: "描述", status: <code> }`,状态码 400/404/405/503
- **CheckFailure**`{ kind: "error"|"mismatch", phase, path, expected?, actual?, message }`
- **错误处理**expect 校验失败记录首个失败原因;网络/超时/进程崩溃统一为 `kind:"error"`
- **错误处理**expect 校验失败记录首个失败原因;网络/超时/进程崩溃统一为 `kind:"error"`,请求/TLS/timeout 错误归属 `phase:"request"`body 超限/解码/解析错误归属 `phase:"body"`
- **日志**:解析失败等非致命异常用 `console.warn`,启动失败用 `console.error` + `process.exit(1)`
### 1.10 测试规范

View File

@@ -123,17 +123,19 @@ targets:
- `status`: 可接受的状态码列表HTTP支持精确状态码和范围模式`"2xx"`)混合配置
- `exitCode`: 可接受的退出码列表Command
- `headers`: 响应头校验HTTP支持 `equals``contains` 等操作符)
- `maxDurationMs`: 最大耗时阈值(毫秒)
- `maxDurationMs`: 最大耗时阈值(毫秒)HTTP 类型覆盖完整执行(含重定向、响应体读取和 expect 校验)
- `body`: HTTP 响应体校验(数组,可组合使用)
- `contains`: 响应体包含的文本
- `match`: 响应体匹配的正则表达式
- `regex`: 响应体匹配的正则表达式
- `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`,也可直接使用数字(字节数)。
大小说明:`maxBodyBytes``maxOutputBytes` 支持单位 `KB``MB``GB`,也可直接使用数字(非负安全整数字节数)。
配置校验:系统启动时严格校验所有已支持字段的类型和格式,非法配置会阻止启动并输出清晰的错误信息。未知字段会被忽略,不影响启动和运行。
时长格式支持:`30s``5m``500ms`
@@ -191,7 +193,7 @@ CLI 只接受一个参数YAML 配置文件路径。
单层判定模型,适用于 HTTP 和 Command 两种类型:
- **matched**: 是否符合 expect 规则(无 expect 时默认为 true
- **matched**: 是否符合 expect 规则(HTTP 无 expect 时默认检查 status 200
- **UP** = matched
- **DOWN** = NOT matched

View File

@@ -128,7 +128,11 @@
- **THEN** 系统 MUST 截断 actual 摘要,而不是持久化完整响应体或命令输出
### Requirement: 状态码范围匹配
系统 SHALL 支持在 `expect.status` 数组中使用范围模式字符串(`"2xx"``"3xx"``"4xx"``"5xx"`),与精确数字混合使用。范围模式 SHALL 匹配对应百位段内的所有状态码。
系统 SHALL 支持在 `expect.status` 数组中使用范围模式字符串(`"1xx"``"2xx"``"3xx"``"4xx"``"5xx"`),与精确数字混合使用。范围模式 SHALL 匹配对应百位段内的所有状态码。其他范围模式 SHALL 在启动期配置校验失败。
#### Scenario: 1xx 范围匹配
- **WHEN** HTTP target 配置 `expect.status: ["1xx"]`,且响应状态码为 101
- **THEN** 系统 SHALL 判定状态码匹配
#### Scenario: 2xx 范围匹配
- **WHEN** HTTP target 配置 `expect.status: ["2xx"]`,且响应状态码为 200
@@ -153,3 +157,57 @@
#### Scenario: 5xx 范围匹配
- **WHEN** HTTP target 配置 `expect.status: ["5xx"]`,且响应状态码为 503
- **THEN** 系统 SHALL 判定状态码匹配
#### Scenario: 非 HTTP 范围模式启动失败
- **WHEN** HTTP target 配置 `expect.status: ["6xx"]`
- **THEN** 系统 SHALL 在启动期配置校验失败
### Requirement: HTTP expect 规则启动期校验
系统 SHALL 在启动期校验 HTTP expect 中已支持字段的类型、格式和可编译表达式。未知字段 SHALL 被忽略,但每个规则对象 MUST 至少包含可产生有效断言的支持字段。
#### Scenario: body rule 使用 regex 字段
- **WHEN** HTTP target 配置 `expect.body: [{regex: "ok|healthy"}]` 且 regex 可编译
- **THEN** 系统 SHALL 接受该配置,并在运行期按 regex body 规则匹配响应体
#### Scenario: body rule 不支持 match 字段
- **WHEN** HTTP target 配置 `expect.body: [{match: "ok"}]` 且该规则没有 contains、regex、json、css、xpath 任一支持字段
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: body rule 忽略未知字段
- **WHEN** HTTP target 配置 `expect.body: [{contains: "ok", note: "ignored"}]`
- **THEN** 系统 SHALL 忽略 note 字段并按 contains 规则校验响应体
#### Scenario: body rule 多支持字段非法
- **WHEN** HTTP target 的同一条 body rule 同时配置 contains 和 regex
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: operator match 正则非法
- **WHEN** HTTP target 的 expect.headers、json、css 或 xpath operator 配置了不可编译的 match 正则
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: operator 数值比较类型非法
- **WHEN** HTTP target 的 expect operator 配置 gt、gte、lt 或 lte且对应值不是有限数字
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: operator 布尔类型非法
- **WHEN** HTTP target 的 expect operator 配置 empty 或 exists且对应值不是布尔值
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: JSONPath 子集非法
- **WHEN** HTTP target 的 json body rule path 不符合系统支持的 JSONPath 子集
- **THEN** 系统 SHALL 在启动期配置校验失败
### Requirement: HTTP body 运行期失败结构化
系统 SHALL 将 HTTP body 运行期失败记录为结构化 CheckFailure并保留与具体规则相关的 phase 和 path。响应内容不符合配置 SHALL 记录为 mismatch响应内容无法按配置解析或解码 SHALL 记录为 error。
#### Scenario: JSON 响应不是合法 JSON
- **WHEN** HTTP target 配置 json body rule但响应体不是合法 JSON
- **THEN** 系统 SHALL 记录 `failure.kind="error"``failure.phase="body"`,且 failure.path SHALL 指向对应 json 规则
#### Scenario: CSS selector 无匹配元素
- **WHEN** HTTP target 配置 css body rule但响应 HTML 中无匹配元素
- **THEN** 系统 SHALL 记录 `failure.kind="mismatch"``failure.phase="body"`,且 failure.path SHALL 指向对应 css 规则
#### Scenario: XPath 无匹配节点
- **WHEN** HTTP target 配置 xpath body rule但响应 XML/HTML 中无匹配节点
- **THEN** 系统 SHALL 记录 `failure.kind="mismatch"``failure.phase="body"`,且 failure.path SHALL 指向对应 xpath 规则

View File

@@ -47,7 +47,7 @@
- **THEN** 系统 SHALL 以错误退出并提示文件不存在
### Requirement: 配置校验
系统 SHALL 在启动时对 YAML 配置进行完整校验,校验失败时以非零状态退出并输出清晰的错误信息。
系统 SHALL 在启动时对 YAML 配置进行完整校验,校验失败时以非零状态退出并输出清晰的错误信息。HTTP checker SHALL 对已支持字段执行严格启动期校验;未知字段 SHALL 被忽略,不触发启动失败且不影响运行行为。
#### Scenario: target 缺少必填字段
- **WHEN** YAML 中某个 target 缺少 name 或 type 字段
@@ -105,10 +105,70 @@
- **WHEN** YAML 中某个 HTTP target 的 `http.ignoreSSL` 不是布尔值
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 ignoreSSL 必须为布尔值
#### Scenario: HTTP headers 类型非法
- **WHEN** YAML 中某个 HTTP target 的 `http.headers` 不是对象,或任一 header 名和值不能作为字符串使用
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 http.headers 格式错误
#### Scenario: HTTP body 类型非法
- **WHEN** YAML 中某个 HTTP target 的 `http.body` 已配置但不是字符串
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 http.body 必须为字符串
#### Scenario: maxBodyBytes 数字非法
- **WHEN** YAML 中某个 HTTP target 的 `http.maxBodyBytes` 或 defaults.http.maxBodyBytes 是负数、非整数或非安全整数
- **THEN** 系统 SHALL 以错误退出,提示 maxBodyBytes 必须为非负安全整数字节数或合法 size 字符串
#### Scenario: status 模式非法
- **WHEN** YAML 中某个 HTTP target 的 `expect.status` 包含不符合 `数字xx` 格式的字符串(如 `"abc"``"2x"`
- **WHEN** YAML 中某个 HTTP target 的 `expect.status` 包含不符合 `1xx``5xx` 格式的字符串(如 `"abc"``"2x"``"6xx"`
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 status 模式不合法
#### Scenario: status 数字非法
- **WHEN** YAML 中某个 HTTP target 的 `expect.status` 包含非整数或不在 100-599 范围内的数字
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 status 数字不合法
#### Scenario: maxDurationMs 非法
- **WHEN** YAML 中某个 target 的 `expect.maxDurationMs` 不是非负有限数字
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 expect.maxDurationMs 格式错误
#### Scenario: HTTP expect headers 非法
- **WHEN** YAML 中某个 HTTP target 的 `expect.headers` 不是对象,或某个 header 期望既不是字符串也不是合法 operator
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 expect.headers 格式错误
#### Scenario: HTTP expect body 必须为数组
- **WHEN** YAML 中某个 HTTP target 的 `expect.body` 已配置但不是数组
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 expect.body 必须为数组
#### Scenario: HTTP body rule 缺少支持字段
- **WHEN** YAML 中某个 HTTP target 的 `expect.body` 数组项未包含 contains、regex、json、css、xpath 任一支持字段
- **THEN** 系统 SHALL 以错误退出,提示该 body rule 缺少支持的规则类型
#### Scenario: HTTP body rule 同时配置多个支持字段
- **WHEN** YAML 中某个 HTTP target 的同一条 body rule 同时包含 contains、regex、json、css、xpath 中的多个支持字段
- **THEN** 系统 SHALL 以错误退出,提示每条 body rule 只能配置一种规则类型
#### Scenario: HTTP body regex 非法
- **WHEN** YAML 中某个 HTTP target 的 body regex 规则不是字符串或不是可编译正则表达式
- **THEN** 系统 SHALL 以错误退出,提示该 body regex 不合法
#### Scenario: HTTP body json path 非法
- **WHEN** YAML 中某个 HTTP target 的 body json 规则缺少 path或 path 不符合系统支持的 JSONPath 子集
- **THEN** 系统 SHALL 以错误退出,提示该 body json path 不合法
#### Scenario: HTTP body css selector 非法
- **WHEN** YAML 中某个 HTTP target 的 body css 规则缺少 selector或 selector 不是非空字符串
- **THEN** 系统 SHALL 以错误退出,提示该 body css selector 不合法
#### Scenario: HTTP body xpath path 非法
- **WHEN** YAML 中某个 HTTP target 的 body xpath 规则缺少 path或 path 不是非空字符串,或可被现有 XPath 库静态判定为语法错误
- **THEN** 系统 SHALL 以错误退出,提示该 body xpath path 不合法
#### Scenario: expect operator 类型非法
- **WHEN** YAML 中某个 HTTP expect operator 的 match 不是可编译正则字符串empty/exists 不是布尔值,或 gt/gte/lt/lte 不是有限数字
- **THEN** 系统 SHALL 以错误退出,提示对应 operator 配置不合法
#### Scenario: unknown 字段忽略
- **WHEN** YAML 中某个 HTTP target、expect 或 rule 对象包含未知字段,且所有已支持字段均合法
- **THEN** 系统 SHALL 忽略未知字段并正常启动
### Requirement: size 配置解析
系统 SHALL 支持使用单位字符串配置读取上限,单位包括 `B``KB``MB``GB`

View File

@@ -31,7 +31,7 @@
- **THEN** 系统 MUST 同时最多运行 3 个检查,其余检查等待并发槽位释放
### Requirement: HTTP 拨测执行
系统 SHALL 对 `type: http` 的目标执行 HTTP 请求,支持 GET、POST、PUT、DELETE、PATCH、HEAD、OPTIONS 方法,并携带 `http.headers``http.body`。系统 SHALL 支持 `http.ignoreSSL` 配置跳过 SSL 证书校验,支持 `http.maxRedirects` 配置控制重定向行为。
系统 SHALL 对 `type: http` 的目标执行 HTTP 请求,支持 GET、POST、PUT、DELETE、PATCH、HEAD、OPTIONS 方法,并携带 `http.headers``http.body`。系统 SHALL 支持 `http.ignoreSSL` 配置跳过 SSL 证书校验,支持 `http.maxRedirects` 配置控制重定向行为。HTTP response body 读取 SHALL 受 `http.maxBodyBytes` 流式限制,重定向跟随 SHALL 释放被跟随响应的 body。
#### Scenario: 执行 GET 请求
- **WHEN** 目标配置 method 为 GET
@@ -47,7 +47,15 @@
#### Scenario: HTTP body 读取上限
- **WHEN** HTTP response body 超过该 target 的 maxBodyBytes
- **THEN** 系统 MUST 停止读取并记录 `matched=false`结构化输出超限错误
- **THEN** 系统 MUST 停止继续读取 response body并记录 `matched=false``failure.kind="error"``failure.phase="body"`结构化输出超限错误
#### Scenario: HTTP body 大小等于上限
- **WHEN** HTTP response body 的字节数等于该 target 的 maxBodyBytes
- **THEN** 系统 SHALL 允许该 body 进入后续解码和 expect 校验
#### Scenario: HTTP body 上限为 0
- **WHEN** HTTP target 配置 maxBodyBytes 为 0 且响应体非空
- **THEN** 系统 SHALL 停止读取并记录 body 超限错误
#### Scenario: 忽略 SSL 证书校验
- **WHEN** 目标配置 `http.ignoreSSL: true` 且目标 URL 为 HTTPS
@@ -63,20 +71,32 @@
#### Scenario: 配置跟随重定向
- **WHEN** 目标配置 `http.maxRedirects: 5` 且服务端返回重定向
- **THEN** 系统 SHALL 跟随重定向,最多跟随 5 次
- **THEN** 系统 SHALL 跟随重定向,最多跟随 5 次,并在跟随前释放当前重定向响应的 body
#### Scenario: 超过最大重定向次数
- **WHEN** 目标配置 `http.maxRedirects: 1` 且服务端连续返回两次重定向
- **THEN** 系统 SHALL 只跟随第一次重定向,并返回第二次重定向响应的状态码和响应头
#### Scenario: POST 重定向改 GET
- **WHEN** POST 请求遇到 301/302 或任意方法请求遇到 303且系统决定按 GET 跟随重定向
- **THEN** 系统 SHALL 移除请求 body并清理 content-type、content-length 等 body 相关 headers 后发起后续 GET 请求
#### Scenario: 跨 origin 重定向敏感 header
- **WHEN** HTTP 请求跟随重定向到不同 origin
- **THEN** 系统 SHALL NOT 将 authorization、cookie 等敏感 headers 转发到新的 origin
#### Scenario: 响应体编码自动检测
- **WHEN** HTTP 响应的 `Content-Type` header 包含 `charset=gbk`
- **WHEN** HTTP 响应的 `Content-Type` header 包含 `charset=gbk``charset="gbk"`
- **THEN** 系统 SHALL 使用 GBK 编码解码响应体,而非硬编码 UTF-8
#### Scenario: 响应体编码回退 UTF-8
- **WHEN** HTTP 响应的 `Content-Type` header 未指定 charset
- **THEN** 系统 SHALL 使用 UTF-8 编码解码响应体
#### Scenario: 响应体编码不支持
- **WHEN** HTTP 响应的 `Content-Type` header 指定了当前运行时不支持的 charset
- **THEN** 系统 SHALL 记录 `matched=false``failure.kind="error"``failure.phase="body"` 的解码失败结果
### Requirement: 请求超时控制
系统 SHALL 对每次 checker 执行实施超时控制,超时时间使用目标配置的 timeout 值。
@@ -93,7 +113,7 @@
- **THEN** 系统 SHALL 正常记录执行结果并进入 expect 校验
### Requirement: expect 校验
系统 SHALL 在 checker 执行完成后根据目标类型的 expect 配置校验观测结果,校验结果和首个失败原因记入 check result。
系统 SHALL 在 checker 执行完成后根据目标类型的 expect 配置校验观测结果,校验结果和首个失败原因记入 check result。HTTP checker 的 `durationMs` SHALL 表示完整 checker 执行耗时,包括重定向、响应体读取、响应体解码和 expect 校验。
#### Scenario: HTTP 默认状态码
- **WHEN** HTTP target 未配置 `expect.status`
@@ -119,6 +139,26 @@
- **WHEN** HTTP target 配置了有序 `expect.body` 规则数组
- **THEN** 系统 SHALL 按数组顺序执行 body 规则,任一失败立即记录 failure 并停止后续规则
#### Scenario: 校验 HTTP 完整耗时阈值
- **WHEN** 目标配置了 `expect.maxDurationMs`,且 HTTP checker 完整执行含重定向、body 读取、解码和 expect后的 durationMs 超过阈值
- **THEN** 系统 SHALL 判定 duration 不匹配,记录完整 durationMs 和 duration failure
#### Scenario: HTTP body 前耗时已超阈值
- **WHEN** HTTP target 配置了 body 校验和 `expect.maxDurationMs`,且进入 body 读取前的已耗时已超过阈值
- **THEN** 系统 SHALL 直接返回 duration failure且 MUST NOT 读取 response body
#### Scenario: HTTP body 失败优先于后续 duration 检查
- **WHEN** HTTP target 配置了 body 校验和 `expect.maxDurationMs`body 阶段存在失败,且完整执行后 duration 也超过阈值
- **THEN** 系统 SHALL 返回 body 阶段的失败首个失败为准durationMs SHALL 记录完整耗时
#### Scenario: HTTP 慢响应体计入耗时
- **WHEN** HTTP target 配置了 body 校验和 `expect.maxDurationMs`,且响应头很快返回但响应体读取导致完整执行耗时超过阈值
- **THEN** 系统 SHALL 判定 duration 不匹配并记录完整 durationMs
#### Scenario: 多条 expect 规则
- **WHEN** 目标同时配置状态、duration、元数据和内容规则
- **THEN** 系统 SHALL 所有规则全部通过时 matched 为 true任一不通过则为 false 并记录首个失败原因
#### Scenario: command 默认 exitCode
- **WHEN** command target 未配置 `expect.exitCode`
- **THEN** 系统 SHALL 按默认 `exitCode: [0]` 校验命令退出码
@@ -127,21 +167,21 @@
- **WHEN** command target 配置了有序 `expect.stdout` 规则数组
- **THEN** 系统 SHALL 按数组顺序执行 stdout 规则,任一失败立即记录 failure 并停止后续规则
#### Scenario: 校验耗时阈值
- **WHEN** 目标配置了 `expect.maxDurationMs`
- **THEN** 系统 SHALL 检查实际 durationMs 是否超过阈值,将匹配结果记录到 matched 字段
#### Scenario: 多条 expect 规则
- **WHEN** 目标同时配置状态、duration、元数据和内容规则
- **THEN** 系统 SHALL 所有规则全部通过时 matched 为 true任一不通过则为 false 并记录首个失败原因
### Requirement: Body 校验按需解析
系统 SHALL 仅在 HTTP target 配置了 body 校验且 status、duration、headers 阶段均通过时才读取并解析响应体,避免不必要的读取和解析开销
系统 SHALL 仅在 HTTP target 配置了 body 校验且 status、headers 阶段均通过,并且进入 body 前未确定 duration 已失败时才读取并解析响应体避免不必要的读取和解析开销。HTTP target 未配置 body 校验时,系统 SHALL NOT 读取 response body
#### Scenario: status 失败时不读取 body
- **WHEN** HTTP target 的 status 阶段不匹配
- **THEN** 系统 SHALL 立即返回 matched=false且 MUST NOT 读取 response body
#### Scenario: headers 失败时不读取 body
- **WHEN** HTTP target 的 status 阶段匹配但 headers 阶段不匹配
- **THEN** 系统 SHALL 立即返回 matched=false且 MUST NOT 读取 response body
#### Scenario: 进入 body 前 duration 已失败时不读取 body
- **WHEN** HTTP target 已配置 `expect.maxDurationMs`,且进入 body 读取前的已耗时已经超过阈值
- **THEN** 系统 SHALL 返回 duration failure且 MUST NOT 读取 response body
#### Scenario: 仅配置 contains 时不解析 JSON
- **WHEN** HTTP target 仅配置 body contains 规则而未配置 json/css/xpath 规则
- **THEN** 系统 SHALL 不执行 JSON.parse 或 HTML/XML 解析
@@ -150,6 +190,21 @@
- **WHEN** HTTP target 配置了 body json 规则但响应体不是合法 JSON
- **THEN** 系统 SHALL 判定 matched 为 false并记录 json 规则对应的 failure.path
### Requirement: HTTP 运行期错误归属
HTTP checker SHALL 将运行期失败归属到实际失败阶段。请求、网络、TLS 和 timeout 错误 SHALL 记录为 request 阶段错误body 超限、响应体解码失败、响应内容解析失败 SHALL 记录为 body 阶段错误expect 不匹配 SHALL 记录为对应 mismatch 阶段。
#### Scenario: 请求错误归属 request
- **WHEN** HTTP 请求因为网络、TLS 或 timeout 失败
- **THEN** 系统 SHALL 记录 `matched=false``failure.kind="error"``failure.phase="request"`
#### Scenario: body 超限归属 body
- **WHEN** HTTP response body 超过 maxBodyBytes
- **THEN** 系统 SHALL 记录 `failure.kind="error"``failure.phase="body"``failure.path="body"`
#### Scenario: body 解析错误归属 body
- **WHEN** HTTP response body 已读取但解码、JSON 解析、CSS 解析或 XPath 解析失败
- **THEN** 系统 SHALL 记录 `failure.phase="body"`,且 SHALL NOT 将该失败记录为 request 错误
### Requirement: 拨测结果记录
系统 SHALL 在每次 checker 完成后,将结果写入 SQLite 数据存储,包含 target_id、timestamp、matched、duration_ms、status_detail、failure 字段。

View File

@@ -59,9 +59,6 @@ export function checkHttpExpect(
const statusResult = checkStatus(statusCode, expect.status ?? [200]);
if (!statusResult.matched) return statusResult;
const durationResult = checkDuration(durationMs, expect.maxDurationMs);
if (!durationResult.matched) return durationResult;
const headersResult = checkHeaders(headers, expect.headers);
if (!headersResult.matched) return headersResult;
@@ -76,6 +73,9 @@ export function checkHttpExpect(
if (!bodyResult.matched) return bodyResult;
}
const durationResult = checkDuration(durationMs, expect.maxDurationMs);
if (!durationResult.matched) return durationResult;
return { failure: null, matched: true };
}

View File

@@ -4,14 +4,17 @@ import type { CheckResult, HttpTargetConfig, ResolvedHttpTarget, ResolvedTarget,
import type { Checker, CheckerContext, ResolveContext } from "../types";
import { parseSize } from "../../size";
import { errorFailure } from "../shared/failure";
import { checkHttpExpect } from "./expect";
import { checkBodyExpect } from "../shared/body";
import { checkDuration } from "../shared/duration";
import { errorFailure, mismatchFailure } from "../shared/failure";
import { checkHeaders, checkStatus } from "./expect";
import { validateHttpConfig, validateHttpExpect } from "./validate";
const ALLOWED_METHODS = new Set(["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]);
const CHARSET_RE = /charset=([^\s;]+)/i;
const STATUS_RANGE_RE = /^\dxx$/;
const CHARSET_RE = /charset="?([^";\s]+)"?/i;
const URL_RE = /^https?:\/\/.+/;
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
const SENSITIVE_HEADERS = new Set(["authorization", "cookie"]);
export class HttpChecker implements Checker {
readonly type = "http";
@@ -19,10 +22,10 @@ export class HttpChecker implements Checker {
async execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult> {
const t = target as ResolvedHttpTarget;
const timestamp = new Date().toISOString();
const expect = t.expect;
const start = performance.now();
try {
const start = performance.now();
const response = await fetchWithRedirects(t.http.url, t.http.maxRedirects, {
body: t.http.method !== "GET" && t.http.method !== "HEAD" ? t.http.body : undefined,
headers: t.http.headers,
@@ -32,63 +35,73 @@ export class HttpChecker implements Checker {
...(t.http.ignoreSSL ? { tls: { rejectUnauthorized: false } } : {}),
});
const durationMs = Math.round(performance.now() - start);
const statusCode = response.status;
const responseHeaders = Object.fromEntries(response.headers);
const hasBodyRules = !!(t.expect?.body && t.expect.body.length > 0);
const preBodyExpect = t.expect
? { headers: t.expect.headers, maxDurationMs: t.expect.maxDurationMs, status: t.expect.status }
: undefined;
const preBodyResult = checkHttpExpect(statusCode, responseHeaders, null, durationMs, preBodyExpect);
if (!hasBodyRules || !preBodyResult.matched) {
return {
durationMs,
failure: preBodyResult.failure,
matched: preBodyResult.matched,
statusDetail: `HTTP ${statusCode}`,
targetName: t.name,
timestamp,
};
const statusResult = checkStatus(statusCode, expect?.status ?? [200]);
if (!statusResult.matched) {
return makeResult(t, timestamp, performance.now() - start, statusResult.failure, statusCode);
}
const bodyBuffer = await response.arrayBuffer();
if (bodyBuffer.byteLength > t.http.maxBodyBytes) {
return {
durationMs,
failure: errorFailure("body", "body", `响应体大小 ${bodyBuffer.byteLength} 超过限制 ${t.http.maxBodyBytes}`),
matched: false,
statusDetail: `HTTP ${statusCode}`,
targetName: t.name,
timestamp,
};
const headersResult = checkHeaders(responseHeaders, expect?.headers);
if (!headersResult.matched) {
return makeResult(t, timestamp, performance.now() - start, headersResult.failure, statusCode);
}
const contentType = response.headers.get("content-type") ?? "";
const charsetMatch = CHARSET_RE.exec(contentType);
const encoding = charsetMatch?.[1] ?? "utf-8";
const body = new TextDecoder(encoding).decode(bodyBuffer);
const fullResult = checkHttpExpect(statusCode, responseHeaders, body, durationMs, t.expect);
const hasBodyRules = !!(expect?.body && expect.body.length > 0);
return {
durationMs,
failure: fullResult.failure,
matched: fullResult.matched,
statusDetail: `HTTP ${statusCode}`,
targetName: t.name,
timestamp,
};
if (hasBodyRules && expect?.maxDurationMs !== undefined) {
const elapsed = performance.now() - start;
if (elapsed > expect.maxDurationMs) {
const durationMs = Math.round(elapsed);
return makeResult(
t,
timestamp,
elapsed,
mismatchFailure(
"duration",
"duration",
`<=${expect.maxDurationMs}ms`,
durationMs,
`duration ${durationMs}ms > ${expect.maxDurationMs}ms`,
),
statusCode,
);
}
}
if (hasBodyRules) {
const bodyReadResult = await readBodyStream(response, t.http.maxBodyBytes);
if (!bodyReadResult.ok) {
return makeResult(t, timestamp, performance.now() - start, bodyReadResult.failure, statusCode);
}
const decodeResult = decodeBody(bodyReadResult.data, response.headers);
if (!decodeResult.ok) {
return makeResult(t, timestamp, performance.now() - start, decodeResult.failure, statusCode);
}
const bodyResult = checkBodyExpect(decodeResult.text, expect.body);
if (!bodyResult.matched) {
return makeResult(t, timestamp, performance.now() - start, bodyResult.failure, statusCode);
}
}
const durationMs = Math.round(performance.now() - start);
const durationResult = checkDuration(durationMs, expect?.maxDurationMs);
if (!durationResult.matched) {
return makeResult(t, timestamp, durationMs, durationResult.failure, statusCode);
}
return makeResult(t, timestamp, durationMs, null, statusCode);
} catch (error) {
const durationMs = Math.round(performance.now() - start);
const isTimeout = ctx.signal.aborted || (error instanceof DOMException && error.name === "AbortError");
return {
durationMs: null,
durationMs,
failure: errorFailure(
"status",
"request",
"request",
isTimeout ? `请求超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
),
@@ -108,6 +121,8 @@ export class HttpChecker implements Checker {
throw new Error(`target "${t.name}" 缺少 http.url 字段`);
}
validateHttpConfig(t.http, t.name);
if (typeof t.http.url !== "string" || t.http.url.trim() === "") {
throw new Error(`target "${t.name}" 缺少 http.url 字段`);
}
@@ -139,22 +154,7 @@ export class HttpChecker implements Checker {
throw new Error(`target "${t.name}" 的 http.maxRedirects 必须为非负整数`);
}
const statusPatterns = target.expect && "status" in target.expect ? target.expect.status : undefined;
if (statusPatterns) {
if (!Array.isArray(statusPatterns)) {
throw new Error(`target "${t.name}" 的 expect.status 必须为数组`);
}
for (const p of statusPatterns) {
if (typeof p !== "number" && typeof p !== "string") {
throw new Error(`target "${t.name}" 的 expect.status 只能包含数字或范围模式字符串`);
}
if (typeof p === "string" && !STATUS_RANGE_RE.test(p)) {
throw new Error(
`target "${t.name}" 的 expect.status 模式 "${p}" 不合法,字符串必须为 "Nxx" 格式(如 "2xx"`,
);
}
}
}
validateHttpExpect(target.expect, t.name);
const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB");
@@ -194,13 +194,62 @@ export class HttpChecker implements Checker {
}
}
function buildRedirectInit(init: RequestInit, statusCode: number): RequestInit {
function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: string, toUrl: string): RequestInit {
let newInit = { ...init };
const method = init.method?.toUpperCase();
if (statusCode === 303 || ((statusCode === 301 || statusCode === 302) && method === "POST")) {
return { ...init, body: undefined, method: "GET" };
const headers =
typeof init.headers === "object" && init.headers !== null
? { ...(init.headers as Record<string, string>) }
: undefined;
if (headers) {
for (const key of Object.keys(headers)) {
const lower = key.toLowerCase();
if (lower === "content-type" || lower === "content-length") {
delete headers[key];
}
}
}
newInit = { ...newInit, body: undefined, headers, method: "GET" };
}
return init;
try {
const fromOrigin = new URL(fromUrl).origin;
const toOrigin = new URL(toUrl).origin;
if (fromOrigin !== toOrigin && newInit.headers && typeof newInit.headers === "object") {
const headers = { ...(newInit.headers as Record<string, string>) };
for (const key of Object.keys(headers)) {
if (SENSITIVE_HEADERS.has(key.toLowerCase())) {
delete headers[key];
}
}
newInit.headers = headers;
}
} catch {
/* URL parsing failed, keep headers */
}
return newInit;
}
function decodeBody(
data: Uint8Array,
headers: Headers,
): { failure: CheckResult["failure"]; ok: false } | { ok: true; text: string } {
const contentType = headers.get("content-type") ?? "";
const charsetMatch = CHARSET_RE.exec(contentType);
const encoding = charsetMatch?.[1]?.toLowerCase() ?? "utf-8";
try {
const text = new TextDecoder(encoding).decode(data);
return { ok: true, text };
} catch {
return {
failure: errorFailure("body", "body", `不支持的字符编码: ${encoding}`),
ok: false,
};
}
}
async function fetchWithRedirects(url: string, maxRedirects: number, init: RequestInit): Promise<Response> {
@@ -214,7 +263,77 @@ async function fetchWithRedirects(url: string, maxRedirects: number, init: Reque
const location = response.headers.get("location");
if (!location || followed >= maxRedirects) return response;
currentUrl = new URL(location, currentUrl).toString();
currentInit = buildRedirectInit(currentInit, response.status);
try {
await response.arrayBuffer();
} catch {
/* ignore body drain error */
}
const nextUrl = new URL(location, currentUrl).toString();
currentInit = buildRedirectInit(currentInit, response.status, currentUrl, nextUrl);
currentUrl = nextUrl;
}
}
function makeResult(
t: ResolvedHttpTarget,
timestamp: string,
elapsed: number,
failure: CheckResult["failure"],
statusCode: number,
): CheckResult {
return {
durationMs: Math.round(elapsed),
failure,
matched: failure === null,
statusDetail: `HTTP ${statusCode}`,
targetName: t.name,
timestamp,
};
}
async function readBodyStream(
response: Response,
maxBodyBytes: number,
): Promise<{ data: Uint8Array; ok: true } | { failure: CheckResult["failure"]; ok: false }> {
const reader = response.body?.getReader();
if (!reader) {
return { data: new Uint8Array(0), ok: true };
}
const chunks: Uint8Array[] = [];
let totalBytes = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > maxBodyBytes) {
try {
await reader.cancel();
} catch {
/* ignore cancel error */
}
return {
failure: errorFailure("body", "body", `响应体大小超过限制 ${maxBodyBytes}`),
ok: false,
};
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
const result = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.byteLength;
}
return { data: result, ok: true };
}

View File

@@ -0,0 +1,251 @@
import { DOMParser } from "@xmldom/xmldom";
import * as xpath from "xpath";
const BODY_RULE_TYPES = ["contains", "regex", "json", "css", "xpath"];
const OPERATOR_KEYS = new Set(["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "match"]);
export function validateHttpConfig(http: unknown, targetName: string): void {
if (!http || typeof http !== "object") {
throw new Error(`target "${targetName}" 缺少 http 配置`);
}
const h = http as Record<string, unknown>;
if ("headers" in h && h["headers"] !== undefined) {
if (typeof h["headers"] !== "object" || h["headers"] === null || Array.isArray(h["headers"])) {
throw new Error(`target "${targetName}" 的 http.headers 必须为对象`);
}
for (const [key, value] of Object.entries(h["headers"] as Record<string, unknown>)) {
if (typeof value !== "string") {
throw new Error(`target "${targetName}" 的 http.headers.${key} 必须为字符串`);
}
}
}
if ("body" in h && h["body"] !== undefined) {
if (typeof h["body"] !== "string") {
throw new Error(`target "${targetName}" 的 http.body 必须为字符串`);
}
}
}
export function validateHttpExpect(expect: unknown, targetName: string): void {
if (expect === undefined || expect === null) return;
if (typeof expect !== "object" || Array.isArray(expect)) {
throw new Error(`target "${targetName}" 的 expect 必须为对象`);
}
const e = expect as Record<string, unknown>;
if ("status" in e) validateStatus(e["status"], targetName);
if ("maxDurationMs" in e) validateMaxDurationMs(e["maxDurationMs"], targetName);
if ("headers" in e) validateExpectHeaders(e["headers"], targetName);
if ("body" in e) validateBodyRules(e["body"], targetName);
}
function validateBodyRules(body: unknown, targetName: string): void {
if (!Array.isArray(body)) {
throw new Error(`target "${targetName}" 的 expect.body 必须为数组`);
}
for (let i = 0; i < body.length; i++) {
validateSingleBodyRule(body[i], i, targetName);
}
}
function validateExpectHeaders(headers: unknown, targetName: string): void {
if (typeof headers !== "object" || headers === null || Array.isArray(headers)) {
throw new Error(`target "${targetName}" 的 expect.headers 必须为对象`);
}
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
if (typeof value === "string") continue;
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
validateOperators(value as Record<string, unknown>, targetName, `expect.headers.${key}`);
} else {
throw new Error(`target "${targetName}" 的 expect.headers.${key} 必须为字符串或操作符对象`);
}
}
}
function validateJsonPath(path: string, targetName: string, rulePath: string): void {
const segments = path.slice(2).split(".");
for (const seg of segments) {
if (seg === "") {
throw new Error(`target "${targetName}" 的 ${rulePath}.path 包含空段`);
}
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
if (bracketMatch?.[1]!.trim() === "") {
throw new Error(`target "${targetName}" 的 ${rulePath}.path 数组访问缺少属性名`);
}
}
}
function validateMaxDurationMs(value: unknown, targetName: string): void {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new Error(`target "${targetName}" 的 expect.maxDurationMs 必须为非负有限数字`);
}
}
function validateOperators(ops: Record<string, unknown>, targetName: string, path: string): void {
for (const [key, value] of Object.entries(ops)) {
if (!OPERATOR_KEYS.has(key)) continue;
switch (key) {
case "contains":
if (typeof value !== "string") {
throw new Error(`target "${targetName}" 的 ${path}.contains 必须为字符串`);
}
break;
case "empty":
case "exists":
if (typeof value !== "boolean") {
throw new Error(`target "${targetName}" 的 ${path}.${key} 必须为布尔值`);
}
break;
case "equals":
if (typeof value !== "boolean" && typeof value !== "number" && typeof value !== "string" && value !== null) {
throw new Error(`target "${targetName}" 的 ${path}.equals 类型不合法`);
}
if (typeof value === "number" && !Number.isFinite(value)) {
throw new Error(`target "${targetName}" 的 ${path}.equals 不能为 NaN 或 Infinity`);
}
break;
case "gt":
case "gte":
case "lt":
case "lte":
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error(`target "${targetName}" 的 ${path}.${key} 必须为有限数字`);
}
break;
case "match":
if (typeof value !== "string") {
throw new Error(`target "${targetName}" 的 ${path}.match 必须为字符串`);
}
try {
new RegExp(value);
} catch {
throw new Error(`target "${targetName}" 的 ${path}.match 正则不合法`);
}
break;
}
}
}
function validateSingleBodyRule(rule: unknown, index: number, targetName: string): void {
if (typeof rule !== "object" || rule === null) {
throw new Error(`target "${targetName}" 的 expect.body[${index}] 必须为对象`);
}
const ruleObj = rule as Record<string, unknown>;
const found: string[] = [];
for (const type of BODY_RULE_TYPES) {
if (type in ruleObj) found.push(type);
}
if (found.length === 0) {
throw new Error(
`target "${targetName}" 的 expect.body[${index}] 缺少支持的规则类型contains/regex/json/css/xpath`,
);
}
if (found.length > 1) {
throw new Error(
`target "${targetName}" 的 expect.body[${index}] 只能配置一种规则类型,当前包含: ${found.join(", ")}`,
);
}
const ruleType = found[0]!;
const rulePath = `expect.body[${index}]`;
switch (ruleType) {
case "contains":
if (typeof ruleObj["contains"] !== "string") {
throw new Error(`target "${targetName}" 的 ${rulePath}.contains 必须为字符串`);
}
break;
case "css": {
const cssRule = ruleObj["css"];
if (typeof cssRule !== "object" || cssRule === null) {
throw new Error(`target "${targetName}" 的 ${rulePath}.css 必须为对象`);
}
const cr = cssRule as Record<string, unknown>;
if (typeof cr["selector"] !== "string" || cr["selector"].trim() === "") {
throw new Error(`target "${targetName}" 的 ${rulePath}.css.selector 必须为非空字符串`);
}
const cssOps: Record<string, unknown> = {};
for (const [k, v] of Object.entries(cr)) {
if (k !== "selector" && k !== "attr") cssOps[k] = v;
}
validateOperators(cssOps, targetName, `${rulePath}.css`);
break;
}
case "json": {
const jsonRule = ruleObj["json"];
if (typeof jsonRule !== "object" || jsonRule === null) {
throw new Error(`target "${targetName}" 的 ${rulePath}.json 必须为对象`);
}
const jr = jsonRule as Record<string, unknown>;
if (typeof jr["path"] !== "string" || !jr["path"].startsWith("$.") || jr["path"].length <= 2) {
throw new Error(`target "${targetName}" 的 ${rulePath}.json.path 必须为以 "$." 开头的有效 JSONPath`);
}
validateJsonPath(jr["path"], targetName, `${rulePath}.json`);
const jsonOps: Record<string, unknown> = {};
for (const [k, v] of Object.entries(jr)) {
if (k !== "path") jsonOps[k] = v;
}
validateOperators(jsonOps, targetName, `${rulePath}.json`);
break;
}
case "regex":
if (typeof ruleObj["regex"] !== "string") {
throw new Error(`target "${targetName}" 的 ${rulePath}.regex 必须为字符串`);
}
try {
new RegExp(ruleObj["regex"]);
} catch {
throw new Error(`target "${targetName}" 的 ${rulePath}.regex 正则不合法`);
}
break;
case "xpath": {
const xpathRule = ruleObj["xpath"];
if (typeof xpathRule !== "object" || xpathRule === null) {
throw new Error(`target "${targetName}" 的 ${rulePath}.xpath 必须为对象`);
}
const xr = xpathRule as Record<string, unknown>;
if (typeof xr["path"] !== "string" || xr["path"].trim() === "") {
throw new Error(`target "${targetName}" 的 ${rulePath}.xpath.path 必须为非空字符串`);
}
try {
const doc = new DOMParser().parseFromString("<x/>", "text/xml");
xpath.select(xr["path"], doc as unknown as Node);
} catch {
throw new Error(`target "${targetName}" 的 ${rulePath}.xpath.path xpath 不合法`);
}
const xpathOps: Record<string, unknown> = {};
for (const [k, v] of Object.entries(xr)) {
if (k !== "path") xpathOps[k] = v;
}
validateOperators(xpathOps, targetName, `${rulePath}.xpath`);
break;
}
}
}
function validateStatus(status: unknown, targetName: string): void {
if (!Array.isArray(status)) {
throw new Error(`target "${targetName}" 的 expect.status 必须为数组`);
}
for (const p of status) {
if (typeof p === "number") {
if (!Number.isInteger(p) || p < 100 || p > 599) {
throw new Error(`target "${targetName}" 的 expect.status 数字 ${p} 不合法,必须为 100-599 之间的整数`);
}
} else if (typeof p === "string") {
if (!/^[1-5]xx$/.test(p)) {
throw new Error(`target "${targetName}" 的 expect.status 模式 "${p}" 不合法,字符串必须为 "1xx" 到 "5xx" 格式`);
}
} else {
throw new Error(`target "${targetName}" 的 expect.status 只能包含数字或范围模式字符串`);
}
}
}

View File

@@ -1,7 +1,12 @@
const SIZE_REGEX = /^(\d+(?:\.\d+)?)(B|KB|MB|GB)$/;
export function parseSize(value: number | string): number {
if (typeof value === "number") return value;
if (typeof value === "number") {
if (!Number.isInteger(value) || value < 0 || !Number.isSafeInteger(value)) {
throw new Error(`无效的 size 数值: ${value},必须为非负安全整数`);
}
return value;
}
const match = SIZE_REGEX.exec(value);
if (!match) {

View File

@@ -625,4 +625,370 @@ targets:
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("group 字段必须为字符串");
});
test("HTTP headers 非字符串值抛出错误", async () => {
const configPath = join(tempDir, "bad-headers-val.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
headers:
X-Custom: 123
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("http.headers");
});
test("HTTP body 非字符串抛出错误", async () => {
const configPath = join(tempDir, "bad-body.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
body: 123
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("http.body 必须为字符串");
});
test("maxBodyBytes 负数抛出错误", async () => {
const configPath = join(tempDir, "neg-bodybytes.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
maxBodyBytes: -1
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("非负安全整数");
});
test("maxBodyBytes 非整数抛出错误", async () => {
const configPath = join(tempDir, "float-bodybytes.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
maxBodyBytes: 1.5
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("非负安全整数");
});
test("expect.status 数字不在 100-599 范围抛出错误", async () => {
const configPath = join(tempDir, "bad-status-num.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
status: [999]
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("100-599");
});
test("expect.status 范围 6xx 抛出错误", async () => {
const configPath = join(tempDir, "bad-status-6xx.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
status: ["6xx"]
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("5xx");
});
test("expect.maxDurationMs 负数抛出错误", async () => {
const configPath = join(tempDir, "neg-duration.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
maxDurationMs: -100
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("maxDurationMs 必须为非负有限数字");
});
test("expect.body 非数组抛出错误", async () => {
const configPath = join(tempDir, "bad-expect-body.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body: "not-array"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("expect.body 必须为数组");
});
test("body rule 缺少支持字段抛出错误", async () => {
const configPath = join(tempDir, "bad-body-rule-nofield.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body:
- foo: "bar"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("缺少支持的规则类型");
});
test("body rule 使用 match 字段(非支持)抛出错误", async () => {
const configPath = join(tempDir, "bad-body-rule-match.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body:
- match: "ok"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("缺少支持的规则类型");
});
test("body rule 多个支持字段抛出错误", async () => {
const configPath = join(tempDir, "bad-body-rule-multi.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body:
- contains: "ok"
regex: "ok"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("只能配置一种规则类型");
});
test("body regex 非法正则抛出错误", async () => {
const configPath = join(tempDir, "bad-body-regex.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body:
- regex: "[invalid"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("regex 正则不合法");
});
test("body json path 不以 $. 开头抛出错误", async () => {
const configPath = join(tempDir, "bad-json-path.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body:
- json:
path: "status"
equals: "ok"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("json.path");
});
test("body css selector 为空抛出错误", async () => {
const configPath = join(tempDir, "bad-css-sel.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body:
- css:
selector: ""
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("css.selector 必须为非空字符串");
});
test("operator match 非法正则抛出错误", async () => {
const configPath = join(tempDir, "bad-op-match.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
headers:
X-Test:
match: "[invalid"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("match 正则不合法");
});
test("operator gte 非数字抛出错误", async () => {
const configPath = join(tempDir, "bad-op-gte.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body:
- json:
path: "$.count"
gte: "abc"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("gte 必须为有限数字");
});
test("operator exists 非布尔值抛出错误", async () => {
const configPath = join(tempDir, "bad-op-exists.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body:
- json:
path: "$.status"
exists: "yes"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("exists 必须为布尔值");
});
test("未知字段忽略不影响启动", async () => {
const configPath = join(tempDir, "unknown-fields.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
unknownHttpField: "value"
expect:
status: [200]
unknownExpectField: "value"
body:
- contains: "ok"
note: "ignored"
`,
);
const config = await loadConfig(configPath);
expect(config.targets).toHaveLength(1);
const t = config.targets[0]!;
if (t.type === "http") {
expect(t.expect?.status).toEqual([200]);
}
});
test("xpath path 非空字符串校验", async () => {
const configPath = join(tempDir, "bad-xpath-path.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
body:
- xpath:
path: ""
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("xpath.path 必须为非空字符串");
});
test("expect headers 非对象抛出错误", async () => {
const configPath = join(tempDir, "bad-expect-headers.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
expect:
headers: "invalid"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("expect.headers 必须为对象");
});
});

View File

@@ -107,7 +107,7 @@ describe("checkHttpExpect", () => {
expect(r.failure!.kind).toBe("error");
});
test("完整流水线 status->duration->headers->body 全部通过", () => {
test("完整流水线 status->headers->body->duration 全部通过", () => {
const r = checkHttpExpect(200, { "content-type": "application/json" }, JSON.stringify({ status: "healthy" }), 50, {
body: [{ json: { equals: "healthy", path: "$.status" } }],
headers: { "content-type": { contains: "json" } },
@@ -118,13 +118,13 @@ describe("checkHttpExpect", () => {
expect(r.failure).toBeNull();
});
test("完整流水线 status 通过但 duration 失败", () => {
test("完整流水线 status 和 headers 通过但 duration 失败", () => {
const r = checkHttpExpect(200, {}, "", 500, { maxDurationMs: 100, status: [200] });
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("duration");
});
test("完整流水线 status 和 duration 通过但 headers 失败", () => {
test("完整流水线 status 通过但 headers 失败", () => {
const r = checkHttpExpect(200, { "x-api": "v1" }, "", 50, {
headers: { "x-api": "v2" },
maxDurationMs: 100,
@@ -134,7 +134,7 @@ describe("checkHttpExpect", () => {
expect(r.failure!.phase).toBe("headers");
});
test("完整流水线 status/duration/headers 通过但 body 失败", () => {
test("完整流水线 status/headers 通过但 body 失败", () => {
const r = checkHttpExpect(200, { "content-type": "text/plain" }, "error occurred", 50, {
body: [{ contains: "success" }],
headers: { "content-type": "text/plain" },
@@ -185,4 +185,16 @@ describe("checkStatus 范围匹配", () => {
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("status");
});
test("1xx 范围匹配 101", () => {
expect(checkStatus(101, ["1xx"]).matched).toBe(true);
});
test("3xx 范围匹配 301", () => {
expect(checkStatus(301, ["3xx"]).matched).toBe(true);
});
test("4xx 范围匹配 404", () => {
expect(checkStatus(404, ["4xx"]).matched).toBe(true);
});
});

View File

@@ -3,6 +3,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { CheckerContext, ResolveContext } from "../../../../../src/server/checker/runner/types";
import type { ResolvedHttpTarget } from "../../../../../src/server/checker/types";
import { checkStatus } from "../../../../../src/server/checker/runner/http/expect";
import { HttpChecker } from "../../../../../src/server/checker/runner/http/runner";
const checker = new HttpChecker();
@@ -71,6 +72,10 @@ describe("HttpChecker", () => {
const gbkBytes = new Uint8Array([0xc4, 0xe3, 0xba, 0xc3]);
return new Response(gbkBytes, { headers: { "content-type": "text/plain; charset=gbk" } });
}
case "/gbk-quoted": {
const gbkBytes = new Uint8Array([0xc4, 0xe3, 0xba, 0xc3]);
return new Response(gbkBytes, { headers: { "content-type": 'text/plain; charset="gbk"' } });
}
case "/json":
return new Response(JSON.stringify({ status: "ok" }), {
headers: { "content-type": "application/json" },
@@ -89,6 +94,20 @@ describe("HttpChecker", () => {
return new Response(null, { headers: { location: `${baseUrl}/redirect-chain-2` }, status: 302 });
case "/redirect-chain-2":
return new Response(null, { headers: { location: `${baseUrl}/ok` }, status: 302 });
case "/redirect-cross": {
const port = parseInt(url.searchParams.get("port") ?? "0");
return new Response(null, {
headers: { location: `http://127.0.0.1:${port}/ok` },
status: 302,
});
}
case "/redirect-post":
return new Response(null, { headers: { location: `${baseUrl}/echo` }, status: 301 });
case "/slow-body":
return new Response("x".repeat(2000));
case "/unknown-charset": {
return new Response("test", { headers: { "content-type": "text/plain; charset=bogus-encoding" } });
}
default:
return new Response("ok");
}
@@ -355,6 +374,445 @@ describe("HttpChecker", () => {
);
expect(result.matched).toBe(true);
});
test("响应体编码 quoted charset", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "你好" }] }, url: `${baseUrl}/gbk-quoted` }),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("响应体不支持的编码返回结构化 body 错误", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "test" }] }, url: `${baseUrl}/unknown-charset` }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("body");
expect(result.failure!.kind).toBe("error");
expect(result.failure!.message).toContain("不支持的字符编码");
});
test("流式 body 等于上限允许通过", async () => {
const bodyLen = "hello world".length;
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "hello" }] }, maxBodyBytes: bodyLen, url: `${baseUrl}/ok` }),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("maxBodyBytes 为 0 且响应体非空时返回超限错误", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "hello" }] }, maxBodyBytes: 0, url: `${baseUrl}/ok` }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("body");
expect(result.failure!.kind).toBe("error");
expect(result.failure!.message).toContain("超过限制");
});
test("body 超限时错误归属 body phase", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "x" }] }, maxBodyBytes: 100, url: `${baseUrl}/large` }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("body");
expect(result.failure!.kind).toBe("error");
});
test("请求错误归属 request phase", async () => {
const result = await checker.execute(makeTarget({ url: "http://localhost:1/" }), makeCtx(500));
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("request");
expect(result.failure!.kind).toBe("error");
});
test("超时错误归属 request phase", async () => {
const timeoutServer = Bun.serve({
async fetch() {
await new Promise((resolve) => setTimeout(resolve, 10000));
return new Response("late");
},
port: 0,
});
try {
const result = await checker.execute(
makeTarget({ timeoutMs: 100, url: `http://localhost:${timeoutServer.port}/` }),
makeCtx(100),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("request");
expect(result.failure!.message).toContain("超时");
} finally {
void timeoutServer.stop();
}
});
test("durationMs 包含 body 读取耗时", async () => {
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "x" }] }, maxBodyBytes: 10240, url: `${baseUrl}/large` }),
makeCtx(),
);
expect(result.matched).toBe(true);
expect(result.durationMs).not.toBeNull();
expect(result.durationMs!).toBeGreaterThanOrEqual(0);
});
test("expect.maxDurationMs 使用完整耗时", async () => {
const slowServer = Bun.serve({
async fetch() {
await new Promise((resolve) => setTimeout(resolve, 50));
return new Response("x".repeat(500));
},
port: 0,
});
try {
const result = await checker.execute(
makeTarget({
expect: { body: [{ contains: "x" }], maxDurationMs: 10 },
url: `http://localhost:${slowServer.port}/`,
}),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("duration");
} finally {
void slowServer.stop();
}
});
test("无 body rules 时不读取 body", async () => {
const result = await checker.execute(
makeTarget({ expect: { status: [200] }, maxBodyBytes: 1, url: `${baseUrl}/large` }),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("body 失败优先于 duration 检查", async () => {
const result = await checker.execute(
makeTarget({
expect: { body: [{ contains: "nonexistent" }], maxDurationMs: 999999 },
url: `${baseUrl}/ok`,
}),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("body");
expect(result.durationMs).not.toBeNull();
expect(result.durationMs!).toBeGreaterThanOrEqual(0);
});
test("POST 重定向改 GET 清理 body headers", async () => {
const result = await checker.execute(
makeTarget({
body: "test-body",
expect: { status: [200] },
headers: { authorization: "Bearer token123", "content-type": "text/plain" },
maxRedirects: 5,
method: "POST",
url: `${baseUrl}/redirect-post`,
}),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("跨 origin 重定向剥离敏感 headers", async () => {
const targetServer = Bun.serve({
fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/ok") {
const auth = req.headers.get("authorization");
if (auth) {
return new Response("auth leaked", { status: 200 });
}
return new Response("safe", { headers: { "content-type": "text/plain" } });
}
return new Response("ok");
},
port: 0,
});
try {
const result = await checker.execute(
makeTarget({
expect: { body: [{ contains: "safe" }] },
headers: { authorization: "Bearer secret" },
maxRedirects: 5,
url: `${baseUrl}/redirect-cross?port=${targetServer.port}`,
}),
makeCtx(),
);
expect(result.matched).toBe(true);
} finally {
void targetServer.stop();
}
});
test("1xx 范围模式匹配 101", () => {
const r = checkStatus(101, ["1xx"]);
expect(r.matched).toBe(true);
});
test("6xx 范围模式启动校验失败", () => {
expect(() =>
checker.resolve(
{ expect: { status: ["6xx"] }, http: { url: "https://example.com" }, name: "test", type: "http" },
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("5xx");
});
test("status 数字 99 启动校验失败", () => {
expect(() =>
checker.resolve(
{ expect: { status: [99] }, http: { url: "https://example.com" }, name: "test", type: "http" },
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("100-599");
});
test("body rule 忽略未知字段", () => {
const result = checker.resolve(
{
expect: { body: [{ contains: "ok", note: "ignored" }], status: [200] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
);
expect((result as ResolvedHttpTarget).expect?.body).toEqual([
{ contains: "ok", note: "ignored" },
] as unknown as Array<{ contains: string }>);
});
test("body rule 使用 match 字段启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ match: "ok" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("缺少支持的规则类型");
});
test("非法 regex 启动校验失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ regex: "[invalid" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("regex 正则不合法");
});
test("非法 JSONPath 启动校验失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ json: { equals: "ok", path: "status" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
},
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("json.path");
});
test("非法 operator match 启动校验失败", () => {
expect(() =>
checker.resolve(
{
expect: { headers: { "x-test": { match: "[invalid" } } },
http: { url: "https://example.com" },
name: "test",
type: "http",
},
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("match 正则不合法");
});
test("非法 operator gte 类型启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ json: { gte: "abc", path: "$.count" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("gte 必须为有限数字");
});
test("非法 operator exists 类型启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ json: { exists: "yes", path: "$.status" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("exists 必须为布尔值");
});
test("body rule 多个支持字段启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ contains: "ok", regex: "ok" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("只能配置一种规则类型");
});
test("body rule 缺少支持字段启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ foo: "bar" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("缺少支持的规则类型");
});
test("css selector 为空启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ css: { selector: "" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("css.selector 必须为非空字符串");
});
test("xpath path 为空启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: [{ xpath: { path: "" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("xpath.path 必须为非空字符串");
});
test("expect.headers 非对象启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { headers: "invalid" },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("expect.headers 必须为对象");
});
test("expect.body 非数组启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: "not-array" },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("expect.body 必须为数组");
});
test("maxDurationMs 负数启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { maxDurationMs: -100 },
http: { url: "https://example.com" },
name: "test",
type: "http",
},
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("maxDurationMs 必须为非负有限数字");
});
test("http.body 非字符串启动失败", () => {
expect(() =>
checker.resolve(
{
http: { body: 123, url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("http.body 必须为字符串");
});
test("http.headers 非字符串值启动失败", () => {
expect(() =>
checker.resolve(
{
http: { headers: { "X-Test": 123 }, url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("http.headers");
});
test("http.headers 非对象启动失败", () => {
expect(() =>
checker.resolve(
{
http: { headers: "invalid", url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("http.headers 必须为对象");
});
});
describe("HttpChecker.resolve", () => {

View File

@@ -136,4 +136,42 @@ describe("checkBodyExpect (BodyRule[])", () => {
expect(r.matched).toBe(false);
expect(r.failure!.path).toContain("body[1]");
});
test("JSON 响应不是合法 JSON 返回 error kind", () => {
const r = checkBodyExpect("not json", [{ json: { equals: "ok", path: "$.status" } }]);
expect(r.matched).toBe(false);
expect(r.failure!.kind).toBe("error");
expect(r.failure!.phase).toBe("body");
expect(r.failure!.path).toContain("json");
});
test("CSS selector 无匹配元素返回 mismatch kind", () => {
const html = "<div>no match</div>";
const r = checkBodyExpect(html, [{ css: { equals: "test", selector: "span.missing" } }]);
expect(r.matched).toBe(false);
expect(r.failure!.kind).toBe("mismatch");
expect(r.failure!.phase).toBe("body");
expect(r.failure!.path).toContain("css");
});
test("XPath 无匹配节点返回 mismatch kind", () => {
const xml = "<root><status>ok</status></root>";
const r = checkBodyExpect(xml, [{ xpath: { equals: "ok", path: "/root/missing/text()" } }]);
expect(r.matched).toBe(false);
expect(r.failure!.kind).toBe("mismatch");
expect(r.failure!.phase).toBe("body");
expect(r.failure!.path).toContain("xpath");
});
test("regex 规则使用 regex 字段", () => {
const r = checkBodyExpect("status: ok", [{ regex: "^status:" }]);
expect(r.matched).toBe(true);
});
test("regex 规则失败返回 body phase", () => {
const r = checkBodyExpect("status: error", [{ regex: "^ok$" }]);
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("body");
expect(r.failure!.path).toBe("body[0]");
});
});

View File

@@ -30,6 +30,18 @@ describe("parseSize", () => {
expect(parseSize(2048)).toBe(2048);
});
test("数字 0 返回 0", () => {
expect(parseSize(0)).toBe(0);
});
test("数字负数抛出错误", () => {
expect(() => parseSize(-1)).toThrow("非负安全整数");
});
test("数字非整数抛出错误", () => {
expect(() => parseSize(1.5)).toThrow("非负安全整数");
});
test("无效格式抛出错误", () => {
expect(() => parseSize("100")).toThrow("无效的 size 格式");
expect(() => parseSize("100MBB")).toThrow("无效的 size 格式");