1
0

feat: ValueMatcher 支持 primitive 原始值简写,等价于 { equals: value }

This commit is contained in:
2026-05-19 17:07:47 +08:00
parent 8d8549d07f
commit 12cd05b04e
37 changed files with 1836 additions and 1022 deletions

View File

@@ -504,7 +504,7 @@ TcpChecker implements Checker
| `ContentRules` | 返回内容或半结构化内容断言,必须是数组 | `body``stdout``stderr``banner``response``output``result` | | `ContentRules` | 返回内容或半结构化内容断言,必须是数组 | `body``stdout``stderr``banner``response``output``result` |
| `KeyValueExpect` | 动态键值断言,字面量等价于 `{ equals: value }` | `headers`、DB `rows[]` 中的列值 | | `KeyValueExpect` | 动态键值断言,字面量等价于 `{ equals: value }` | `headers`、DB `rows[]` 中的列值 |
`ValueMatcher` 支持 `equals``contains``regex``empty``exists``gte``lte``gt``lt`。一个 matcher 对象内多个字段为 AND 语义;`exists: false` 不能和其他 matcher 组合;`equals` 使用 `es-toolkit/isEqual` 做 JSON 深度相等;`regex` 固定为无 flags 的 `new RegExp(pattern).test(String(actual))` `ValueMatcher` 支持 `equals``contains``regex``empty``exists``gte``lte``gt``lt`。一个 matcher 对象内多个字段为 AND 语义;`exists: false` 不能和其他 matcher 组合;`equals` 使用 `es-toolkit/isEqual` 做 JSON 深度相等;`regex` 固定为无 flags 的 `new RegExp(pattern).test(String(actual))`ValueMatcher expect 字段输入可使用 string、number、boolean 或 null 简写,语义校验入口会归一化为 `{ equals: value }`;数组和对象简写不支持,必须显式写成 `{ equals: ... }`
`ContentRules` 数组按顺序快速失败。数组项可以是直接 matcher也可以是 `{ json: {...} }``{ css: {...} }``{ xpath: {...} }` 提取器规则;一条规则不能混用直接 matcher 和 extractor多个 extractor 也不能共存。Extractor 未配置 matcher 时等价于 `exists: true`。对对象或数组源执行直接 `contains`/`regex` 时会先 JSON 序列化,`equals` 仍对原始结构做深度相等。 `ContentRules` 数组按顺序快速失败。数组项可以是直接 matcher也可以是 `{ json: {...} }``{ css: {...} }``{ xpath: {...} }` 提取器规则;一条规则不能混用直接 matcher 和 extractor多个 extractor 也不能共存。Extractor 未配置 matcher 时等价于 `exists: true`。对对象或数组源执行直接 `contains`/`regex` 时会先 JSON 序列化,`equals` 仍对原始结构做深度相等。
@@ -556,7 +556,7 @@ expect 字段
1. **状态类字段使用 enum 或 boolean**。结果集合小且稳定时(如 HTTP status 200/2xx、exitCode 0枚举和布尔比 matcher 更贴近协议语义,配置也更直观。不要为了统一而把状态类字段改成 ValueMatcher。 1. **状态类字段使用 enum 或 boolean**。结果集合小且稳定时(如 HTTP status 200/2xx、exitCode 0枚举和布尔比 matcher 更贴近协议语义,配置也更直观。不要为了统一而把状态类字段改成 ValueMatcher。
2. **单值数字指标和字符串元数据使用 ValueMatcher**。观测值是一个明确的标量耗时、行数、丢包率、finish reason但阈值不确定时使用 `{ lte: 100 }``{ regex: "^(stop|end)$" }` 等 matcher 表达。 2. **单值数字指标和字符串元数据使用 ValueMatcher**。观测值是一个明确的标量耗时、行数、丢包率、finish reason但阈值不确定时使用 `{ lte: 100 }``{ regex: "^(stop|end)$" }` 等 matcher 表达;精确匹配 primitive 可直接写 `100``"stop"`
3. **返回内容使用 ContentRules 数组**。观测值是文本、JSON、HTML 或 XML 内容,且可能需要多步提取或多条规则时,使用 ContentRules。即使只有一条规则也必须写成数组形式`[{ contains: "ok" }]`),不支持对象快捷写法。 3. **返回内容使用 ContentRules 数组**。观测值是文本、JSON、HTML 或 XML 内容,且可能需要多步提取或多条规则时,使用 ContentRules。即使只有一条规则也必须写成数组形式`[{ contains: "ok" }]`),不支持对象快捷写法。

View File

