From 12cd05b04e7262136aca25a0c34f73014316eb94 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Tue, 19 May 2026 17:07:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20ValueMatcher=20=E6=94=AF=E6=8C=81=20pri?= =?UTF-8?q?mitive=20=E5=8E=9F=E5=A7=8B=E5=80=BC=E7=AE=80=E5=86=99=EF=BC=8C?= =?UTF-8?q?=E7=AD=89=E4=BB=B7=E4=BA=8E=20{=20equals:=20value=20}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEVELOPMENT.md | 4 +- README.md | 2 +- openspec/specs/expect-rule-system/spec.md | 44 +- openspec/specs/probe-config/spec.md | 42 +- probe-config.schema.json | 2350 ++++++++++------- probes.example.yaml | 35 +- src/server/checker/expect/matcher.ts | 9 +- src/server/checker/expect/normalize.ts | 22 + src/server/checker/expect/types.ts | 4 + src/server/checker/expect/validate-matcher.ts | 5 +- src/server/checker/runner/cmd/types.ts | 4 +- src/server/checker/runner/cmd/validate.ts | 4 + src/server/checker/runner/db/expect.ts | 4 +- src/server/checker/runner/db/types.ts | 6 +- src/server/checker/runner/db/validate.ts | 3 + src/server/checker/runner/http/execute.ts | 4 +- src/server/checker/runner/http/types.ts | 4 +- src/server/checker/runner/http/validate.ts | 3 + src/server/checker/runner/icmp/expect.ts | 8 +- src/server/checker/runner/icmp/types.ts | 10 +- src/server/checker/runner/icmp/validate.ts | 3 + src/server/checker/runner/llm/types.ts | 16 +- src/server/checker/runner/llm/validate.ts | 7 + src/server/checker/runner/tcp/types.ts | 4 +- src/server/checker/runner/tcp/validate.ts | 3 + src/server/checker/runner/udp/expect.ts | 8 +- src/server/checker/runner/udp/types.ts | 10 +- src/server/checker/runner/udp/validate.ts | 3 + src/server/checker/schema/fragments.ts | 8 +- .../checker/config-contract/validate.test.ts | 22 + tests/server/checker/config-loader.test.ts | 37 +- tests/server/checker/expect/normalize.test.ts | 31 + .../server/checker/runner/db/validate.test.ts | 4 +- .../checker/runner/icmp/validate.test.ts | 10 +- .../shared/value-matcher-shorthand.test.ts | 115 + .../checker/runner/tcp/validate.test.ts | 6 +- .../checker/runner/udp/validate.test.ts | 4 +- 37 files changed, 1836 insertions(+), 1022 deletions(-) create mode 100644 src/server/checker/expect/normalize.ts create mode 100644 tests/server/checker/expect/normalize.test.ts create mode 100644 tests/server/checker/runner/shared/value-matcher-shorthand.test.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 51fce60..21b3ec8 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -504,7 +504,7 @@ TcpChecker implements Checker | `ContentRules` | 返回内容或半结构化内容断言,必须是数组 | `body`、`stdout`、`stderr`、`banner`、`response`、`output`、`result` | | `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` 仍对原始结构做深度相等。 @@ -556,7 +556,7 @@ expect 字段 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" }]`),不支持对象快捷写法。 diff --git a/README.md b/README.md index 1666fb7..f86b868 100644 --- a/README.md +++ b/README.md @@ -336,7 +336,7 @@ Ping checker 通过系统 `ping` 命令执行 ICMP 探测,支持 Linux、macOS - `css` — CSS 选择器提取 HTML 元素(`selector` 必填,`attr` 可选提取属性) - `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`。 diff --git a/openspec/specs/expect-rule-system/spec.md b/openspec/specs/expect-rule-system/spec.md index 4ddf1bf..7ae5fd6 100644 --- a/openspec/specs/expect-rule-system/spec.md +++ b/openspec/specs/expect-rule-system/spec.md @@ -7,6 +7,8 @@ ### 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 均通过。 +所有类型为 `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 匹配对象 - **WHEN** 实际值为 `{status: "ok", count: 1}` 且 matcher 为 `{equals: {status: "ok", count: 1}}` - **THEN** 系统 SHALL 使用深度相等判定该 matcher 通过 @@ -23,8 +25,28 @@ - **WHEN** 实际值为 `"healthy"` 且 matcher 为 `{contains: "health", regex: "^ready$"}` - **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 启动期校验 -系统 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 对象被拒绝 - **WHEN** YAML 配置中任一 matcher 对象为空 `{}` @@ -42,6 +64,26 @@ - **WHEN** YAML 配置中任一 matcher 的 `exists` 或 `empty` 值不是布尔类型 - **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 语义 `empty: true` SHALL 在以下情况判定通过:实际值为 `null`、`undefined`、空字符串 `""`、空数组 `[]` 或空对象 `{}`。`empty: false` SHALL 在以上条件均不满足时判定通过。数字 `0` 和布尔 `false` SHALL NOT 被视为 empty。 diff --git a/openspec/specs/probe-config/spec.md b/openspec/specs/probe-config/spec.md index af30fe2..dacee2f 100644 --- a/openspec/specs/probe-config/spec.md +++ b/openspec/specs/probe-config/spec.md @@ -101,6 +101,8 @@ 除 `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 缺少必填字段 - **WHEN** YAML 中某个 target 缺少 id 或 type 字段 - **THEN** 系统 SHALL 以错误退出,提示哪个 target 缺少哪个字段 @@ -194,9 +196,29 @@ - **THEN** 系统 SHALL 以错误退出,提示该 target 的 status 数字不合法 #### Scenario: durationMs matcher 非法 -- **WHEN** YAML 中某个 target 的 `expect.durationMs` 不是合法 `ValueMatcher` +- **WHEN** YAML 中某个 target 的 `expect.durationMs` 不是合法 `ValueMatcher` 也不是 primitive 原始值 - **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 - **WHEN** YAML 中某个 target 配置 `type: ping` 但缺少 `ping.host` - **THEN** 系统 SHALL 以错误退出,提示该 target 缺少 ping.host 字段 @@ -271,7 +293,23 @@ #### Scenario: 导出配置 JSON Schema - **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`。 #### Scenario: 解析 MB diff --git a/probe-config.schema.json b/probe-config.schema.json index 08b486b..6ccab4d 100644 --- a/probe-config.schema.json +++ b/probe-config.schema.json @@ -514,17 +514,8 @@ } }, "durationMs": { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { + "anyOf": [ + { "anyOf": [ { "type": "string" @@ -537,36 +528,65 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } } - } + ] }, "headers": { "additionalProperties": { @@ -596,17 +616,8 @@ ] }, { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { + "anyOf": [ + { "anyOf": [ { "type": "string" @@ -619,36 +630,65 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } } - } + ] } ] }, @@ -801,17 +841,8 @@ "type": "object", "properties": { "durationMs": { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { + "anyOf": [ + { "anyOf": [ { "type": "string" @@ -824,36 +855,65 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } } - } + ] }, "exitCode": { "type": "array", @@ -1445,17 +1505,8 @@ "type": "object", "properties": { "durationMs": { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { + "anyOf": [ + { "anyOf": [ { "type": "string" @@ -1468,36 +1519,65 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } } - } + ] }, "result": { "type": "array", @@ -1743,17 +1823,8 @@ } }, "rowCount": { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { + "anyOf": [ + { "anyOf": [ { "type": "string" @@ -1766,36 +1837,65 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } } - } + ] }, "rows": { "type": "array", @@ -1827,17 +1927,8 @@ ] }, { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { + "anyOf": [ + { "anyOf": [ { "type": "string" @@ -1850,36 +1941,65 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } } - } + ] } ] }, @@ -2208,17 +2328,8 @@ "type": "boolean" }, "durationMs": { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { + "anyOf": [ + { "anyOf": [ { "type": "string" @@ -2231,36 +2342,65 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } } - } + ] } } }, @@ -2361,17 +2501,8 @@ "type": "boolean" }, "avgLatencyMs": { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { + "anyOf": [ + { "anyOf": [ { "type": "string" @@ -2384,49 +2515,69 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } } - } + ] }, "durationMs": { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { + "anyOf": [ + { "anyOf": [ { "type": "string" @@ -2439,49 +2590,69 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } } - } + ] }, "maxLatencyMs": { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { + "anyOf": [ + { "anyOf": [ { "type": "string" @@ -2494,49 +2665,69 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } } - } + ] }, "packetLossPercent": { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { + "anyOf": [ + { "anyOf": [ { "type": "string" @@ -2549,36 +2740,65 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } } - } + ] } } }, @@ -2662,17 +2882,8 @@ "type": "object", "properties": { "durationMs": { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { + "anyOf": [ + { "anyOf": [ { "type": "string" @@ -2685,36 +2896,65 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } } - } + ] }, "responded": { "type": "boolean" @@ -2963,17 +3203,8 @@ } }, "responseSize": { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { + "anyOf": [ + { "anyOf": [ { "type": "string" @@ -2986,49 +3217,69 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } } - } + ] }, "sourceHost": { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { + "anyOf": [ + { "anyOf": [ { "type": "string" @@ -3041,49 +3292,69 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } } - } + ] }, "sourcePort": { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { + "anyOf": [ + { "anyOf": [ { "type": "string" @@ -3096,36 +3367,65 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } } - } + ] } } }, @@ -3251,17 +3551,8 @@ "type": "object", "properties": { "durationMs": { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { + "anyOf": [ + { "anyOf": [ { "type": "string" @@ -3274,49 +3565,69 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } } - } + ] }, "finishReason": { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { + "anyOf": [ + { "anyOf": [ { "type": "string" @@ -3329,36 +3640,65 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } } - } + ] }, "headers": { "additionalProperties": { @@ -3388,17 +3728,8 @@ ] }, { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { + "anyOf": [ + { "anyOf": [ { "type": "string" @@ -3411,36 +3742,65 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } } - } + ] } ] }, @@ -3690,17 +4050,8 @@ } }, "rawFinishReason": { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { + "anyOf": [ + { "anyOf": [ { "type": "string" @@ -3713,36 +4064,65 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } } - } + ] }, "status": { "type": "array", @@ -3768,17 +4148,8 @@ "type": "boolean" }, "firstTokenMs": { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { + "anyOf": [ + { "anyOf": [ { "type": "string" @@ -3791,36 +4162,65 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } } - } + ] } } }, @@ -3829,17 +4229,8 @@ "type": "object", "properties": { "inputTokens": { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { + "anyOf": [ + { "anyOf": [ { "type": "string" @@ -3852,49 +4243,69 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } } - } + ] }, "outputTokens": { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { + "anyOf": [ + { "anyOf": [ { "type": "string" @@ -3907,49 +4318,69 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } } - } + ] }, "totalTokens": { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { + "anyOf": [ + { "anyOf": [ { "type": "string" @@ -3962,36 +4393,65 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } } - } + ] } } } @@ -4436,17 +4896,8 @@ ] }, { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { + "anyOf": [ + { "anyOf": [ { "type": "string" @@ -4459,53 +4910,73 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } } - } + ] } ] }, "type": "object" }, "ValueMatcher": { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { + "anyOf": [ + { "anyOf": [ { "type": "string" @@ -4518,36 +4989,65 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } } - } + ] } } } diff --git a/probes.example.yaml b/probes.example.yaml index f218296..c3ca2fe 100644 --- a/probes.example.yaml +++ b/probes.example.yaml @@ -139,8 +139,7 @@ targets: url: "${sqlite_url}" query: "SELECT 1 as id, 'Alice' as name, 'engineer' as role" expect: - rowCount: - equals: 1 + rowCount: 1 rows: - id: gte: 1 @@ -249,37 +248,13 @@ targets: group: "AI 服务" llm: provider: openai - url: "https://api.openai.com/v1" - model: "gpt-4o-mini" + url: "https://open.bigmodel.cn/api/paas/v4" + model: "glm-4.7-flash" prompt: "Say OK" - key: "${OPENAI_API_KEY}" + key: "d1e97306540d12bb2f834be961fcacb1.SNBShlCxWYJCx0qZ" expect: status: - 200 - finishReason: - equals: "stop" + finishReason: "stop" output: - 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 diff --git a/src/server/checker/expect/matcher.ts b/src/server/checker/expect/matcher.ts index da783b3..5142a20 100644 --- a/src/server/checker/expect/matcher.ts +++ b/src/server/checker/expect/matcher.ts @@ -1,7 +1,7 @@ import { isEmptyObject, isEqual, isNil, isPlainObject } from "es-toolkit"; import type { CheckFailure, JsonValue } from "../types"; -import type { ExpectResult, ValueMatcher } from "./types"; +import type { ExpectResult, ValueMatcher, ValueMatcherInput } from "./types"; import { mismatchFailure } from "./failure"; @@ -66,18 +66,19 @@ export function checkExpectValue(actual: unknown, expected: JsonValue | ValueMat export function checkValueMatcher( actual: unknown, - matcher: undefined | ValueMatcher, + matcher: undefined | ValueMatcherInput, options: { message?: string; path: string; phase: CheckFailure["phase"]; stringifyNonString?: boolean }, ): ExpectResult { 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: mismatchFailure( options.phase, options.path, - matcher, + normalized, actual, options.message ?? `${options.path} mismatch`, ), diff --git a/src/server/checker/expect/normalize.ts b/src/server/checker/expect/normalize.ts new file mode 100644 index 0000000..cb9b6cf --- /dev/null +++ b/src/server/checker/expect/normalize.ts @@ -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, 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; +} diff --git a/src/server/checker/expect/types.ts b/src/server/checker/expect/types.ts index a2a35a2..fadc6d5 100644 --- a/src/server/checker/expect/types.ts +++ b/src/server/checker/expect/types.ts @@ -39,3 +39,7 @@ export interface ValueMatcher { lte?: number; regex?: string; } + +export type ValueMatcherInput = ValueMatcher | ValueMatcherPrimitive; + +export type ValueMatcherPrimitive = boolean | null | number | string; diff --git a/src/server/checker/expect/validate-matcher.ts b/src/server/checker/expect/validate-matcher.ts index 2b56a5e..5ddcf81 100644 --- a/src/server/checker/expect/validate-matcher.ts +++ b/src/server/checker/expect/validate-matcher.ts @@ -6,6 +6,7 @@ import type { ConfigValidationIssue } from "../schema/issues"; import type { JsonValue } from "../types"; import { issue, joinPath } from "../schema/issues"; +import { isValueMatcherPrimitive } from "./normalize"; import { isUnsafeRegex } from "./redos"; export const MatcherKeys = ["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "regex"] as const; @@ -72,7 +73,9 @@ export function validateValueMatcher( options: { requireAtLeastOne?: boolean } = {}, ): ConfigValidationIssue[] { 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[] = []; let found = 0; diff --git a/src/server/checker/runner/cmd/types.ts b/src/server/checker/runner/cmd/types.ts index 3b36bd8..a230ef8 100644 --- a/src/server/checker/runner/cmd/types.ts +++ b/src/server/checker/runner/cmd/types.ts @@ -1,4 +1,4 @@ -import type { ContentRules, ValueMatcher } from "../../expect/types"; +import type { ContentRules, ValueMatcherInput } from "../../expect/types"; import type { ResolvedTargetBase } from "../../types"; export interface CommandDefaultsConfig { @@ -7,7 +7,7 @@ export interface CommandDefaultsConfig { } export interface CommandExpectConfig { - durationMs?: ValueMatcher; + durationMs?: ValueMatcherInput; exitCode?: number[]; stderr?: ContentRules; stdout?: ContentRules; diff --git a/src/server/checker/runner/cmd/validate.ts b/src/server/checker/runner/cmd/validate.ts index 070c837..a69ed82 100644 --- a/src/server/checker/runner/cmd/validate.ts +++ b/src/server/checker/runner/cmd/validate.ts @@ -3,6 +3,7 @@ import { isNumber, isPlainObject, isString } from "es-toolkit"; import type { ConfigValidationIssue } from "../../schema/issues"; import type { CheckerValidationInput } from "../types"; +import { normalizeExpectMatchers } from "../../expect/normalize"; import { validateContentRules, validateValueMatcher } from "../../expect/validate-matcher"; import { issue, joinPath } from "../../schema/issues"; import { parseSize } from "../../utils"; @@ -41,6 +42,9 @@ function validateCommandExpect(target: Record, path: string): C if (expect === undefined || expect === null || !isPlainObject(expect)) return []; const issues: ConfigValidationIssue[] = []; const expectPath = joinPath(path, "expect"); + + normalizeExpectMatchers(expect, ["durationMs"]); + if (expect["stdout"] !== undefined) { issues.push(...validateContentRules(expect["stdout"], joinPath(expectPath, "stdout"), targetName)); } diff --git a/src/server/checker/runner/db/expect.ts b/src/server/checker/runner/db/expect.ts index 7e7707c..68af1d9 100644 --- a/src/server/checker/runner/db/expect.ts +++ b/src/server/checker/runner/db/expect.ts @@ -1,12 +1,12 @@ 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 { checkKeyValueExpect } from "../../expect/key-value"; 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, { message: `rowCount ${actual} 不满足条件`, path: "rowCount", diff --git a/src/server/checker/runner/db/types.ts b/src/server/checker/runner/db/types.ts index 2356652..e8b17d2 100644 --- a/src/server/checker/runner/db/types.ts +++ b/src/server/checker/runner/db/types.ts @@ -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"; export interface DbExpectConfig { - durationMs?: ValueMatcher; + durationMs?: ValueMatcherInput; result?: ContentRules; - rowCount?: ValueMatcher; + rowCount?: ValueMatcherInput; rows?: KeyValueExpect[]; } diff --git a/src/server/checker/runner/db/validate.ts b/src/server/checker/runner/db/validate.ts index e403b0b..59b6912 100644 --- a/src/server/checker/runner/db/validate.ts +++ b/src/server/checker/runner/db/validate.ts @@ -3,6 +3,7 @@ import { isPlainObject, isString } from "es-toolkit"; import type { ConfigValidationIssue } from "../../schema/issues"; import type { CheckerValidationInput } from "../types"; +import { normalizeExpectMatchers } from "../../expect/normalize"; import { validateContentRules, validateKeyValueExpect, validateValueMatcher } from "../../expect/validate-matcher"; import { issue, joinPath } from "../../schema/issues"; @@ -44,6 +45,8 @@ function validateDbExpect(target: Record, path: string): Config const issues: ConfigValidationIssue[] = []; const expectPath = joinPath(path, "expect"); + normalizeExpectMatchers(expect, ["durationMs", "rowCount"]); + if (expect["durationMs"] !== undefined) { issues.push(...validateValueMatcher(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName)); } diff --git a/src/server/checker/runner/http/execute.ts b/src/server/checker/runner/http/execute.ts index c0d1242..c84ca4c 100644 --- a/src/server/checker/runner/http/execute.ts +++ b/src/server/checker/runner/http/execute.ts @@ -6,7 +6,7 @@ import type { HttpExpectConfig, HttpTargetConfig, ResolvedHttpTarget } from "./t import { checkContentRules } from "../../expect/content"; import { errorFailure, mismatchFailure } from "../../expect/failure"; -import { checkValueMatcher } from "../../expect/matcher"; +import { checkValueMatcher, isValueMatcherObject } from "../../expect/matcher"; import { parseSize } from "../../utils"; import { checkHeaders, checkStatus } from "./expect"; import { httpCheckerSchemas } from "./schema"; @@ -198,7 +198,7 @@ function checkEarlyTimeout( start: number, durationMatcher: HttpExpectConfig["durationMs"] | undefined, ): null | { elapsed: number; failure: CheckResult["failure"] } { - if (durationMatcher === undefined) return null; + if (!isValueMatcherObject(durationMatcher)) return null; const limit = Math.min( durationMatcher.lte ?? Number.POSITIVE_INFINITY, durationMatcher.lt ?? Number.POSITIVE_INFINITY, diff --git a/src/server/checker/runner/http/types.ts b/src/server/checker/runner/http/types.ts index b39ad51..c23a692 100644 --- a/src/server/checker/runner/http/types.ts +++ b/src/server/checker/runner/http/types.ts @@ -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"; export interface HttpDefaultsConfig { @@ -9,7 +9,7 @@ export interface HttpDefaultsConfig { export interface HttpExpectConfig { body?: ContentRules; - durationMs?: ValueMatcher; + durationMs?: ValueMatcherInput; headers?: KeyValueExpect; status?: Array; } diff --git a/src/server/checker/runner/http/validate.ts b/src/server/checker/runner/http/validate.ts index c8cf80f..9005ef9 100644 --- a/src/server/checker/runner/http/validate.ts +++ b/src/server/checker/runner/http/validate.ts @@ -3,6 +3,7 @@ import { isNumber, isString } from "es-toolkit"; import type { ConfigValidationIssue } from "../../schema/issues"; import type { CheckerValidationInput } from "../types"; +import { normalizeExpectMatchers } from "../../expect/normalize"; import { isPlainRecord, validateContentRules, @@ -67,6 +68,8 @@ function validateHttpExpect(target: Record, path: string): Conf const issues: ConfigValidationIssue[] = []; const expectPath = joinPath(path, "expect"); + normalizeExpectMatchers(expect, ["durationMs"]); + if (isPlainRecord(expect["headers"])) { issues.push(...validateKeyValueExpect(expect["headers"], joinPath(expectPath, "headers"), targetName)); } diff --git a/src/server/checker/runner/icmp/expect.ts b/src/server/checker/runner/icmp/expect.ts index f460e59..39547c2 100644 --- a/src/server/checker/runner/icmp/expect.ts +++ b/src/server/checker/runner/icmp/expect.ts @@ -1,4 +1,4 @@ -import type { ExpectResult, ValueMatcher } from "../../expect/types"; +import type { ExpectResult, ValueMatcherInput } from "../../expect/types"; import { mismatchFailure } from "../../expect/failure"; 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, { message: "平均延迟不满足条件", 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, { message: "最大延迟不满足条件", 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, { message: "丢包率不满足条件", path: "packetLossPercent", diff --git a/src/server/checker/runner/icmp/types.ts b/src/server/checker/runner/icmp/types.ts index 08b426a..5c4a870 100644 --- a/src/server/checker/runner/icmp/types.ts +++ b/src/server/checker/runner/icmp/types.ts @@ -1,12 +1,12 @@ -import type { ValueMatcher } from "../../expect/types"; +import type { ValueMatcherInput } from "../../expect/types"; import type { ResolvedTargetBase } from "../../types"; export interface PingExpectConfig { alive?: boolean; - avgLatencyMs?: ValueMatcher; - durationMs?: ValueMatcher; - maxLatencyMs?: ValueMatcher; - packetLossPercent?: ValueMatcher; + avgLatencyMs?: ValueMatcherInput; + durationMs?: ValueMatcherInput; + maxLatencyMs?: ValueMatcherInput; + packetLossPercent?: ValueMatcherInput; } export interface PingStats { diff --git a/src/server/checker/runner/icmp/validate.ts b/src/server/checker/runner/icmp/validate.ts index ba16c20..4452aea 100644 --- a/src/server/checker/runner/icmp/validate.ts +++ b/src/server/checker/runner/icmp/validate.ts @@ -3,6 +3,7 @@ import { isNumber, isPlainObject, isString } from "es-toolkit"; import type { ConfigValidationIssue } from "../../schema/issues"; import type { CheckerValidationInput } from "../types"; +import { normalizeExpectMatchers } from "../../expect/normalize"; import { validateValueMatcher } from "../../expect/validate-matcher"; import { issue, joinPath } from "../../schema/issues"; @@ -46,6 +47,8 @@ function validatePingExpect(target: Record, path: string): Conf const targetName = getTargetName(target); const expectPath = joinPath(path, "expect"); + normalizeExpectMatchers(expect, ["packetLossPercent", "avgLatencyMs", "maxLatencyMs", "durationMs"]); + if (expect["alive"] !== undefined && typeof expect["alive"] !== "boolean") { issues.push(issue("invalid-type", joinPath(expectPath, "alive"), "必须为布尔值", targetName)); } diff --git a/src/server/checker/runner/llm/types.ts b/src/server/checker/runner/llm/types.ts index 3d1f95e..42ffda7 100644 --- a/src/server/checker/runner/llm/types.ts +++ b/src/server/checker/runner/llm/types.ts @@ -1,6 +1,6 @@ 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"; export interface LlmCheckObservation { @@ -24,11 +24,11 @@ export interface LlmDefaultsConfig { } export interface LlmExpectConfig { - durationMs?: ValueMatcher; - finishReason?: ValueMatcher; + durationMs?: ValueMatcherInput; + finishReason?: ValueMatcherInput; headers?: KeyValueExpect; output?: ContentRules; - rawFinishReason?: ValueMatcher; + rawFinishReason?: ValueMatcherInput; status?: Array; stream?: LlmStreamExpect; usage?: LlmUsageExpect; @@ -57,7 +57,7 @@ export type LlmProvider = "anthropic" | "openai" | "openai-responses"; export interface LlmStreamExpect { completed?: boolean; - firstTokenMs?: ValueMatcher; + firstTokenMs?: ValueMatcherInput; } export interface LlmStreamObservation { @@ -80,9 +80,9 @@ export interface LlmTargetConfig { } export interface LlmUsageExpect { - inputTokens?: ValueMatcher; - outputTokens?: ValueMatcher; - totalTokens?: ValueMatcher; + inputTokens?: ValueMatcherInput; + outputTokens?: ValueMatcherInput; + totalTokens?: ValueMatcherInput; } export interface LlmUsageObservation { diff --git a/src/server/checker/runner/llm/validate.ts b/src/server/checker/runner/llm/validate.ts index b4b92db..0c953de 100644 --- a/src/server/checker/runner/llm/validate.ts +++ b/src/server/checker/runner/llm/validate.ts @@ -3,6 +3,7 @@ import { isBoolean, isNumber, isString } from "es-toolkit"; import type { ConfigValidationIssue } from "../../schema/issues"; import type { CheckerValidationInput } from "../types"; +import { normalizeExpectMatchers } from "../../expect/normalize"; import { isPlainRecord, validateContentRules, @@ -72,6 +73,8 @@ function validateLlmExpect( const issues: ConfigValidationIssue[] = []; const expectPath = joinPath(path, "expect"); + normalizeExpectMatchers(expect, ["durationMs", "finishReason", "rawFinishReason"]); + if (Array.isArray(expect["status"])) { 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)]; const issues: ConfigValidationIssue[] = []; + normalizeExpectMatchers(stream, ["firstTokenMs"]); + if (stream["completed"] !== undefined && !isBoolean(stream["completed"])) { 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)]; const issues: ConfigValidationIssue[] = []; + normalizeExpectMatchers(usage, ["inputTokens", "outputTokens", "totalTokens"]); + for (const key of ["inputTokens", "outputTokens", "totalTokens"]) { if (usage[key] !== undefined) { issues.push(...validateValueMatcher(usage[key], joinPath(path, key), targetName)); diff --git a/src/server/checker/runner/tcp/types.ts b/src/server/checker/runner/tcp/types.ts index 91b0436..0c0156b 100644 --- a/src/server/checker/runner/tcp/types.ts +++ b/src/server/checker/runner/tcp/types.ts @@ -1,4 +1,4 @@ -import type { ContentRules, ValueMatcher } from "../../expect/types"; +import type { ContentRules, ValueMatcherInput } from "../../expect/types"; import type { ResolvedTargetBase } from "../../types"; export interface ResolvedTcpConfig { @@ -27,7 +27,7 @@ export interface TcpDefaultsConfig { export interface TcpExpectConfig { banner?: ContentRules; connected?: boolean; - durationMs?: ValueMatcher; + durationMs?: ValueMatcherInput; } export interface TcpTargetConfig { diff --git a/src/server/checker/runner/tcp/validate.ts b/src/server/checker/runner/tcp/validate.ts index 0065e07..81537bc 100644 --- a/src/server/checker/runner/tcp/validate.ts +++ b/src/server/checker/runner/tcp/validate.ts @@ -3,6 +3,7 @@ import { isNumber, isPlainObject, isString } from "es-toolkit"; import type { ConfigValidationIssue } from "../../schema/issues"; import type { CheckerValidationInput } from "../types"; +import { normalizeExpectMatchers } from "../../expect/normalize"; import { validateContentRules, validateValueMatcher } from "../../expect/validate-matcher"; import { issue, joinPath } from "../../schema/issues"; @@ -75,6 +76,8 @@ function validateTcpExpect( const issues: ConfigValidationIssue[] = []; const expectPath = joinPath(path, "expect"); + normalizeExpectMatchers(expect, ["durationMs"]); + if (expect["connected"] !== undefined && typeof expect["connected"] !== "boolean") { issues.push(issue("invalid-type", joinPath(expectPath, "connected"), "必须为布尔值", targetName)); } diff --git a/src/server/checker/runner/udp/expect.ts b/src/server/checker/runner/udp/expect.ts index 7160603..21c1c92 100644 --- a/src/server/checker/runner/udp/expect.ts +++ b/src/server/checker/runner/udp/expect.ts @@ -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 { 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, { message: "响应大小不满足条件", path: "responseSize", @@ -30,7 +30,7 @@ export function checkResponseText(text: string, rules: ContentRules): ExpectResu 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, { message: "响应来源地址不满足条件", 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, { message: "响应来源端口不满足条件", path: "sourcePort", diff --git a/src/server/checker/runner/udp/types.ts b/src/server/checker/runner/udp/types.ts index 2f54900..7a83ddf 100644 --- a/src/server/checker/runner/udp/types.ts +++ b/src/server/checker/runner/udp/types.ts @@ -1,4 +1,4 @@ -import type { ContentRules, ValueMatcher } from "../../expect/types"; +import type { ContentRules, ValueMatcherInput } from "../../expect/types"; import type { ResolvedTargetBase } from "../../types"; export interface ResolvedUdpConfig { @@ -29,12 +29,12 @@ export interface UdpDefaultsConfig { export type UdpEncoding = "base64" | "hex" | "text"; export interface UdpExpectConfig { - durationMs?: ValueMatcher; + durationMs?: ValueMatcherInput; responded?: boolean; response?: ContentRules; - responseSize?: ValueMatcher; - sourceHost?: ValueMatcher; - sourcePort?: ValueMatcher; + responseSize?: ValueMatcherInput; + sourceHost?: ValueMatcherInput; + sourcePort?: ValueMatcherInput; } export interface UdpTargetConfig { diff --git a/src/server/checker/runner/udp/validate.ts b/src/server/checker/runner/udp/validate.ts index 462d9b0..1f4ca76 100644 --- a/src/server/checker/runner/udp/validate.ts +++ b/src/server/checker/runner/udp/validate.ts @@ -3,6 +3,7 @@ import { isNumber, isPlainObject, isString } from "es-toolkit"; import type { ConfigValidationIssue } from "../../schema/issues"; import type { CheckerValidationInput } from "../types"; +import { normalizeExpectMatchers } from "../../expect/normalize"; import { validateContentRules, validateValueMatcher } from "../../expect/validate-matcher"; import { issue, joinPath } from "../../schema/issues"; @@ -75,6 +76,8 @@ function validateUdpExpect(target: Record, path: string): Confi const expectPath = joinPath(path, "expect"); const responded: unknown = expect["responded"]; + normalizeExpectMatchers(expect, ["durationMs", "responseSize", "sourceHost", "sourcePort"]); + if (responded !== undefined && typeof responded !== "boolean") { issues.push(issue("invalid-type", joinPath(expectPath, "responded"), "必须为布尔值", targetName)); } diff --git a/src/server/checker/schema/fragments.ts b/src/server/checker/schema/fragments.ts index f34c4fa..39714bb 100644 --- a/src/server/checker/schema/fragments.ts +++ b/src/server/checker/schema/fragments.ts @@ -25,6 +25,10 @@ export const jsonValueSchema = Type.Unsafe({ ], }); +export const primitiveValueSchema = Type.Unsafe({ + anyOf: [{ type: "string" }, { type: "number" }, { type: "boolean" }, { type: "null" }], +}); + export const sizeSchema = Type.Union([Type.String(), Type.Integer({ minimum: 0 })]); export const variableValueSchema = Type.Union([Type.String(), Type.Number(), Type.Boolean()]); @@ -72,7 +76,9 @@ export function createKeyValueExpectSchema(): 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 { diff --git a/tests/server/checker/config-contract/validate.test.ts b/tests/server/checker/config-contract/validate.test.ts index 4aafd80..c3aad9f 100644 --- a/tests/server/checker/config-contract/validate.test.ts +++ b/tests/server/checker/config-contract/validate.test.ts @@ -62,6 +62,28 @@ describe("config contract", () => { ).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", () => { const result = validateProbeConfigContract( { diff --git a/tests/server/checker/config-loader.test.ts b/tests/server/checker/config-loader.test.ts index 5fac7b5..bfdbfb8 100644 --- a/tests/server/checker/config-loader.test.ts +++ b/tests/server/checker/config-loader.test.ts @@ -3,12 +3,14 @@ import { mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; 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 { ResolvedHttpTarget } from "../../../src/server/checker/runner/http/types"; import type { ResolvedPingTarget } from "../../../src/server/checker/runner/icmp/types"; import type { ResolvedTcpTarget } from "../../../src/server/checker/runner/tcp/types"; 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 { CommandChecker } from "../../../src/server/checker/runner/cmd/execute"; import { HttpChecker } from "../../../src/server/checker/runner/http/execute"; @@ -288,6 +290,32 @@ targets: 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 () => { const configPath = join(tempDir, "empty-name.yaml"); await writeFile( @@ -1076,8 +1104,8 @@ targets: await expect(loadConfig(configPath)).rejects.toThrow("5xx"); }); - test("expect.durationMs 非 matcher 抛出错误", async () => { - const configPath = join(tempDir, "neg-duration.yaml"); + test("expect.durationMs 对象简写抛出错误", async () => { + const configPath = join(tempDir, "bad-duration-object.yaml"); await writeFile( configPath, `targets: @@ -1087,11 +1115,12 @@ targets: http: url: "http://example.com" expect: - durationMs: -100 + durationMs: + foo: "bar" `, ); // 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 () => { diff --git a/tests/server/checker/expect/normalize.test.ts b/tests/server/checker/expect/normalize.test.ts new file mode 100644 index 0000000..1472e75 --- /dev/null +++ b/tests/server/checker/expect/normalize.test.ts @@ -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 = { durationMs: 100, responded: true }; + + normalizeExpectMatchers(expectConfig, ["durationMs"]); + + expect(expectConfig).toEqual({ durationMs: { equals: 100 }, responded: true }); + }); +}); diff --git a/tests/server/checker/runner/db/validate.test.ts b/tests/server/checker/runner/db/validate.test.ts index 884108c..0bfa81d 100644 --- a/tests/server/checker/runner/db/validate.test.ts +++ b/tests/server/checker/runner/db/validate.test.ts @@ -49,13 +49,13 @@ describe("validateDbConfig", () => { expect(unknownError!.code).toBe("unknown-field"); }); - test("expect.durationMs 非 matcher 返回错误", () => { + test("expect.durationMs 数组简写返回错误", () => { const result = validateDbConfig({ defaults: {}, targets: [ { db: { url: "sqlite://:memory:" }, - expect: { durationMs: "invalid" }, + expect: { durationMs: [1, 2] }, id: "test", name: "test", type: "db", diff --git a/tests/server/checker/runner/icmp/validate.test.ts b/tests/server/checker/runner/icmp/validate.test.ts index db4267f..c136f8a 100644 --- a/tests/server/checker/runner/icmp/validate.test.ts +++ b/tests/server/checker/runner/icmp/validate.test.ts @@ -48,19 +48,19 @@ describe("validatePingConfig", () => { expect(issues.some((item) => item.path.endsWith("expect.maxPacketLoss"))).toBe(true); }); - test("durationMs 类型非法", () => { - const issues = validate({ expect: { durationMs: -1 }, id: "ping", ping: { host: "127.0.0.1" }, type: "ping" }); + test("durationMs 数组简写非法", () => { + 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); }); - test("avgLatencyMs 类型非法", () => { + test("avgLatencyMs 对象简写非法", () => { const issues = validate({ - expect: { avgLatencyMs: "slow" }, + expect: { avgLatencyMs: { foo: "bar" } }, id: "ping", ping: { host: "127.0.0.1" }, 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 为空字符串", () => { diff --git a/tests/server/checker/runner/shared/value-matcher-shorthand.test.ts b/tests/server/checker/runner/shared/value-matcher-shorthand.test.ts new file mode 100644 index 0000000..5988897 --- /dev/null +++ b/tests/server/checker/runner/shared/value-matcher-shorthand.test.ts @@ -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): 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)["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); + }); +}); diff --git a/tests/server/checker/runner/tcp/validate.test.ts b/tests/server/checker/runner/tcp/validate.test.ts index e85f65b..25bdc3f 100644 --- a/tests/server/checker/runner/tcp/validate.test.ts +++ b/tests/server/checker/runner/tcp/validate.test.ts @@ -106,18 +106,18 @@ describe("validateTcpConfig", () => { expect(issues.some((i) => i.path.includes("connected"))).toBe(true); }); - test("expect durationMs 非 matcher", () => { + test("expect durationMs 数组简写非法", () => { const issues = validateTcpConfig( makeInput([ { - expect: { durationMs: "slow" }, + expect: { durationMs: [1, 2] }, id: "t1", tcp: { host: "127.0.0.1", port: 80 }, 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 未知字段", () => { diff --git a/tests/server/checker/runner/udp/validate.test.ts b/tests/server/checker/runner/udp/validate.test.ts index e6350d2..6de8c51 100644 --- a/tests/server/checker/runner/udp/validate.test.ts +++ b/tests/server/checker/runner/udp/validate.test.ts @@ -213,12 +213,12 @@ describe("validateUdpConfig", () => { 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( makeInput({ targets: [ { - expect: { durationMs: -100 }, + expect: { durationMs: [1, 2] }, id: "test", type: "udp", udp: { host: "127.0.0.1", port: 53 },