@@ -336,7 +336,7 @@ Ping checker 通过系统 `ping` 命令执行 ICMP 探测,支持 Linux、macOS
- `css` — CSS 选择器提取 HTML 元素(`selector` 必填,`attr` 可选提取属性) - `css` — CSS 选择器提取 HTML 元素(`selector` 必填,`attr` 可选提取属性)
- `xpath` — XPath 提取 XML/HTML 节点(`path` 必填,如 `/html/body/h1/text()` - `xpath` — XPath 提取 XML/HTML 节点(`path` 必填,如 `/html/body/h1/text()`
**ValueMatcher 字段**`equals``contains``regex``empty``exists``gte``lte``gt``lt``equals` 支持 JSON 深度相等;`regex` 固定使用无 flags 正则;提取器未配置 matcher 时等价于 `exists: true` **ValueMatcher 字段**`equals``contains``regex``empty``exists``gte``lte``gt``lt``equals` 支持 JSON 深度相等;`regex` 固定使用无 flags 正则;提取器未配置 matcher 时等价于 `exists: true`ValueMatcher expect 字段也可直接写 string、number、boolean 或 null等价于 `{ equals: value }`;数组和对象必须显式写成 `{ equals: ... }`
旧字段 `maxDurationMs``maxPacketLoss``maxAvgLatencyMs``maxMaxLatencyMs` 和旧正则字段 `match` 已移除,请分别改用 `durationMs`、Ping matcher 字段和 `regex` 旧字段 `maxDurationMs``maxPacketLoss``maxAvgLatencyMs``maxMaxLatencyMs` 和旧正则字段 `match` 已移除,请分别改用 `durationMs`、Ping matcher 字段和 `regex`

View File

@@ -7,6 +7,8 @@
### Requirement: ValueMatcher 统一匹配器 ### Requirement: ValueMatcher 统一匹配器
系统 SHALL 提供共享 `ValueMatcher` 作为所有非状态类 expect 的基础匹配结构。`ValueMatcher` SHALL 支持 `equals``contains``regex``exists``empty``gt``gte``lt``lte` 字段。`equals` MUST 支持任意 JSON value并使用深度相等比较。`contains``regex` SHALL 将实际值转换为字符串后匹配。`gt``gte``lt``lte` SHALL 将实际值转换为有限数字后比较,无法转换为有限数字时 SHALL 判定不匹配。一个 `ValueMatcher` 对象包含多个 matcher 字段时,系统 SHALL 要求全部 matcher 均通过。 系统 SHALL 提供共享 `ValueMatcher` 作为所有非状态类 expect 的基础匹配结构。`ValueMatcher` SHALL 支持 `equals``contains``regex``exists``empty``gt``gte``lt``lte` 字段。`equals` MUST 支持任意 JSON value并使用深度相等比较。`contains``regex` SHALL 将实际值转换为字符串后匹配。`gt``gte``lt``lte` SHALL 将实际值转换为有限数字后比较,无法转换为有限数字时 SHALL 判定不匹配。一个 `ValueMatcher` 对象包含多个 matcher 字段时,系统 SHALL 要求全部 matcher 均通过。
所有类型为 `ValueMatcher` 的 expect 字段 SHALL 同时接受 primitive 原始值string / number / boolean / null作为简写形式。原始值简写 SHALL 等价于 `{ equals: value }`。系统 SHALL 在语义校验入口将 primitive 原始值归一化为 `{ equals: value }` 对象形式,后续 resolve 和运行期逻辑 SHALL 仅处理 ValueMatcher 对象形式。数组和对象 MUST NOT 作为原始值简写;需要对数组或对象执行 equals 匹配时,配置 MUST 显式写成 `{ equals: value }`
#### Scenario: equals 匹配对象 #### Scenario: equals 匹配对象
- **WHEN** 实际值为 `{status: "ok", count: 1}` 且 matcher 为 `{equals: {status: "ok", count: 1}}` - **WHEN** 实际值为 `{status: "ok", count: 1}` 且 matcher 为 `{equals: {status: "ok", count: 1}}`
- **THEN** 系统 SHALL 使用深度相等判定该 matcher 通过 - **THEN** 系统 SHALL 使用深度相等判定该 matcher 通过
@@ -23,8 +25,28 @@
- **WHEN** 实际值为 `"healthy"` 且 matcher 为 `{contains: "health", regex: "^ready$"}` - **WHEN** 实际值为 `"healthy"` 且 matcher 为 `{contains: "health", regex: "^ready$"}`
- **THEN** 系统 SHALL 在任一 matcher 不满足时判定该 matcher 对象不通过 - **THEN** 系统 SHALL 在任一 matcher 不满足时判定该 matcher 对象不通过
#### Scenario: 字符串原始值简写等价 equals
- **WHEN** expect 字段配置为 `finishReason: "stop"` 且实际值为 `"stop"`
- **THEN** 系统 SHALL 将 `"stop"` 归一化为 `{equals: "stop"}` 并判定通过
#### Scenario: 数字原始值简写等价 equals
- **WHEN** expect 字段配置为 `rowCount: 1` 且实际值为 `1`
- **THEN** 系统 SHALL 将 `1` 归一化为 `{equals: 1}` 并判定通过
#### Scenario: 布尔原始值简写等价 equals
- **WHEN** expect 字段配置为 ValueMatcher 类型且值为 `true`,实际值为 `true`
- **THEN** 系统 SHALL 将 `true` 归一化为 `{equals: true}` 并判定通过
#### Scenario: null 原始值简写等价 equals
- **WHEN** expect 字段配置为 ValueMatcher 类型且值为 `null`,实际值为 `null`
- **THEN** 系统 SHALL 将 `null` 归一化为 `{equals: null}` 并判定通过
#### Scenario: 原始值简写不匹配
- **WHEN** expect 字段配置为 `finishReason: "stop"` 且实际值为 `"error"`
- **THEN** 系统 SHALL 判定不通过并生成 mismatch failure
### Requirement: ValueMatcher 启动期校验 ### Requirement: ValueMatcher 启动期校验
系统 SHALL 在启动期对所有 `ValueMatcher` 对象执行严格的类型和语义校验。`contains` MUST 为 string。`equals` MAY 为任意 JSON value。`exists``empty` MUST 为 boolean。`gt``gte``lt``lte` MUST 为有限数字(`Number.isFinite`)。`regex` MUST 为可编译的 string pattern并通过 ReDoS 风险校验。ValueMatcher 对象 MUST 至少包含一个合法 matcher 字段,空对象 `{}` SHALL 导致启动期配置错误。ValueMatcher 对象 MUST NOT 包含未知字段,任何不属于 `equals``contains``regex``exists``empty``gt``gte``lt``lte` 的字段 SHALL 导致启动期配置错误。 系统 SHALL 在启动期对所有 `ValueMatcher` 字段执行严格的类型和语义校验。校验 SHALL 同时接受 primitive 原始值和 ValueMatcher 对象两种形式。当输入为 primitive 原始值时,系统 SHALL 视为合法配置(等价于 `{equals: value}`),无需进一步校验 matcher 字段。当输入为 ValueMatcher 对象时,`contains` MUST 为 string。`equals` MAY 为任意 JSON value。`exists``empty` MUST 为 boolean。`gt``gte``lt``lte` MUST 为有限数字(`Number.isFinite`)。`regex` MUST 为可编译的 string pattern并通过 ReDoS 风险校验。ValueMatcher 对象 MUST 至少包含一个合法 matcher 字段,空对象 `{}` SHALL 导致启动期配置错误。ValueMatcher 对象 MUST NOT 包含未知字段,任何不属于 `equals``contains``regex``exists``empty``gt``gte``lt``lte` 的字段 SHALL 导致启动期配置错误。
#### Scenario: 空 matcher 对象被拒绝 #### Scenario: 空 matcher 对象被拒绝
- **WHEN** YAML 配置中任一 matcher 对象为空 `{}` - **WHEN** YAML 配置中任一 matcher 对象为空 `{}`
@@ -42,6 +64,26 @@
- **WHEN** YAML 配置中任一 matcher 的 `exists``empty` 值不是布尔类型 - **WHEN** YAML 配置中任一 matcher 的 `exists``empty` 值不是布尔类型
- **THEN** 系统 SHALL 在启动期配置校验失败,提示该字段必须为布尔值 - **THEN** 系统 SHALL 在启动期配置校验失败,提示该字段必须为布尔值
#### Scenario: 字符串原始值校验通过
- **WHEN** YAML 配置中 ValueMatcher 字段值为字符串 `"stop"`
- **THEN** 系统 SHALL 接受该配置,视为 `{equals: "stop"}`
#### Scenario: 数字原始值校验通过
- **WHEN** YAML 配置中 ValueMatcher 字段值为数字 `5000`
- **THEN** 系统 SHALL 接受该配置,视为 `{equals: 5000}`
#### Scenario: null 原始值校验通过
- **WHEN** YAML 配置中 ValueMatcher 字段值为 `null`
- **THEN** 系统 SHALL 接受该配置,视为 `{equals: null}`
#### Scenario: 数组原始值被拒绝
- **WHEN** YAML 配置中 ValueMatcher 字段值为数组 `[1, 2]`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示必须为 primitive 原始值或 matcher 对象;如需数组 equals 匹配应写成 `{equals: [1, 2]}`
#### Scenario: 对象原始值必须显式 equals
- **WHEN** YAML 配置中 ValueMatcher 字段值为对象 `{foo: "bar"}`,且 `foo` 不是合法 matcher 字段
- **THEN** 系统 SHALL 在启动期配置校验失败,提示 `foo` 是未知 matcher如需对象 equals 匹配应写成 `{equals: {foo: "bar"}}`
### Requirement: empty matcher 语义 ### Requirement: empty matcher 语义
`empty: true` SHALL 在以下情况判定通过:实际值为 `null``undefined`、空字符串 `""`、空数组 `[]` 或空对象 `{}``empty: false` SHALL 在以上条件均不满足时判定通过。数字 `0` 和布尔 `false` SHALL NOT 被视为 empty。 `empty: true` SHALL 在以下情况判定通过:实际值为 `null``undefined`、空字符串 `""`、空数组 `[]` 或空对象 `{}``empty: false` SHALL 在以上条件均不满足时判定通过。数字 `0` 和布尔 `false` SHALL NOT 被视为 empty。

View File

@@ -101,6 +101,8 @@
`headers``env``variables` 等明确声明为动态键值表的对象外,配置中的未知字段 SHALL 导致启动期配置错误。系统 MUST NOT 静默忽略未知字段。 `headers``env``variables` 等明确声明为动态键值表的对象外,配置中的未知字段 SHALL 导致启动期配置错误。系统 MUST NOT 静默忽略未知字段。
所有 ValueMatcher 类型的 expect 字段 SHALL 在 JSON Schema 契约中声明为 `anyOf: [primitiveValue, matcherObject]` 联合类型,同时接受 primitive 原始值string / number / boolean / null和 ValueMatcher 对象。语义 validator SHALL 在校验 ValueMatcher 字段之前执行归一化,将 primitive 原始值转换为 `{equals: value}` 对象形式。数组和对象 MUST NOT 作为 ValueMatcher 原始值简写;需要对数组或对象执行 equals 匹配时,配置 MUST 显式写成 `{equals: value}`
#### Scenario: target 缺少必填字段 #### Scenario: target 缺少必填字段
- **WHEN** YAML 中某个 target 缺少 id 或 type 字段 - **WHEN** YAML 中某个 target 缺少 id 或 type 字段
- **THEN** 系统 SHALL 以错误退出,提示哪个 target 缺少哪个字段 - **THEN** 系统 SHALL 以错误退出,提示哪个 target 缺少哪个字段
@@ -194,9 +196,29 @@
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 status 数字不合法 - **THEN** 系统 SHALL 以错误退出,提示该 target 的 status 数字不合法
#### Scenario: durationMs matcher 非法 #### Scenario: durationMs matcher 非法
- **WHEN** YAML 中某个 target 的 `expect.durationMs` 不是合法 `ValueMatcher` - **WHEN** YAML 中某个 target 的 `expect.durationMs` 不是合法 `ValueMatcher` 也不是 primitive 原始值
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 expect.durationMs 格式错误 - **THEN** 系统 SHALL 以错误退出,提示该 target 的 expect.durationMs 格式错误
#### Scenario: durationMs 原始值简写合法
- **WHEN** YAML 中某个 target 配置 `expect.durationMs: 5000`
- **THEN** 系统 SHALL 接受该配置,归一化为 `{equals: 5000}` 后校验通过
#### Scenario: ValueMatcher 字段字符串简写合法
- **WHEN** YAML 中某个 target 配置 `expect.finishReason: "stop"`
- **THEN** 系统 SHALL 接受该配置,归一化为 `{equals: "stop"}` 后校验通过
#### Scenario: ValueMatcher 字段 null 简写合法
- **WHEN** YAML 中某个 target 配置 ValueMatcher 字段值为 `null`
- **THEN** 系统 SHALL 接受该配置,归一化为 `{equals: null}` 后校验通过
#### Scenario: ValueMatcher 字段数组简写非法
- **WHEN** YAML 中某个 target 配置 ValueMatcher 字段值为数组 `[1, 2]`
- **THEN** 系统 SHALL 以错误退出,提示该字段必须为 primitive 原始值或 matcher 对象;如需数组 equals 匹配应写成 `{equals: [1, 2]}`
#### Scenario: ValueMatcher 字段对象简写非法
- **WHEN** YAML 中某个 target 配置 ValueMatcher 字段值为对象 `{foo: "bar"}`,且 `foo` 不是合法 matcher 字段
- **THEN** 系统 SHALL 以错误退出,提示 `foo` 是未知 matcher如需对象 equals 匹配应写成 `{equals: {foo: "bar"}}`
#### Scenario: ping target 缺少 host #### Scenario: ping target 缺少 host
- **WHEN** YAML 中某个 target 配置 `type: ping` 但缺少 `ping.host` - **WHEN** YAML 中某个 target 配置 `type: ping` 但缺少 `ping.host`
- **THEN** 系统 SHALL 以错误退出,提示该 target 缺少 ping.host 字段 - **THEN** 系统 SHALL 以错误退出,提示该 target 缺少 ping.host 字段
@@ -271,7 +293,23 @@
#### Scenario: 导出配置 JSON Schema #### Scenario: 导出配置 JSON Schema
- **WHEN** 仓库生成或检查配置契约 - **WHEN** 仓库生成或检查配置契约
- **THEN** 根目录 SHALL 存在 draft-07 `probe-config.schema.json`,且其内容 SHALL 与当前公共 fragments 和已注册 checker fragments 组装出的完整 schema 一致(包含 variables 段和 target 的 id/name 字段) - **THEN** 根目录 SHALL 存在 draft-07 `probe-config.schema.json`,且其内容 SHALL 与当前公共 fragments 和已注册 checker fragments 组装出的完整 schema 一致(包含 variables 段和 target 的 id/name 字段)。所有 ValueMatcher 字段的 schema SHALL 声明为 `anyOf: [primitiveValue, matcherObject]` 联合类型
#### Scenario: JSON Schema ValueMatcher 接受原始值
- **WHEN** 使用 JSON Schema 校验配置文件中 ValueMatcher 字段值为数字 `5000`
- **THEN** JSON Schema 校验 SHALL 通过,因为 ValueMatcher schema 声明为 `anyOf: [primitiveValue, matcherObject]`
#### Scenario: JSON Schema ValueMatcher 接受 matcher 对象
- **WHEN** 使用 JSON Schema 校验配置文件中 ValueMatcher 字段值为 `{lte: 5000}`
- **THEN** JSON Schema 校验 SHALL 通过
#### Scenario: JSON Schema ValueMatcher 拒绝数组原始值
- **WHEN** 使用 JSON Schema 校验配置文件中 ValueMatcher 字段值为数组 `[1, 2]`
- **THEN** JSON Schema 校验 SHALL 失败,因为数组不属于 primitive 原始值或 matcher 对象
#### Scenario: JSON Schema ValueMatcher 接受 equals 数组对象
- **WHEN** 使用 JSON Schema 校验配置文件中 ValueMatcher 字段值为 `{equals: [1, 2]}``{equals: {status: "ok"}}`
- **THEN** JSON Schema 校验 SHALL 通过,因为 `equals` 支持任意 JSON value
系统 SHALL 支持使用单位字符串配置读取上限,单位包括 `B``KB``MB``GB` 系统 SHALL 支持使用单位字符串配置读取上限,单位包括 `B``KB``MB``GB`
#### Scenario: 解析 MB #### Scenario: 解析 MB

View File

@@ -514,6 +514,24 @@
} }
}, },
"durationMs": { "durationMs": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1, "minProperties": 1,
"type": "object", "type": "object",
@@ -567,6 +585,8 @@
"type": "string" "type": "string"
} }
} }
}
]
}, },
"headers": { "headers": {
"additionalProperties": { "additionalProperties": {
@@ -595,6 +615,24 @@
} }
] ]
}, },
{
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{ {
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1, "minProperties": 1,
@@ -651,6 +689,8 @@
} }
} }
] ]
}
]
}, },
"type": "object" "type": "object"
}, },
@@ -801,6 +841,24 @@
"type": "object", "type": "object",
"properties": { "properties": {
"durationMs": { "durationMs": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1, "minProperties": 1,
"type": "object", "type": "object",
@@ -854,6 +912,8 @@
"type": "string" "type": "string"
} }
} }
}
]
}, },
"exitCode": { "exitCode": {
"type": "array", "type": "array",
@@ -1445,6 +1505,24 @@
"type": "object", "type": "object",
"properties": { "properties": {
"durationMs": { "durationMs": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1, "minProperties": 1,
"type": "object", "type": "object",
@@ -1498,6 +1576,8 @@
"type": "string" "type": "string"
} }
} }
}
]
}, },
"result": { "result": {
"type": "array", "type": "array",
@@ -1743,6 +1823,24 @@
} }
}, },
"rowCount": { "rowCount": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1, "minProperties": 1,
"type": "object", "type": "object",
@@ -1796,6 +1894,8 @@
"type": "string" "type": "string"
} }
} }
}
]
}, },
"rows": { "rows": {
"type": "array", "type": "array",
@@ -1826,6 +1926,24 @@
} }
] ]
}, },
{
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{ {
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1, "minProperties": 1,
@@ -1882,6 +2000,8 @@
} }
} }
] ]
}
]
}, },
"type": "object" "type": "object"
} }
@@ -2208,6 +2328,24 @@
"type": "boolean" "type": "boolean"
}, },
"durationMs": { "durationMs": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1, "minProperties": 1,
"type": "object", "type": "object",
@@ -2262,6 +2400,8 @@
} }
} }
} }
]
}
} }
}, },
"group": { "group": {
@@ -2361,6 +2501,24 @@
"type": "boolean" "type": "boolean"
}, },
"avgLatencyMs": { "avgLatencyMs": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1, "minProperties": 1,
"type": "object", "type": "object",
@@ -2414,8 +2572,28 @@
"type": "string" "type": "string"
} }
} }
}
]
}, },
"durationMs": { "durationMs": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1, "minProperties": 1,
"type": "object", "type": "object",
@@ -2469,8 +2647,28 @@
"type": "string" "type": "string"
} }
} }
}
]
}, },
"maxLatencyMs": { "maxLatencyMs": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1, "minProperties": 1,
"type": "object", "type": "object",
@@ -2524,8 +2722,28 @@
"type": "string" "type": "string"
} }
} }
}
]
}, },
"packetLossPercent": { "packetLossPercent": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1, "minProperties": 1,
"type": "object", "type": "object",
@@ -2580,6 +2798,8 @@
} }
} }
} }
]
}
} }
}, },
"group": { "group": {
@@ -2662,6 +2882,24 @@
"type": "object", "type": "object",
"properties": { "properties": {
"durationMs": { "durationMs": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1, "minProperties": 1,
"type": "object", "type": "object",
@@ -2715,6 +2953,8 @@
"type": "string" "type": "string"
} }
} }
}
]
}, },
"responded": { "responded": {
"type": "boolean" "type": "boolean"
@@ -2963,6 +3203,24 @@
} }
}, },
"responseSize": { "responseSize": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1, "minProperties": 1,
"type": "object", "type": "object",
@@ -3016,8 +3274,28 @@
"type": "string" "type": "string"
} }
} }
}
]
}, },
"sourceHost": { "sourceHost": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1, "minProperties": 1,
"type": "object", "type": "object",
@@ -3071,8 +3349,28 @@
"type": "string" "type": "string"
} }
} }
}
]
}, },
"sourcePort": { "sourcePort": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1, "minProperties": 1,
"type": "object", "type": "object",
@@ -3127,6 +3425,8 @@
} }
} }
} }
]
}
} }
}, },
"group": { "group": {
@@ -3251,6 +3551,24 @@
"type": "object", "type": "object",
"properties": { "properties": {
"durationMs": { "durationMs": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1, "minProperties": 1,
"type": "object", "type": "object",
@@ -3304,8 +3622,28 @@
"type": "string" "type": "string"
} }
} }
}
]
}, },
"finishReason": { "finishReason": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1, "minProperties": 1,
"type": "object", "type": "object",
@@ -3359,6 +3697,8 @@
"type": "string" "type": "string"
} }
} }
}
]
}, },
"headers": { "headers": {
"additionalProperties": { "additionalProperties": {
@@ -3387,6 +3727,24 @@
} }
] ]
}, },
{
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{ {
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1, "minProperties": 1,
@@ -3443,6 +3801,8 @@
} }
} }
] ]
}
]
}, },
"type": "object" "type": "object"
}, },
@@ -3690,6 +4050,24 @@
} }
}, },
"rawFinishReason": { "rawFinishReason": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1, "minProperties": 1,
"type": "object", "type": "object",
@@ -3743,6 +4121,8 @@
"type": "string" "type": "string"
} }
} }
}
]
}, },
"status": { "status": {
"type": "array", "type": "array",
@@ -3768,6 +4148,24 @@
"type": "boolean" "type": "boolean"
}, },
"firstTokenMs": { "firstTokenMs": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1, "minProperties": 1,
"type": "object", "type": "object",
@@ -3822,6 +4220,8 @@
} }
} }
} }
]
}
} }
}, },
"usage": { "usage": {
@@ -3829,6 +4229,24 @@
"type": "object", "type": "object",
"properties": { "properties": {
"inputTokens": { "inputTokens": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1, "minProperties": 1,
"type": "object", "type": "object",
@@ -3882,8 +4300,28 @@
"type": "string" "type": "string"
} }
} }
}
]
}, },
"outputTokens": { "outputTokens": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1, "minProperties": 1,
"type": "object", "type": "object",
@@ -3937,8 +4375,28 @@
"type": "string" "type": "string"
} }
} }
}
]
}, },
"totalTokens": { "totalTokens": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1, "minProperties": 1,
"type": "object", "type": "object",
@@ -3993,6 +4451,8 @@
} }
} }
} }
]
}
} }
} }
} }
@@ -4435,6 +4895,24 @@
} }
] ]
}, },
{
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{ {
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1, "minProperties": 1,
@@ -4491,10 +4969,30 @@
} }
} }
] ]
}
]
}, },
"type": "object" "type": "object"
}, },
"ValueMatcher": { "ValueMatcher": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1, "minProperties": 1,
"type": "object", "type": "object",
@@ -4549,5 +5047,7 @@
} }
} }
} }
]
}
} }
} }

View File

@@ -139,8 +139,7 @@ targets:
url: "${sqlite_url}" url: "${sqlite_url}"
query: "SELECT 1 as id, 'Alice' as name, 'engineer' as role" query: "SELECT 1 as id, 'Alice' as name, 'engineer' as role"
expect: expect:
rowCount: rowCount: 1
equals: 1
rows: rows:
- id: - id:
gte: 1 gte: 1
@@ -249,37 +248,13 @@ targets:
group: "AI 服务" group: "AI 服务"
llm: llm:
provider: openai provider: openai
url: "https://api.openai.com/v1" url: "https://open.bigmodel.cn/api/paas/v4"
model: "gpt-4o-mini" model: "glm-4.7-flash"
prompt: "Say OK" prompt: "Say OK"
key: "${OPENAI_API_KEY}" key: "d1e97306540d12bb2f834be961fcacb1.SNBShlCxWYJCx0qZ"
expect: expect:
status: status:
- 200 - 200
finishReason: finishReason: "stop"
equals: "stop"
output: output:
- contains: "OK" - contains: "OK"
- id: "llm-anthropic-stream-probe"
name: "Anthropic 流式健康检查"
type: llm
group: "AI 服务"
llm:
provider: anthropic
url: "https://api.anthropic.com/v1"
model: "claude-3-5-haiku-20241022"
prompt: "Say OK"
key: "${ANTHROPIC_API_KEY}"
mode: stream
expect:
status:
- 200
stream:
completed: true
firstTokenMs:
lte: 5000
finishReason:
equals: "stop"
durationMs:
lte: 15000

View File

@@ -1,7 +1,7 @@
import { isEmptyObject, isEqual, isNil, isPlainObject } from "es-toolkit"; import { isEmptyObject, isEqual, isNil, isPlainObject } from "es-toolkit";
import type { CheckFailure, JsonValue } from "../types"; import type { CheckFailure, JsonValue } from "../types";
import type { ExpectResult, ValueMatcher } from "./types"; import type { ExpectResult, ValueMatcher, ValueMatcherInput } from "./types";
import { mismatchFailure } from "./failure"; import { mismatchFailure } from "./failure";
@@ -66,18 +66,19 @@ export function checkExpectValue(actual: unknown, expected: JsonValue | ValueMat
export function checkValueMatcher( export function checkValueMatcher(
actual: unknown, actual: unknown,
matcher: undefined | ValueMatcher, matcher: undefined | ValueMatcherInput,
options: { message?: string; path: string; phase: CheckFailure["phase"]; stringifyNonString?: boolean }, options: { message?: string; path: string; phase: CheckFailure["phase"]; stringifyNonString?: boolean },
): ExpectResult { ): ExpectResult {
if (matcher === undefined) return { failure: null, matched: true }; if (matcher === undefined) return { failure: null, matched: true };
if (applyMatcher(actual, matcher, { stringifyNonString: options.stringifyNonString })) { const normalized = isValueMatcherObject(matcher) ? matcher : { equals: matcher };
if (applyMatcher(actual, normalized, { stringifyNonString: options.stringifyNonString })) {
return { failure: null, matched: true }; return { failure: null, matched: true };
} }
return { return {
failure: mismatchFailure( failure: mismatchFailure(
options.phase, options.phase,
options.path, options.path,
matcher, normalized,
actual, actual,
options.message ?? `${options.path} mismatch`, options.message ?? `${options.path} mismatch`,
), ),

View File

@@ -0,0 +1,22 @@
import type { ValueMatcherPrimitive } from "./types";
import { isValueMatcherObject } from "./matcher";
export function isValueMatcherPrimitive(value: unknown): value is ValueMatcherPrimitive {
return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
}
export function normalizeExpectMatchers(expect: Record<string, unknown>, keys: string[]): void {
for (const key of keys) {
if (key in expect) {
expect[key] = normalizeValueMatcher(expect[key]);
}
}
}
export function normalizeValueMatcher(value: unknown): unknown {
if (value === undefined) return undefined;
if (isValueMatcherObject(value)) return value;
if (isValueMatcherPrimitive(value)) return { equals: value };
return value;
}

View File

@@ -39,3 +39,7 @@ export interface ValueMatcher {
lte?: number; lte?: number;
regex?: string; regex?: string;
} }
export type ValueMatcherInput = ValueMatcher | ValueMatcherPrimitive;
export type ValueMatcherPrimitive = boolean | null | number | string;

View File

@@ -6,6 +6,7 @@ import type { ConfigValidationIssue } from "../schema/issues";
import type { JsonValue } from "../types"; import type { JsonValue } from "../types";
import { issue, joinPath } from "../schema/issues"; import { issue, joinPath } from "../schema/issues";
import { isValueMatcherPrimitive } from "./normalize";
import { isUnsafeRegex } from "./redos"; import { isUnsafeRegex } from "./redos";
export const MatcherKeys = ["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "regex"] as const; export const MatcherKeys = ["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "regex"] as const;
@@ -72,7 +73,9 @@ export function validateValueMatcher(
options: { requireAtLeastOne?: boolean } = {}, options: { requireAtLeastOne?: boolean } = {},
): ConfigValidationIssue[] { ): ConfigValidationIssue[] {
const requireAtLeastOne = options.requireAtLeastOne ?? true; const requireAtLeastOne = options.requireAtLeastOne ?? true;
if (!isPlainRecord(matcher)) return [issue("invalid-type", path, "必须为 matcher 对象", targetName)]; if (isValueMatcherPrimitive(matcher)) return [];
if (!isPlainRecord(matcher))
return [issue("invalid-type", path, "必须为 primitive 原始值或 matcher 对象", targetName)];
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
let found = 0; let found = 0;

View File

@@ -1,4 +1,4 @@
import type { ContentRules, ValueMatcher } from "../../expect/types"; import type { ContentRules, ValueMatcherInput } from "../../expect/types";
import type { ResolvedTargetBase } from "../../types"; import type { ResolvedTargetBase } from "../../types";
export interface CommandDefaultsConfig { export interface CommandDefaultsConfig {
@@ -7,7 +7,7 @@ export interface CommandDefaultsConfig {
} }
export interface CommandExpectConfig { export interface CommandExpectConfig {
durationMs?: ValueMatcher; durationMs?: ValueMatcherInput;
exitCode?: number[]; exitCode?: number[];
stderr?: ContentRules; stderr?: ContentRules;
stdout?: ContentRules; stdout?: ContentRules;

View File

@@ -3,6 +3,7 @@ import { isNumber, isPlainObject, isString } from "es-toolkit";
import type { ConfigValidationIssue } from "../../schema/issues"; import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types"; import type { CheckerValidationInput } from "../types";
import { normalizeExpectMatchers } from "../../expect/normalize";
import { validateContentRules, validateValueMatcher } from "../../expect/validate-matcher"; import { validateContentRules, validateValueMatcher } from "../../expect/validate-matcher";
import { issue, joinPath } from "../../schema/issues"; import { issue, joinPath } from "../../schema/issues";
import { parseSize } from "../../utils"; import { parseSize } from "../../utils";
@@ -41,6 +42,9 @@ function validateCommandExpect(target: Record<string, unknown>, path: string): C
if (expect === undefined || expect === null || !isPlainObject(expect)) return []; if (expect === undefined || expect === null || !isPlainObject(expect)) return [];
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect"); const expectPath = joinPath(path, "expect");
normalizeExpectMatchers(expect, ["durationMs"]);
if (expect["stdout"] !== undefined) { if (expect["stdout"] !== undefined) {
issues.push(...validateContentRules(expect["stdout"], joinPath(expectPath, "stdout"), targetName)); issues.push(...validateContentRules(expect["stdout"], joinPath(expectPath, "stdout"), targetName));
} }

View File

@@ -1,12 +1,12 @@
import { isPlainObject } from "es-toolkit"; import { isPlainObject } from "es-toolkit";
import type { ExpectResult, KeyValueExpect, ValueMatcher } from "../../expect/types"; import type { ExpectResult, KeyValueExpect, ValueMatcherInput } from "../../expect/types";
import { mismatchFailure } from "../../expect/failure"; import { mismatchFailure } from "../../expect/failure";
import { checkKeyValueExpect } from "../../expect/key-value"; import { checkKeyValueExpect } from "../../expect/key-value";
import { checkValueMatcher } from "../../expect/matcher"; import { checkValueMatcher } from "../../expect/matcher";
export function checkRowCount(actual: number, matcher: ValueMatcher): ExpectResult { export function checkRowCount(actual: number, matcher: ValueMatcherInput): ExpectResult {
return checkValueMatcher(actual, matcher, { return checkValueMatcher(actual, matcher, {
message: `rowCount ${actual} 不满足条件`, message: `rowCount ${actual} 不满足条件`,
path: "rowCount", path: "rowCount",

View File

@@ -1,10 +1,10 @@
import type { ContentRules, KeyValueExpect, ValueMatcher } from "../../expect/types"; import type { ContentRules, KeyValueExpect, ValueMatcherInput } from "../../expect/types";
import type { ResolvedTargetBase } from "../../types"; import type { ResolvedTargetBase } from "../../types";
export interface DbExpectConfig { export interface DbExpectConfig {
durationMs?: ValueMatcher; durationMs?: ValueMatcherInput;
result?: ContentRules; result?: ContentRules;
rowCount?: ValueMatcher; rowCount?: ValueMatcherInput;
rows?: KeyValueExpect[]; rows?: KeyValueExpect[];
} }

View File

@@ -3,6 +3,7 @@ import { isPlainObject, isString } from "es-toolkit";
import type { ConfigValidationIssue } from "../../schema/issues"; import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types"; import type { CheckerValidationInput } from "../types";
import { normalizeExpectMatchers } from "../../expect/normalize";
import { validateContentRules, validateKeyValueExpect, validateValueMatcher } from "../../expect/validate-matcher"; import { validateContentRules, validateKeyValueExpect, validateValueMatcher } from "../../expect/validate-matcher";
import { issue, joinPath } from "../../schema/issues"; import { issue, joinPath } from "../../schema/issues";
@@ -44,6 +45,8 @@ function validateDbExpect(target: Record<string, unknown>, path: string): Config
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect"); const expectPath = joinPath(path, "expect");
normalizeExpectMatchers(expect, ["durationMs", "rowCount"]);
if (expect["durationMs"] !== undefined) { if (expect["durationMs"] !== undefined) {
issues.push(...validateValueMatcher(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName)); issues.push(...validateValueMatcher(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
} }

View File

@@ -6,7 +6,7 @@ import type { HttpExpectConfig, HttpTargetConfig, ResolvedHttpTarget } from "./t
import { checkContentRules } from "../../expect/content"; import { checkContentRules } from "../../expect/content";
import { errorFailure, mismatchFailure } from "../../expect/failure"; import { errorFailure, mismatchFailure } from "../../expect/failure";
import { checkValueMatcher } from "../../expect/matcher"; import { checkValueMatcher, isValueMatcherObject } from "../../expect/matcher";
import { parseSize } from "../../utils"; import { parseSize } from "../../utils";
import { checkHeaders, checkStatus } from "./expect"; import { checkHeaders, checkStatus } from "./expect";
import { httpCheckerSchemas } from "./schema"; import { httpCheckerSchemas } from "./schema";
@@ -198,7 +198,7 @@ function checkEarlyTimeout(
start: number, start: number,
durationMatcher: HttpExpectConfig["durationMs"] | undefined, durationMatcher: HttpExpectConfig["durationMs"] | undefined,
): null | { elapsed: number; failure: CheckResult["failure"] } { ): null | { elapsed: number; failure: CheckResult["failure"] } {
if (durationMatcher === undefined) return null; if (!isValueMatcherObject(durationMatcher)) return null;
const limit = Math.min( const limit = Math.min(
durationMatcher.lte ?? Number.POSITIVE_INFINITY, durationMatcher.lte ?? Number.POSITIVE_INFINITY,
durationMatcher.lt ?? Number.POSITIVE_INFINITY, durationMatcher.lt ?? Number.POSITIVE_INFINITY,

View File

@@ -1,4 +1,4 @@
import type { ContentRules, KeyValueExpect, ValueMatcher } from "../../expect/types"; import type { ContentRules, KeyValueExpect, ValueMatcherInput } from "../../expect/types";
import type { ResolvedTargetBase } from "../../types"; import type { ResolvedTargetBase } from "../../types";
export interface HttpDefaultsConfig { export interface HttpDefaultsConfig {
@@ -9,7 +9,7 @@ export interface HttpDefaultsConfig {
export interface HttpExpectConfig { export interface HttpExpectConfig {
body?: ContentRules; body?: ContentRules;
durationMs?: ValueMatcher; durationMs?: ValueMatcherInput;
headers?: KeyValueExpect; headers?: KeyValueExpect;
status?: Array<number | string>; status?: Array<number | string>;
} }

View File

@@ -3,6 +3,7 @@ import { isNumber, isString } from "es-toolkit";
import type { ConfigValidationIssue } from "../../schema/issues"; import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types"; import type { CheckerValidationInput } from "../types";
import { normalizeExpectMatchers } from "../../expect/normalize";
import { import {
isPlainRecord, isPlainRecord,
validateContentRules, validateContentRules,
@@ -67,6 +68,8 @@ function validateHttpExpect(target: Record<string, unknown>, path: string): Conf
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect"); const expectPath = joinPath(path, "expect");
normalizeExpectMatchers(expect, ["durationMs"]);
if (isPlainRecord(expect["headers"])) { if (isPlainRecord(expect["headers"])) {
issues.push(...validateKeyValueExpect(expect["headers"], joinPath(expectPath, "headers"), targetName)); issues.push(...validateKeyValueExpect(expect["headers"], joinPath(expectPath, "headers"), targetName));
} }

View File

@@ -1,4 +1,4 @@
import type { ExpectResult, ValueMatcher } from "../../expect/types"; import type { ExpectResult, ValueMatcherInput } from "../../expect/types";
import { mismatchFailure } from "../../expect/failure"; import { mismatchFailure } from "../../expect/failure";
import { checkValueMatcher } from "../../expect/matcher"; import { checkValueMatcher } from "../../expect/matcher";
@@ -17,7 +17,7 @@ export function checkAlive(actual: boolean, expected: boolean): ExpectResult {
}; };
} }
export function checkAvgLatency(actual: null | number, matcher: undefined | ValueMatcher): ExpectResult { export function checkAvgLatency(actual: null | number, matcher: undefined | ValueMatcherInput): ExpectResult {
return checkValueMatcher(actual, matcher, { return checkValueMatcher(actual, matcher, {
message: "平均延迟不满足条件", message: "平均延迟不满足条件",
path: "avgLatencyMs", path: "avgLatencyMs",
@@ -25,7 +25,7 @@ export function checkAvgLatency(actual: null | number, matcher: undefined | Valu
}); });
} }
export function checkMaxLatency(actual: null | number, matcher: undefined | ValueMatcher): ExpectResult { export function checkMaxLatency(actual: null | number, matcher: undefined | ValueMatcherInput): ExpectResult {
return checkValueMatcher(actual, matcher, { return checkValueMatcher(actual, matcher, {
message: "最大延迟不满足条件", message: "最大延迟不满足条件",
path: "maxLatencyMs", path: "maxLatencyMs",
@@ -33,7 +33,7 @@ export function checkMaxLatency(actual: null | number, matcher: undefined | Valu
}); });
} }
export function checkPacketLoss(actual: number, matcher: undefined | ValueMatcher): ExpectResult { export function checkPacketLoss(actual: number, matcher: undefined | ValueMatcherInput): ExpectResult {
return checkValueMatcher(actual, matcher, { return checkValueMatcher(actual, matcher, {
message: "丢包率不满足条件", message: "丢包率不满足条件",
path: "packetLossPercent", path: "packetLossPercent",

View File

@@ -1,12 +1,12 @@
import type { ValueMatcher } from "../../expect/types"; import type { ValueMatcherInput } from "../../expect/types";
import type { ResolvedTargetBase } from "../../types"; import type { ResolvedTargetBase } from "../../types";
export interface PingExpectConfig { export interface PingExpectConfig {
alive?: boolean; alive?: boolean;
avgLatencyMs?: ValueMatcher; avgLatencyMs?: ValueMatcherInput;
durationMs?: ValueMatcher; durationMs?: ValueMatcherInput;
maxLatencyMs?: ValueMatcher; maxLatencyMs?: ValueMatcherInput;
packetLossPercent?: ValueMatcher; packetLossPercent?: ValueMatcherInput;
} }
export interface PingStats { export interface PingStats {

View File

@@ -3,6 +3,7 @@ import { isNumber, isPlainObject, isString } from "es-toolkit";
import type { ConfigValidationIssue } from "../../schema/issues"; import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types"; import type { CheckerValidationInput } from "../types";
import { normalizeExpectMatchers } from "../../expect/normalize";
import { validateValueMatcher } from "../../expect/validate-matcher"; import { validateValueMatcher } from "../../expect/validate-matcher";
import { issue, joinPath } from "../../schema/issues"; import { issue, joinPath } from "../../schema/issues";
@@ -46,6 +47,8 @@ function validatePingExpect(target: Record<string, unknown>, path: string): Conf
const targetName = getTargetName(target); const targetName = getTargetName(target);
const expectPath = joinPath(path, "expect"); const expectPath = joinPath(path, "expect");
normalizeExpectMatchers(expect, ["packetLossPercent", "avgLatencyMs", "maxLatencyMs", "durationMs"]);
if (expect["alive"] !== undefined && typeof expect["alive"] !== "boolean") { if (expect["alive"] !== undefined && typeof expect["alive"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(expectPath, "alive"), "必须为布尔值", targetName)); issues.push(issue("invalid-type", joinPath(expectPath, "alive"), "必须为布尔值", targetName));
} }

View File

@@ -1,6 +1,6 @@
import type { JSONObject } from "@ai-sdk/provider"; import type { JSONObject } from "@ai-sdk/provider";
import type { ContentRules, KeyValueExpect, ValueMatcher } from "../../expect/types"; import type { ContentRules, KeyValueExpect, ValueMatcherInput } from "../../expect/types";
import type { ResolvedTargetBase } from "../../types"; import type { ResolvedTargetBase } from "../../types";
export interface LlmCheckObservation { export interface LlmCheckObservation {
@@ -24,11 +24,11 @@ export interface LlmDefaultsConfig {
} }
export interface LlmExpectConfig { export interface LlmExpectConfig {
durationMs?: ValueMatcher; durationMs?: ValueMatcherInput;
finishReason?: ValueMatcher; finishReason?: ValueMatcherInput;
headers?: KeyValueExpect; headers?: KeyValueExpect;
output?: ContentRules; output?: ContentRules;
rawFinishReason?: ValueMatcher; rawFinishReason?: ValueMatcherInput;
status?: Array<number | string>; status?: Array<number | string>;
stream?: LlmStreamExpect; stream?: LlmStreamExpect;
usage?: LlmUsageExpect; usage?: LlmUsageExpect;
@@ -57,7 +57,7 @@ export type LlmProvider = "anthropic" | "openai" | "openai-responses";
export interface LlmStreamExpect { export interface LlmStreamExpect {
completed?: boolean; completed?: boolean;
firstTokenMs?: ValueMatcher; firstTokenMs?: ValueMatcherInput;
} }
export interface LlmStreamObservation { export interface LlmStreamObservation {
@@ -80,9 +80,9 @@ export interface LlmTargetConfig {
} }
export interface LlmUsageExpect { export interface LlmUsageExpect {
inputTokens?: ValueMatcher; inputTokens?: ValueMatcherInput;
outputTokens?: ValueMatcher; outputTokens?: ValueMatcherInput;
totalTokens?: ValueMatcher; totalTokens?: ValueMatcherInput;
} }
export interface LlmUsageObservation { export interface LlmUsageObservation {

View File

@@ -3,6 +3,7 @@ import { isBoolean, isNumber, isString } from "es-toolkit";
import type { ConfigValidationIssue } from "../../schema/issues"; import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types"; import type { CheckerValidationInput } from "../types";
import { normalizeExpectMatchers } from "../../expect/normalize";
import { import {
isPlainRecord, isPlainRecord,
validateContentRules, validateContentRules,
@@ -72,6 +73,8 @@ function validateLlmExpect(
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect"); const expectPath = joinPath(path, "expect");
normalizeExpectMatchers(expect, ["durationMs", "finishReason", "rawFinishReason"]);
if (Array.isArray(expect["status"])) { if (Array.isArray(expect["status"])) {
issues.push(...validateStatusValues(expect["status"], joinPath(expectPath, "status"), targetName)); issues.push(...validateStatusValues(expect["status"], joinPath(expectPath, "status"), targetName));
} }
@@ -286,6 +289,8 @@ function validateStreamExpect(stream: unknown, path: string, targetName?: string
if (!isPlainRecord(stream)) return [issue("invalid-type", path, "必须为对象", targetName)]; if (!isPlainRecord(stream)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
normalizeExpectMatchers(stream, ["firstTokenMs"]);
if (stream["completed"] !== undefined && !isBoolean(stream["completed"])) { if (stream["completed"] !== undefined && !isBoolean(stream["completed"])) {
issues.push(issue("invalid-type", joinPath(path, "completed"), "必须为布尔值", targetName)); issues.push(issue("invalid-type", joinPath(path, "completed"), "必须为布尔值", targetName));
} }
@@ -316,6 +321,8 @@ function validateUsageExpect(usage: unknown, path: string, targetName?: string):
if (!isPlainRecord(usage)) return [issue("invalid-type", path, "必须为对象", targetName)]; if (!isPlainRecord(usage)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
normalizeExpectMatchers(usage, ["inputTokens", "outputTokens", "totalTokens"]);
for (const key of ["inputTokens", "outputTokens", "totalTokens"]) { for (const key of ["inputTokens", "outputTokens", "totalTokens"]) {
if (usage[key] !== undefined) { if (usage[key] !== undefined) {
issues.push(...validateValueMatcher(usage[key], joinPath(path, key), targetName)); issues.push(...validateValueMatcher(usage[key], joinPath(path, key), targetName));

View File

@@ -1,4 +1,4 @@
import type { ContentRules, ValueMatcher } from "../../expect/types"; import type { ContentRules, ValueMatcherInput } from "../../expect/types";
import type { ResolvedTargetBase } from "../../types"; import type { ResolvedTargetBase } from "../../types";
export interface ResolvedTcpConfig { export interface ResolvedTcpConfig {
@@ -27,7 +27,7 @@ export interface TcpDefaultsConfig {
export interface TcpExpectConfig { export interface TcpExpectConfig {
banner?: ContentRules; banner?: ContentRules;
connected?: boolean; connected?: boolean;
durationMs?: ValueMatcher; durationMs?: ValueMatcherInput;
} }
export interface TcpTargetConfig { export interface TcpTargetConfig {

View File

@@ -3,6 +3,7 @@ import { isNumber, isPlainObject, isString } from "es-toolkit";
import type { ConfigValidationIssue } from "../../schema/issues"; import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types"; import type { CheckerValidationInput } from "../types";
import { normalizeExpectMatchers } from "../../expect/normalize";
import { validateContentRules, validateValueMatcher } from "../../expect/validate-matcher"; import { validateContentRules, validateValueMatcher } from "../../expect/validate-matcher";
import { issue, joinPath } from "../../schema/issues"; import { issue, joinPath } from "../../schema/issues";
@@ -75,6 +76,8 @@ function validateTcpExpect(
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect"); const expectPath = joinPath(path, "expect");
normalizeExpectMatchers(expect, ["durationMs"]);
if (expect["connected"] !== undefined && typeof expect["connected"] !== "boolean") { if (expect["connected"] !== undefined && typeof expect["connected"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(expectPath, "connected"), "必须为布尔值", targetName)); issues.push(issue("invalid-type", joinPath(expectPath, "connected"), "必须为布尔值", targetName));
} }

View File

@@ -1,4 +1,4 @@
import type { ContentRules, ExpectResult, ValueMatcher } from "../../expect/types"; import type { ContentRules, ExpectResult, ValueMatcherInput } from "../../expect/types";
import { checkContentRules } from "../../expect/content"; import { checkContentRules } from "../../expect/content";
import { mismatchFailure } from "../../expect/failure"; import { mismatchFailure } from "../../expect/failure";
@@ -18,7 +18,7 @@ export function checkResponded(responded: boolean, expected: boolean): ExpectRes
}; };
} }
export function checkResponseSize(size: number, matcher: ValueMatcher): ExpectResult { export function checkResponseSize(size: number, matcher: ValueMatcherInput): ExpectResult {
return checkValueMatcher(size, matcher, { return checkValueMatcher(size, matcher, {
message: "响应大小不满足条件", message: "响应大小不满足条件",
path: "responseSize", path: "responseSize",
@@ -30,7 +30,7 @@ export function checkResponseText(text: string, rules: ContentRules): ExpectResu
return checkContentRules(text, rules, { path: "response", phase: "response" }); return checkContentRules(text, rules, { path: "response", phase: "response" });
} }
export function checkSourceHost(actual: string, matcher: ValueMatcher): ExpectResult { export function checkSourceHost(actual: string, matcher: ValueMatcherInput): ExpectResult {
return checkValueMatcher(actual, matcher, { return checkValueMatcher(actual, matcher, {
message: "响应来源地址不满足条件", message: "响应来源地址不满足条件",
path: "sourceHost", path: "sourceHost",
@@ -38,7 +38,7 @@ export function checkSourceHost(actual: string, matcher: ValueMatcher): ExpectRe
}); });
} }
export function checkSourcePort(actual: number, matcher: ValueMatcher): ExpectResult { export function checkSourcePort(actual: number, matcher: ValueMatcherInput): ExpectResult {
return checkValueMatcher(actual, matcher, { return checkValueMatcher(actual, matcher, {
message: "响应来源端口不满足条件", message: "响应来源端口不满足条件",
path: "sourcePort", path: "sourcePort",

View File

@@ -1,4 +1,4 @@
import type { ContentRules, ValueMatcher } from "../../expect/types"; import type { ContentRules, ValueMatcherInput } from "../../expect/types";
import type { ResolvedTargetBase } from "../../types"; import type { ResolvedTargetBase } from "../../types";
export interface ResolvedUdpConfig { export interface ResolvedUdpConfig {
@@ -29,12 +29,12 @@ export interface UdpDefaultsConfig {
export type UdpEncoding = "base64" | "hex" | "text"; export type UdpEncoding = "base64" | "hex" | "text";
export interface UdpExpectConfig { export interface UdpExpectConfig {
durationMs?: ValueMatcher; durationMs?: ValueMatcherInput;
responded?: boolean; responded?: boolean;
response?: ContentRules; response?: ContentRules;
responseSize?: ValueMatcher; responseSize?: ValueMatcherInput;
sourceHost?: ValueMatcher; sourceHost?: ValueMatcherInput;
sourcePort?: ValueMatcher; sourcePort?: ValueMatcherInput;
} }
export interface UdpTargetConfig { export interface UdpTargetConfig {

View File

@@ -3,6 +3,7 @@ import { isNumber, isPlainObject, isString } from "es-toolkit";
import type { ConfigValidationIssue } from "../../schema/issues"; import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types"; import type { CheckerValidationInput } from "../types";
import { normalizeExpectMatchers } from "../../expect/normalize";
import { validateContentRules, validateValueMatcher } from "../../expect/validate-matcher"; import { validateContentRules, validateValueMatcher } from "../../expect/validate-matcher";
import { issue, joinPath } from "../../schema/issues"; import { issue, joinPath } from "../../schema/issues";
@@ -75,6 +76,8 @@ function validateUdpExpect(target: Record<string, unknown>, path: string): Confi
const expectPath = joinPath(path, "expect"); const expectPath = joinPath(path, "expect");
const responded: unknown = expect["responded"]; const responded: unknown = expect["responded"];
normalizeExpectMatchers(expect, ["durationMs", "responseSize", "sourceHost", "sourcePort"]);
if (responded !== undefined && typeof responded !== "boolean") { if (responded !== undefined && typeof responded !== "boolean") {
issues.push(issue("invalid-type", joinPath(expectPath, "responded"), "必须为布尔值", targetName)); issues.push(issue("invalid-type", joinPath(expectPath, "responded"), "必须为布尔值", targetName));
} }

View File

@@ -25,6 +25,10 @@ export const jsonValueSchema = Type.Unsafe<JsonValue>({
], ],
}); });
export const primitiveValueSchema = Type.Unsafe<boolean | null | number | string>({
anyOf: [{ type: "string" }, { type: "number" }, { type: "boolean" }, { type: "null" }],
});
export const sizeSchema = Type.Union([Type.String(), Type.Integer({ minimum: 0 })]); export const sizeSchema = Type.Union([Type.String(), Type.Integer({ minimum: 0 })]);
export const variableValueSchema = Type.Union([Type.String(), Type.Number(), Type.Boolean()]); export const variableValueSchema = Type.Union([Type.String(), Type.Number(), Type.Boolean()]);
@@ -72,7 +76,9 @@ export function createKeyValueExpectSchema(): TSchema {
} }
export function createValueMatcherSchema(): TSchema { export function createValueMatcherSchema(): TSchema {
return Type.Object(matcherProperties(), { additionalProperties: false, minProperties: 1 }); return Type.Unsafe({
anyOf: [primitiveValueSchema, Type.Object(matcherProperties(), { additionalProperties: false, minProperties: 1 })],
});
} }
export function matcherProperties(): Record<string, TSchema> { export function matcherProperties(): Record<string, TSchema> {

View File

@@ -62,6 +62,28 @@ describe("config contract", () => {
).toBe(false); ).toBe(false);
}); });
test("导出 schema 支持 ValueMatcher primitive 简写且拒绝数组对象简写", () => {
const ajv = new Ajv({
allErrors: true,
coerceTypes: false,
removeAdditional: false,
strict: true,
useDefaults: false,
});
const validate = ajv.compile(createProbeConfigJsonSchema(createDefaultCheckerRegistry()));
const target = (durationMs: unknown) => ({
targets: [{ expect: { durationMs }, http: { url: "https://example.com" }, id: "api", type: "http" }],
});
expect(validate(target(5000))).toBe(true);
expect(validate(target("5000"))).toBe(true);
expect(validate(target(null))).toBe(true);
expect(validate(target([1, 2]))).toBe(false);
expect(validate(target({ foo: "bar" }))).toBe(false);
expect(validate(target({ equals: [1, 2] }))).toBe(true);
expect(validate(target({ equals: { status: "ok" } }))).toBe(true);
});
test("Ajv 错误转换为中文结构化 issue", () => { test("Ajv 错误转换为中文结构化 issue", () => {
const result = validateProbeConfigContract( const result = validateProbeConfigContract(
{ {

View File

@@ -3,12 +3,14 @@ import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import type { ValueMatcher } from "../../../src/server/checker/expect/types";
import type { ResolvedCommandTarget } from "../../../src/server/checker/runner/cmd/types"; import type { ResolvedCommandTarget } from "../../../src/server/checker/runner/cmd/types";
import type { ResolvedHttpTarget } from "../../../src/server/checker/runner/http/types"; import type { ResolvedHttpTarget } from "../../../src/server/checker/runner/http/types";
import type { ResolvedPingTarget } from "../../../src/server/checker/runner/icmp/types"; import type { ResolvedPingTarget } from "../../../src/server/checker/runner/icmp/types";
import type { ResolvedTcpTarget } from "../../../src/server/checker/runner/tcp/types"; import type { ResolvedTcpTarget } from "../../../src/server/checker/runner/tcp/types";
import { loadConfig, parseDuration } from "../../../src/server/checker/config-loader"; import { loadConfig, parseDuration } from "../../../src/server/checker/config-loader";
import { checkValueMatcher } from "../../../src/server/checker/expect/matcher";
import { checkerRegistry } from "../../../src/server/checker/runner"; import { checkerRegistry } from "../../../src/server/checker/runner";
import { CommandChecker } from "../../../src/server/checker/runner/cmd/execute"; import { CommandChecker } from "../../../src/server/checker/runner/cmd/execute";
import { HttpChecker } from "../../../src/server/checker/runner/http/execute"; import { HttpChecker } from "../../../src/server/checker/runner/http/execute";
@@ -288,6 +290,32 @@ targets:
expect(config.targets[0]!.name).toBeNull(); expect(config.targets[0]!.name).toBeNull();
}); });
test("ValueMatcher primitive 简写在加载时归一化后可运行期匹配", async () => {
const configPath = join(tempDir, "matcher-shorthand.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
type: http
http:
url: "http://example.com"
expect:
durationMs: 123
`,
);
const config = await loadConfig(configPath);
const target = config.targets[0]! as ResolvedHttpTarget;
expect(target.expect?.durationMs).toEqual({ equals: 123 });
expect(
checkValueMatcher(123, target.expect?.durationMs as ValueMatcher, {
path: "durationMs",
phase: "duration",
}).matched,
).toBe(true);
});
test("name 为空字符串抛出错误", async () => { test("name 为空字符串抛出错误", async () => {
const configPath = join(tempDir, "empty-name.yaml"); const configPath = join(tempDir, "empty-name.yaml");
await writeFile( await writeFile(
@@ -1076,8 +1104,8 @@ targets:
await expect(loadConfig(configPath)).rejects.toThrow("5xx"); await expect(loadConfig(configPath)).rejects.toThrow("5xx");
}); });
test("expect.durationMs 非 matcher 抛出错误", async () => { test("expect.durationMs 对象简写抛出错误", async () => {
const configPath = join(tempDir, "neg-duration.yaml"); const configPath = join(tempDir, "bad-duration-object.yaml");
await writeFile( await writeFile(
configPath, configPath,
`targets: `targets:
@@ -1087,11 +1115,12 @@ targets:
http: http:
url: "http://example.com" url: "http://example.com"
expect: expect:
durationMs: -100 durationMs:
foo: "bar"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable // eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("expect.durationMs 必须为 matcher 对象"); await expect(loadConfig(configPath)).rejects.toThrow("expect.durationMs.foo 是未知 matcher");
}); });
test("expect.body 非数组抛出错误", async () => { test("expect.body 非数组抛出错误", async () => {

View File

@@ -0,0 +1,31 @@
import { describe, expect, test } from "bun:test";
import { normalizeExpectMatchers, normalizeValueMatcher } from "../../../../src/server/checker/expect/normalize";
describe("normalizeValueMatcher", () => {
test("normalizes primitive values to equals matcher", () => {
expect(normalizeValueMatcher("stop")).toEqual({ equals: "stop" });
expect(normalizeValueMatcher(1)).toEqual({ equals: 1 });
expect(normalizeValueMatcher(true)).toEqual({ equals: true });
expect(normalizeValueMatcher(null)).toEqual({ equals: null });
});
test("leaves undefined, matcher objects, arrays, and plain objects unchanged", () => {
const matcher = { lte: 5000 };
const array = [1, 2];
const object = { foo: "bar" };
expect(normalizeValueMatcher(undefined)).toBeUndefined();
expect(normalizeValueMatcher(matcher)).toBe(matcher);
expect(normalizeValueMatcher(array)).toBe(array);
expect(normalizeValueMatcher(object)).toBe(object);
});
test("normalizes only selected expect keys", () => {
const expectConfig: Record<string, unknown> = { durationMs: 100, responded: true };
normalizeExpectMatchers(expectConfig, ["durationMs"]);
expect(expectConfig).toEqual({ durationMs: { equals: 100 }, responded: true });
});
});

View File

@@ -49,13 +49,13 @@ describe("validateDbConfig", () => {
expect(unknownError!.code).toBe("unknown-field"); expect(unknownError!.code).toBe("unknown-field");
}); });
test("expect.durationMs 非 matcher 返回错误", () => { test("expect.durationMs 数组简写返回错误", () => {
const result = validateDbConfig({ const result = validateDbConfig({
defaults: {}, defaults: {},
targets: [ targets: [
{ {
db: { url: "sqlite://:memory:" }, db: { url: "sqlite://:memory:" },
expect: { durationMs: "invalid" }, expect: { durationMs: [1, 2] },
id: "test", id: "test",
name: "test", name: "test",
type: "db", type: "db",

View File

@@ -48,19 +48,19 @@ describe("validatePingConfig", () => {
expect(issues.some((item) => item.path.endsWith("expect.maxPacketLoss"))).toBe(true); expect(issues.some((item) => item.path.endsWith("expect.maxPacketLoss"))).toBe(true);
}); });
test("durationMs 类型非法", () => { test("durationMs 数组简写非法", () => {
const issues = validate({ expect: { durationMs: -1 }, id: "ping", ping: { host: "127.0.0.1" }, type: "ping" }); const issues = validate({ expect: { durationMs: [1, 2] }, id: "ping", ping: { host: "127.0.0.1" }, type: "ping" });
expect(issues.some((item) => item.path.endsWith("expect.durationMs"))).toBe(true); expect(issues.some((item) => item.path.endsWith("expect.durationMs"))).toBe(true);
}); });
test("avgLatencyMs 类型非法", () => { test("avgLatencyMs 对象简写非法", () => {
const issues = validate({ const issues = validate({
expect: { avgLatencyMs: "slow" }, expect: { avgLatencyMs: { foo: "bar" } },
id: "ping", id: "ping",
ping: { host: "127.0.0.1" }, ping: { host: "127.0.0.1" },
type: "ping", type: "ping",
}); });
expect(issues.some((item) => item.path.endsWith("expect.avgLatencyMs"))).toBe(true); expect(issues.some((item) => item.path.endsWith("expect.avgLatencyMs.foo"))).toBe(true);
}); });
test("host 为空字符串", () => { test("host 为空字符串", () => {

View File

@@ -0,0 +1,115 @@
import { describe, expect, test } from "bun:test";
import type { CheckerValidationInput } from "../../../../../src/server/checker/runner/types";
import { validateCommandConfig } from "../../../../../src/server/checker/runner/cmd/validate";
import { validateDbConfig } from "../../../../../src/server/checker/runner/db/validate";
import { validateHttpConfig } from "../../../../../src/server/checker/runner/http/validate";
import { validatePingConfig } from "../../../../../src/server/checker/runner/icmp/validate";
import { validateLlmConfig } from "../../../../../src/server/checker/runner/llm/validate";
import { validateTcpConfig } from "../../../../../src/server/checker/runner/tcp/validate";
import { validateUdpConfig } from "../../../../../src/server/checker/runner/udp/validate";
function input(target: Record<string, unknown>): CheckerValidationInput {
return { defaults: {}, targets: [target as CheckerValidationInput["targets"][number]] };
}
describe("ValueMatcher primitive shorthand in checker validators", () => {
test("normalizes shorthand for all checker ValueMatcher fields", () => {
const targets = [
{
expect: { durationMs: 100 },
http: { url: "https://example.com" },
id: "http",
type: "http",
validate: validateHttpConfig,
},
{
expect: { durationMs: 100 },
id: "tcp",
tcp: { host: "127.0.0.1", port: 80 },
type: "tcp",
validate: validateTcpConfig,
},
{
expect: { durationMs: 100, responseSize: 1, sourceHost: "127.0.0.1", sourcePort: 53 },
id: "udp",
type: "udp",
udp: { host: "127.0.0.1", port: 53 },
validate: validateUdpConfig,
},
{
expect: { avgLatencyMs: 1, durationMs: 100, maxLatencyMs: 2, packetLossPercent: 0 },
id: "ping",
ping: { host: "127.0.0.1" },
type: "ping",
validate: validatePingConfig,
},
{
cmd: { exec: "true" },
expect: { durationMs: 100 },
id: "cmd",
type: "cmd",
validate: validateCommandConfig,
},
{
db: { url: "sqlite://:memory:" },
expect: { durationMs: 100, rowCount: 1 },
id: "db",
type: "db",
validate: validateDbConfig,
},
{
expect: {
durationMs: 100,
finishReason: "stop",
rawFinishReason: null,
stream: { firstTokenMs: 10 },
usage: { inputTokens: 1, outputTokens: 2, totalTokens: 3 },
},
id: "llm",
llm: {
mode: "stream",
model: "test-model",
prompt: "hello",
provider: "openai",
url: "https://example.com/v1/chat/completions",
},
type: "llm",
validate: validateLlmConfig,
},
];
for (const target of targets) {
const { validate, ...config } = target;
expect(validate(input(config))).toHaveLength(0);
expect((config.expect as Record<string, unknown>)["durationMs"]).toEqual({ equals: 100 });
}
});
test("rejects array and object shorthand while accepting explicit equals", () => {
const arrayTarget = {
expect: { durationMs: [1, 2] },
http: { url: "https://example.com" },
id: "array",
type: "http",
};
const objectTarget = {
expect: { durationMs: { foo: "bar" } },
http: { url: "https://example.com" },
id: "object",
type: "http",
};
const equalsObjectTarget = {
expect: { durationMs: { equals: { foo: "bar" } } },
http: { url: "https://example.com" },
id: "equals-object",
type: "http",
};
expect(validateHttpConfig(input(arrayTarget)).some((issue) => issue.path.includes("durationMs"))).toBe(true);
expect(validateHttpConfig(input(objectTarget)).some((issue) => issue.code === "unknown-matcher")).toBe(true);
expect(validateHttpConfig(input(equalsObjectTarget))).toHaveLength(0);
});
});

View File

@@ -106,18 +106,18 @@ describe("validateTcpConfig", () => {
expect(issues.some((i) => i.path.includes("connected"))).toBe(true); expect(issues.some((i) => i.path.includes("connected"))).toBe(true);
}); });
test("expect durationMs 非 matcher", () => { test("expect durationMs 数组简写非法", () => {
const issues = validateTcpConfig( const issues = validateTcpConfig(
makeInput([ makeInput([
{ {
expect: { durationMs: "slow" }, expect: { durationMs: [1, 2] },
id: "t1", id: "t1",
tcp: { host: "127.0.0.1", port: 80 }, tcp: { host: "127.0.0.1", port: 80 },
type: "tcp", type: "tcp",
}, },
]), ]),
); );
expect(issues.some((i) => i.path.includes("durationMs"))).toBe(true); expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("durationMs"))).toBe(true);
}); });
test("expect 未知字段", () => { test("expect 未知字段", () => {

View File

@@ -213,12 +213,12 @@ describe("validateUdpConfig", () => {
expect(issues.some((i) => i.path.includes("responded") && i.message.includes("响应来源"))).toBe(true); expect(issues.some((i) => i.path.includes("responded") && i.message.includes("响应来源"))).toBe(true);
}); });
it("reports invalid-type for non-matcher expect.durationMs", () => { it("reports invalid-type for array shorthand expect.durationMs", () => {
const issues = validateUdpConfig( const issues = validateUdpConfig(
makeInput({ makeInput({
targets: [ targets: [
{ {
expect: { durationMs: -100 }, expect: { durationMs: [1, 2] },
id: "test", id: "test",
type: "udp", type: "udp",
udp: { host: "127.0.0.1", port: 53 }, udp: { host: "127.0.0.1", port: 53 },