1
0

refactor: expect 类型模型重构,Raw/Resolved 双层分离与断言基础设施内聚

- 重命名 ContentRules→ContentExpectations, KeyValueExpect→KeyedExpectations
- 新增 Raw/Resolved 双层模型:resolve 阶段物化为执行计划,store 持久化 Raw 快照
- HTTP body 按需读取:status/headers 失败或无 body expectation 时不读取 body
- 新增 displayValueExpectation() 解包 failure.expected 用户可读展示
- 修复 checkEarlyTimeout 独立 lte/lt 检查,修复 KeyedExpectations JSON Schema
- 新增 expect/value.ts(resolve/check/display)、keyed.ts、content.ts、headers.ts、status.ts
- 删除旧 normalize.ts/matcher.ts/validate-matcher.ts/key-value.ts
- 更新 DEVELOPMENT.md:expect 五层管线表、displayValueExpectation、1.7↔1.10 交叉引用
- 同步 13 个 main specs,归档 refactor-expect-type-model 变更(62/62 tasks)
This commit is contained in:
2026-05-20 16:12:48 +08:00
parent 6098be2d9e
commit 60a54b483f
90 changed files with 2487 additions and 1493 deletions

View File

@@ -39,7 +39,7 @@ src/
variables.ts 配置 variables 提取、target 字符串变量替换和 unresolved-variable issue 生成
schema/ TypeBox + Ajv 配置契约、schema fragments、issue 渲染和 schema 导出入口
builder.ts 全量 JSON Schema 组装(遍历 registry 生成)
fragments.ts 共享 TypeBox schema 片段duration、size、ValueMatcher、ContentRules、KeyValueExpect 等)
fragments.ts 共享 TypeBox schema 片段duration、size、ValueMatcher、ContentExpectations、KeyedExpectations 等)
validate.ts Ajv 契约校验入口
issues.ts 校验问题类型与渲染
types.ts schema 层类型
@@ -48,12 +48,14 @@ src/
engine.ts 调度引擎(按 interval 分组的 es-toolkit groupBy + Semaphore 并发控制 + 数据清理)
utils.ts 共享工具函数parseSize、parseDuration
expect/ 共享 expect 断言基础设施(跨 checker 复用)
types.ts ExpectResult、ValueMatcher、ContentRules、KeyValueExpect 类型
types.ts Raw/Resolved ValueExpectation、ContentExpectations、KeyedExpectations、ExpectationResult 类型
failure.ts 失败信息构造errorFailure、mismatchFailure、truncateActual
matcher.ts ValueMatcher 执行、JSONPath 提取、字面量 equals 快捷语义
content.ts ContentRules 执行direct/json/css/xpath
key-value.ts KeyValueExpect 执行(动态键与 key 规范化)
validate-matcher.ts matcher/content/key-value 语义校验
value.ts ValueExpectation resolveprimitive→equals和执行、JSONPath 提取
content.ts Resolved ContentExpectations 执行kind=value/json/css/xpath和 Raw resolve
keyed.ts Resolved KeyedExpectations 执行(顺序 + key 规范化)和 Raw resolve
headers.ts HTTP/LLM 共享 header keyed expectation 包装(大小写不敏感)
status.ts HTTP/LLM 共享 status code 断言(精确数值与 1xx-5xx 范围)
validate.ts Raw value/content/keyed expectation 语义校验(不修改输入)
redos.ts regex ReDoS 风险检测
runner/ Checker 统一抽象与注册机制
types.ts CheckerDefinition、CheckerContext、CheckerSchemas、ResolveContext
@@ -266,9 +268,9 @@ checkerRegistry单例
每个 checker 目录的标准文件结构:
| 文件 | 职责 |
| ------------- | ------------------------------------------------------------------------------------- |
| ------------- | ------------------------------------------------------------------------------------------ |
| `index.ts` | 模块入口re-export Checker 类 |
| `types.ts` | Checker 专属类型ResolvedXxxTarget、XxxTargetConfig、XxxExpectConfig 等) |
| `types.ts` | Checker 专属类型RawXxxTargetConfig、Raw/Resolved XxxExpectConfig、ResolvedXxxTarget 等) |
| `schema.ts` | TypeBox 契约 schemaconfig / defaults / expect 三部分) |
| `validate.ts` | 启动期语义校验JSON Schema 无法表达的规则) |
| `execute.ts` | Checker 类resolve默认值合并 + 解析、execute执行检查、serializeDB 持久化) |
@@ -279,8 +281,8 @@ checkerRegistry单例
`src/server/checker/runner/tcp/types.ts` 中定义 checker 专属类型(参考 `http/types.ts``cmd/types.ts`
- `XxxTargetConfig` — YAML 原始配置类型
- `XxxExpectConfig` — expect 字段类型
- `RawXxxTargetConfig` — YAML 原始配置类型
- `RawXxxExpectConfig` / `ResolvedXxxExpectConfig` Raw expect 字段类型与运行期 Resolved expect 执行计划类型
- `XxxDefaultsConfig` — defaults 专属字段类型
- `ResolvedXxxTarget extends ResolvedTargetBase` — resolve 后的完整类型,含 `type: "xxx"` 字面量
@@ -293,14 +295,14 @@ checkerRegistry单例
**可复用的共享 fragments**(来自 `schema/fragments.ts`
| Fragment | 用途 |
| ------------------------------ | -------------------------------------------------------- |
| ----------------------------------- | ----------------------------------------------------------- |
| `durationSchema` | 时长字符串(`"30s"``"5m"``"2h"``"7d"``"500ms"` |
| `sizeSchema` | 大小单位(字符串如 `"10MB"` 或整数) |
| `statusCodePatternSchema` | 状态码(`100`-`599``"2xx"` |
| `stringMapSchema` | `Record<string, string>`(用于 headers / env |
| `createValueMatcherSchema()` | `ValueMatcher` 对象equals/contains/regex/数值比较等) |
| `createContentRulesSchema()` | `ContentRules` 数组(direct/json/css/xpath 内容规则) |
| `createKeyValueExpectSchema()` | 动态键 `KeyValueExpect`headers、DB rows 列值) |
| `createContentExpectationsSchema()` | `ContentExpectations` 数组(value/json/css/xpath 内容断言) |
| `createKeyedExpectationsSchema()` | 动态键 `KeyedExpectations`headers、DB rows 列值) |
| `matcherProperties()` | matcher 字段 Record供 extractor schema 复用 |
**注意**:默认对象策略为 `additionalProperties: false`。只有明确的动态键值表(如 `http.headers``cmd.env`)可以开放任意键名。
@@ -313,13 +315,13 @@ checkerRegistry单例
export function validateTcpConfig(input: CheckerValidationInput): ConfigValidationIssue[];
```
**共享校验工具**`expect/validate-matcher.ts`
**共享校验工具**`expect/validate.ts`
| 函数 | 用途 |
| ------------------------------------------------------ | ----------------------------------------- |
| `validateValueMatcher(value, path, targetName, opts?)` | 校验 matcher 字段、类型、regex 和组合语义 |
| `validateContentRules(rules, path, targetName)` | 校验 ContentRules 数组、extractor 互斥 |
| `validateKeyValueExpect(value, path, targetName)` | 校验动态键值断言 |
| -------------------------------------------------------------- | --------------------------------------------------- |
| `validateRawValueExpectation(value, path, targetName, opts?)` | 校验 Raw `ValueExpectation`primitive 或 matcher |
| `validateRawContentExpectations(value, path, targetName)` | 校验 Raw `ContentExpectations` 数组、extractor 互斥 |
| `validateRawKeyedExpectations(value, path, targetName, opts?)` | 校验 Raw `KeyedExpectations`,可选大小写不敏感重复 |
| `validateJsonPath(path, rulePath, targetName)` | 校验项目支持的 JSONPath 子集 |
| `isJsonValue(value)` | 判断是否为合法 JSON value |
@@ -342,9 +344,47 @@ TcpChecker implements Checker
**`resolve()` 规范**
- 只做默认值合并、路径解析、单位转换,**不执行校验**
- 若 checker 支持 expect必须保留 `rawExpect` 为变量替换后的 Raw 配置快照,并生成只供 `execute()` 使用的 Resolved `expect`
- 返回 `satisfies ResolvedXxxTarget` 确保类型正确
- 通过 `context.defaults[this.configKey]` 访问 checker 专属默认值(需 `as` 断言为具体类型)
**expect 五层管线**:每种断言模型从定义到执行经过五个阶段,每层使用对应的共享函数:
| 断言模型 | 类型层Raw | Schema 层 | Validate 层 | Resolve 层 | Execute 层 |
| --------------------- | ----------------------------------- | ----------------------------------- | ---------------------------------- | ------------------------------ | ---------------------------- |
| `ValueExpectation` | `number \| ValueMatcher` | `createValueMatcherSchema()` | `validateRawValueExpectation()` | `resolveValueExpectation()` | `checkValueExpectation()` |
| `ContentExpectations` | `(ValueMatcher \| ExtractorRule)[]` | `createContentExpectationsSchema()` | `validateRawContentExpectations()` | `resolveContentExpectations()` | `checkContentExpectations()` |
| `KeyedExpectations` | `Record<string, ValueExpectation>` | `createKeyedExpectationsSchema()` | `validateRawKeyedExpectations()` | `resolveKeyedExpectations()` | `checkKeyedExpectations()` |
选择哪种模型参考 [1.10 expect 字段选择规范](#110-expect-断言系统)的决策树。
**resolve 中的标准模式**
```typescript
// resolve() 内:逐字段调用对应的 resolve 函数,未配置的字段保持 undefined
const rawExpect = raw.expect ?? {};
expect: {
durationMs: rawExpect.durationMs != null ? resolveValueExpectation(rawExpect.durationMs) : undefined,
body: rawExpect.body != null ? resolveContentExpectations(rawExpect.body) : undefined,
headers: rawExpect.headers != null ? resolveKeyedExpectations(rawExpect.headers) : undefined,
}
```
**execute 中的标准模式**
```typescript
// execute() 内:按快速失败顺序依次检查,首个失败即返回
const r = resolved.expect;
if (r.durationMs) {
const result = checkValueExpectation(elapsed, r.durationMs, { phase: "duration", path: "durationMs" });
if (!result.matched) return { ..., failure: result.failure, matched: false };
}
if (r.body) {
const result = checkContentExpectations(bodyText, r.body, { phase: "body", path: "body" });
if (!result.matched) return { ..., failure: result.failure, matched: false };
}
```
**`execute()` 规范**
- 始终记录 `timestamp`ISO 字符串)和 `start = performance.now()`
@@ -353,23 +393,29 @@ TcpChecker implements Checker
- 成功时 `failure: null, matched: true`
- 异常时使用 `errorFailure(phase, path, message)` 构造 failure
- 不匹配时使用 `mismatchFailure(phase, path, expected, actual, message)` 构造 failure
- `mismatchFailure``expected` 参数应传用户可读值,使用 `displayValueExpectation(matcher)` 解包单字段 `{ equals: x }``x`
**可用的共享断言工具**`checker/expect/`
| 模块 | 函数 | 用途 |
| --------------------- | ----------------------------------------------------- | ------------------------------------- |
| ------------- | ------------------------------------------------------------------- | ---------------------------------------------------- |
| `failure.ts` | `errorFailure(phase, path, msg)` | 构造错误类型 failure |
| `failure.ts` | `mismatchFailure(phase, path, expected, actual, msg)` | 构造不匹配类型 failure |
| `failure.ts` | `truncateActual(value, maxLen?)` | 截断过长的 actual 值(默认 200 字符) |
| `matcher.ts` | `applyMatcher(actual, matcher, options?)` | 执行 ValueMatcher AND 匹配 |
| `matcher.ts` | `checkValueMatcher(actual, matcher, options)` | 执行 matcher 并返回 `ExpectResult` |
| `matcher.ts` | `checkExpectValue(actual, expected)` | 执行字面量 equals 或 matcher |
| `matcher.ts` | `evaluateJsonPath(json, path)` | JSONPath 提取 |
| `content.ts` | `checkContentRules(source, rules, options)` | 执行 ContentRules 数组 |
| `key-value.ts` | `checkKeyValueExpect(actual, expected, options)` | 执行动态键值断言 |
| `validate-matcher.ts` | `validateValueMatcher/ContentRules/KeyValueExpect` | matcher/content/key-value 语义校验 |
| `value.ts` | `applyValueMatcher(actual, matcher, options?)` | 执行 Resolved `ValueMatcher` AND 匹配 |
| `value.ts` | `checkValueExpectation(actual, matcher, options)` | 执行 matcher 并返回 `ExpectationResult` |
| `value.ts` | `resolveValueExpectation(raw)` | Raw `ValueExpectation` → Resolved `ValueExpectation` |
| `value.ts` | `displayValueExpectation(matcher)` | 解包单字段 `{ equals: x }``x`,用于 failure 展示 |
| `value.ts` | `evaluateJsonPath(json, path)` | JSONPath 提取 |
| `content.ts` | `checkContentExpectations(source, expectations, options)` | 执行 Resolved `ContentExpectations` |
| `content.ts` | `resolveContentExpectations(raw)` | Raw → Resolved `ContentExpectations` |
| `keyed.ts` | `checkKeyedExpectations(actual, expectations, options)` | 执行 Resolved `KeyedExpectations` |
| `keyed.ts` | `resolveKeyedExpectations(raw)` | Raw Record → Resolved 有序数组 |
| `headers.ts` | `checkHeaderExpectations(headers, expectations, options?)` | HTTP/LLM headers 大小写不敏感包装 |
| `status.ts` | `checkStatusCode(actual, expected, phase, path)` | HTTP/LLM status code精确数值与 1xx-5xx 范围) |
| `validate.ts` | `validateRawValueExpectation/ContentExpectations/KeyedExpectations` | Raw expectation 语义校验(不修改输入) |
**Checker 专属断言**(如需要)放在同目录的 `expect.ts` 中,参考 `http/expect.ts`checkStatus、checkHeaders)和 `cmd/expect.ts`checkExitCode
**Checker 专属断言**(如需要)放在同目录的 `expect.ts` 中,参考 `cmd/expect.ts`checkExitCode`tcp/expect.ts`checkConnected`udp/expect.ts`checkResponded)和 `icmp/expect.ts`checkAlive。HTTP/LLM 复用的 status 与 headers 断言放在共享 expect 模块
#### 1.7.6 步骤五:创建模块入口并注册
@@ -496,19 +542,21 @@ TcpChecker implements Checker
两层模型:**观测值收集** → **规则校验**。共享断言基础设施位于 `checker/expect/`checker 专属状态断言位于各自目录。
**Raw vs Resolved**:用户 YAML 写的是 Raw 形态primitive 简写、`{ json: { path, equals } }` 提取器对象、`Record<string, value>` 键值表),`config-loader` 的 resolve 阶段将其转换为 Resolved 形态供运行期执行(`{ equals: primitive }``{ kind, matcher, ... }` content 联合、`{ key, matcher }[]` 有序数组。Store 持久化 Raw 快照(`rawExpect`checker.execute 消费 Resolved `expect`
**共享模型**
| 模型 | 用途 | 典型字段 |
| ---------------- | ---------------------------------------------- | -------------------------------------------------------------------- |
| `ValueMatcher` | 单个值、数字指标和字符串元数据断言 | `durationMs``rowCount``usage.totalTokens``finishReason` |
| `ContentRules` | 返回内容或半结构化内容断言,必须是数组 | `body``stdout``stderr``banner``response``output``result` |
| `KeyValueExpect` | 动态键值断言,字面量等价于 `{ equals: value }` | `headers`、DB `rows[]` 中的列值 |
| --------------------- | ---------------------------------------------- | -------------------------------------------------------------------- |
| `ValueExpectation` | 单个值、数字指标和字符串元数据断言 | `durationMs``rowCount``usage.totalTokens``finishReason` |
| `ContentExpectations` | 返回内容或半结构化内容断言,必须是数组 | `body``stdout``stderr``banner``response``output``result` |
| `KeyedExpectations` | 动态键值断言,字面量等价于 `{ 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 expect 字段输入可使用 string、number、boolean 或 null 简写,语义校验入口会归一化为 `{ equals: value }`;数组和对象简写不支持,必须显式写成 `{ equals: ... }`
`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))`。ValueExpectation Raw 输入可使用 string、number、boolean 或 null 简写,resolve 阶段归一化为 `{ equals: value }`;数组和对象简写不支持,必须显式写成 `{ equals: ... }`
`ContentRules` 数组按顺序快速失败。数组项可以是直接 matcher也可以是 `{ json: {...} }``{ css: {...} }``{ xpath: {...} }` 提取器规则;一条规则不能混用直接 matcher 和 extractor多个 extractor 也不能共存。Extractor 未配置 matcher 时等价于 `exists: true`。对对象或数组源执行直接 `contains`/`regex` 时会先 JSON 序列化,`equals` 仍对原始结构做深度相等。
`ContentExpectations` 数组按顺序快速失败。数组项可以是直接 matcher也可以是 `{ json: {...} }``{ css: {...} }``{ xpath: {...} }` 提取器规则;一条规则不能混用直接 matcher 和 extractor多个 extractor 也不能共存。Extractor 未配置 matcher 时等价于 `exists: true`。对对象或数组源执行直接 `contains`/`regex` 时会先 JSON 序列化,`equals` 仍对原始结构做深度相等。
启动期语义校验统一由 `expect/validate-matcher.ts` 负责,会校验空 matcher、未知字段、字段类型、`exists:false` 组合、ContentRules 互斥性、JSONPath 子集、XPath 可编译性、regex 可编译性ReDoS 风险。旧字段 `match``maxDurationMs`、ICMP 的 `max*` 阈值字段不再支持。
启动期语义校验统一由 `expect/validate.ts` 负责,会校验空 matcher、未知字段、字段类型、`exists:false` 组合、ContentExpectations 互斥性、JSONPath 子集、XPath 可编译性、regex 可编译性ReDoS 风险以及 HTTP/LLM headers 大小写归一化后重复 key。语义校验不修改 Raw 输入。旧字段 `match``maxDurationMs`、ICMP 的 `max*` 阈值字段不再支持。
**快速失败顺序**
@@ -523,11 +571,11 @@ TcpChecker implements Checker
| LLM http | `status → headers → output → finishReason → rawFinishReason → usage → durationMs` |
| LLM stream | `status → headers → stream.completed → stream.firstTokenMs → output → finishReason → rawFinishReason → usage → durationMs` |
HTTP checker 的 `durationMs` 覆盖完整执行(含重定向、响应体读取、解码和 expect 校验。status 或 headers 失败时不读取 body有 body 规则时,在读取 body 前可先检查是否已超过 `durationMs` 阈值,避免无意义读取。
HTTP checker 的 `durationMs` 覆盖完整执行(含重定向、按需响应体读取、解码和 expect 校验)。未配置 body expectation、status 失败或 headers 失败时不读取 body有 body expectation 时,在读取 body 前可先检查 `durationMs` 上界 matcher 是否已不可能通过,避免无意义读取。
**expect 字段选择规范**
新增或修改 checker 的 expect 字段时,按以下决策树选择合适的断言模型:
新增或修改 checker 的 expect 字段时,按以下决策树选择合适的断言模型(选定后,各层的具体函数映射参考 [1.7.5 的五层管线表](#175-步骤四实现-checker-类)
```
expect 字段
@@ -544,11 +592,11 @@ expect 字段
│ finishReason、rawFinishReason、usage.*、stream.firstTokenMs
└─ 返回内容 / 半结构化内容 / 不完全确定的值
├─ 内容断言 → ContentRules数组
├─ 内容断言 → ContentExpectations数组
│ HTTP body、Cmd stdout/stderr、TCP banner、
│ UDP response、LLM output、DB result
└─ 键值断言 → KeyValueExpect动态键对象
└─ 键值断言 → KeyedExpectations(动态键对象)
HTTP/LLM headers、DB rows[] 中的列值
```
@@ -558,14 +606,16 @@ expect 字段
2. **单值数字指标和字符串元数据使用 ValueMatcher**。观测值是一个明确的标量耗时、行数、丢包率、finish reason但阈值不确定时使用 `{ lte: 100 }``{ regex: "^(stop|end)$" }` 等 matcher 表达;精确匹配 primitive 可直接写 `100``"stop"`
3. **返回内容使用 ContentRules 数组**。观测值是文本、JSON、HTML 或 XML 内容,且可能需要多步提取或多条规则时,使用 ContentRules。即使只有一条规则也必须写成数组形式`[{ contains: "ok" }]`),不支持对象快捷写法。
3. **返回内容使用 ContentExpectations 数组**。观测值是文本、JSON、HTML 或 XML 内容,且可能需要多步提取或多条规则时,使用 ContentExpectations。即使只有一条规则也必须写成数组形式`[{ contains: "ok" }]`),不支持对象快捷写法。
4. **键值对使用 KeyValueExpect**。观测值是动态键值表(如 headers且需要对每个键独立断言时使用。字面量值自动等价于 `{ equals: value }`
4. **键值对使用 KeyedExpectations**。观测值是动态键值表(如 headers且需要对每个键独立断言时使用。字面量值自动等价于 `{ equals: value }`
5. **不要混用模型**。一个 expect 字段只能对应一种断言模型。例如 `finishReason` 是单值字符串元数据,用 ValueMatcher 而非 ContentRulesContentRules 的 json/css/xpath 提取器对单字符串无意义,且会增加数组包装的配置冗余)。
5. **不要混用模型**。一个 expect 字段只能对应一种断言模型。例如 `finishReason` 是单值字符串元数据,用 ValueMatcher 而非 ContentExpectationsContentExpectations 的 json/css/xpath 提取器对单字符串无意义,且会增加数组包装的配置冗余)。
6. **failure phase 命名遵循去单位后缀规则**。数字指标字段的 phase 去掉单位后缀(`durationMs``duration``packetLossPercent``packetLoss``avgLatencyMs``avgLatency`),不带单位后缀的字段直接使用字段名(`rowCount``rowCount``finishReason``finishReason`)。
7. **实现时参考 [1.7.5 五层管线](#175-步骤四实现-checker-类) 中的对应表**。决策树解决"选哪种模型",五层管线表解决"每种模型从类型定义到执行分别调哪个函数"。
### 1.11 错误模式
- **API 错误**`{ error: "描述", status: <code> }`,状态码 400/404/503
@@ -575,7 +625,7 @@ expect 字段
### 1.12 测试规范
- 测试目录 `tests/` 镜像 `src/` 目录结构,但共享 expect 模块的测试集中放在 `tests/server/checker/runner/shared/` 下,覆盖 `failure.ts``matcher.ts``content.ts``key-value.ts``validate-matcher.ts` `redos.ts`
- 测试目录 `tests/` 镜像 `src/` 目录结构,但共享 expect 模块的测试集中放在 `tests/server/checker/runner/shared/` 下,覆盖 `failure.ts``value.ts`operator`content.ts`body/text`keyed.ts`headers/duplicate-key`validate.ts`shorthand`redos.ts`
- 使用 `bun:test` 框架(`describe`/`test`/`expect`),测试数据库用临时目录 + `tmpdir()`
- 新增 store 方法必须编写单元测试;新增 API 端点必须在 `app.test.ts` 中添加集成测试
- 测试后清理:`afterAll``store.close()` + `rm(tempDir, { recursive: true })`

View File

@@ -16,7 +16,7 @@ DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**
- 多种拨测类型HTTPGET/POST/PUT 等、Cmd命令行执行、DBPostgreSQL/MySQL/SQLite、TCP端口可达性 + Banner 探测、UDP自定义 payload 请求-响应、ICMP存活检测、延迟、丢包率、LLM大模型服务应用层健康检查
- 丰富的校验规则状态码、响应头、JSONPath、CSS 选择器、XPath、正则匹配、数值比较等
- 结构化观测数据:检查结果保留 HTTP body 预览、TCP/UDP 响应摘要、ICMP 丢包率、CMD 输出预览、LLM token 用量等 observation便于排障和后续分析
- 结构化观测数据:检查结果保留按需读取的 HTTP body 预览、TCP/UDP 响应摘要、ICMP 丢包率、CMD 输出预览、LLM token 用量等 observation便于排障和后续分析
- 响应式 Dashboard实时状态、可用率统计、耗时趋势图、手动/自动刷新
- 多主题支持:系统、明亮、黑暗三种主题模式
- 零外部依赖:数据存储使用 SQLite无需额外数据库服务
@@ -185,8 +185,8 @@ targets: # 拨测目标列表(必填)
| 字段 | 说明 | 必填 | 默认值 |
| ------------ | -------------------------------------------------- | ---- | ------- |
| `status` | 可接受的状态码列表,支持精确码和范围(如 `"2xx"` | 否 | `[200]` |
| `headers` | 响应头校验,使用动态键名和 `KeyValueExpect` | 否 | |
| `body` | 响应体校验,使用 `ContentRules` 数组 | 否 | |
| `headers` | 响应头校验,使用动态键名和 `KeyedExpectations` | 否 | |
| `body` | 响应体校验,使用 `ContentExpectations` 数组 | 否 | |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | |
**配置示例**
@@ -235,10 +235,10 @@ targets: # 拨测目标列表(必填)
**expect 校验项**
| 字段 | 说明 | 必填 | 默认值 |
| ------------ | -------------------------------------- | ---- | ------ |
| ------------ | --------------------------------------------- | ---- | ------ |
| `exitCode` | 可接受的退出码列表 | 否 | `[0]` |
| `stdout` | 标准输出校验,使用 `ContentRules` 数组 | 否 | |
| `stderr` | 标准错误校验,使用 `ContentRules` 数组 | 否 | |
| `stdout` | 标准输出校验,使用 `ContentExpectations` 数组 | 否 | |
| `stderr` | 标准错误校验,使用 `ContentExpectations` 数组 | 否 | |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | |
**配置示例**
@@ -270,10 +270,10 @@ targets: # 拨测目标列表(必填)
**expect 校验项**
| 字段 | 说明 | 必填 | 默认值 |
| ------------ | ---------------------------------------------------------------- | ---- | ------ |
| ------------ | ----------------------------------------------------------------------- | ---- | ------ |
| `rowCount` | 查询返回行数校验,使用 `ValueMatcher` | 否 | |
| `rows` | 查询结果逐行校验,数组内每行为列名到 `KeyValueExpect` 的映射 | 否 | |
| `result` | 完整查询结果 `{ rows, rowCount }` 校验,使用 `ContentRules` 数组 | 否 | |
| `rows` | 查询结果逐行校验,数组内每行为列名到 `KeyedExpectations` 的映射 | 否 | |
| `result` | 完整查询结果 `{ rows, rowCount }` 校验,使用 `ContentExpectations` 数组 | 否 | |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | |
**配置示例**
@@ -310,9 +310,9 @@ targets: # 拨测目标列表(必填)
**expect 校验项**
| 字段 | 说明 | 必填 | 默认值 |
| ------------ | ------------------------------------------------------------------ | ---- | ------ |
| ------------ | ------------------------------------------------------------------------- | ---- | ------ |
| `connected` | 期望连接结果,`true` 可达或 `false` 期望不可达 | 否 | `true` |
| `banner` | Banner 内容校验,使用 `ContentRules` 数组,需开启 `tcp.readBanner` | 否 | |
| `banner` | Banner 内容校验,使用 `ContentExpectations` 数组,需开启 `tcp.readBanner` | 否 | |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | |
**配置示例**
@@ -355,9 +355,9 @@ targets: # 拨测目标列表(必填)
**expect 校验项**
| 字段 | 说明 | 必填 | 默认值 |
| -------------- | -------------------------------------- | ---- | ------ |
| -------------- | --------------------------------------------- | ---- | ------ |
| `responded` | 期望是否收到响应 | 否 | `true` |
| `response` | 响应内容校验,使用 `ContentRules` 数组 | 否 | |
| `response` | 响应内容校验,使用 `ContentExpectations` 数组 | 否 | |
| `responseSize` | 响应字节数校验,使用 `ValueMatcher` | 否 | |
| `sourceHost` | 响应来源地址校验,使用 `ValueMatcher` | 否 | |
| `sourcePort` | 响应来源端口校验,使用 `ValueMatcher` | 否 | |
@@ -466,8 +466,8 @@ ICMP checker 通过系统 `ping` 命令执行 ICMP 探测,支持 Linux、macOS
| 字段 | 说明 | 必填 | 默认值 |
| ----------------- | ---------------------------------------------------------------------- | ---- | ------- |
| `status` | 可接受的状态码列表,支持精确码和范围(如 `"2xx"` | 否 | `[200]` |
| `headers` | 响应头校验,使用动态键名和 `KeyValueExpect` | 否 | |
| `output` | 模型输出校验,使用 `ContentRules` 数组 | 否 | |
| `headers` | 响应头校验,使用动态键名和 `KeyedExpectations` | 否 | |
| `output` | 模型输出校验,使用 `ContentExpectations` 数组 | 否 | |
| `finishReason` | finish reason 校验,使用 `ValueMatcher` | 否 | |
| `rawFinishReason` | 原始 finish reason 校验,使用 `ValueMatcher` | 否 | |
| `usage` | Token usage 校验(`inputTokens`/`outputTokens`/`totalTokens` matcher | 否 | |
@@ -497,7 +497,7 @@ ICMP checker 通过系统 `ping` 命令执行 ICMP 探测,支持 Linux、macOS
### 通用校验规则
#### ContentRules 校验项
#### ContentExpectations 校验项
`body``stdout``stderr``banner``response``output``result` 均使用数组:
@@ -511,7 +511,7 @@ ICMP checker 通过系统 `ping` 命令执行 ICMP 探测,支持 Linux、macOS
`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: ... }`
#### KeyValueExpect
#### KeyedExpectations
`headers``rows` 中每行使用的校验结构,支持 ValueMatcher 的全部字段。

View File

@@ -47,30 +47,30 @@
- **THEN** 该 checker 专属的断言函数 SHALL 定义在 `expect.ts`
### Requirement: 断言基础设施目录
系统 SHALL 在 `src/server/checker/expect/` 目录中提供所有 checker 共享的断言基础设施。
系统 SHALL 在 `src/server/checker/expect/` 目录中提供所有 checker 共享的断言基础设施。共享 expect 目录 SHALL 使用 Raw/Resolved expectation 术语和 value/content/keyed/status/headers 模块边界。
#### Scenario: expect 共享类型位置
- **WHEN** 任何 checker 需要使用断言相关的共享类型(如 `ExpectResult`
- **WHEN** 任何 checker 需要使用断言相关的共享类型(如 `ExpectationResult``ValueExpectation``ContentExpectations``KeyedExpectations`
- **THEN** 这些类型 SHALL 从 `src/server/checker/expect/types.ts` 导入
#### Scenario: operator 断言引擎位置
- **WHEN** 任何 checker 需要使用 `applyOperator``evaluateJsonPath``checkExpectValue`
- **THEN** 这些函数 SHALL 从 `src/server/checker/expect/operator.ts` 导入
#### Scenario: value 断言引擎位置
- **WHEN** 任何 checker 需要使用 `applyValueMatcher``evaluateJsonPath``resolveValueExpectation``checkValueExpectation`
- **THEN** 这些函数 SHALL 从 `src/server/checker/expect/value.ts` 导入
#### Scenario: duration 断言位置
- **WHEN** 任何 checker 需要使用 `checkDuration`
- **THEN** 该函数 SHALL 从 `src/server/checker/expect/duration.ts` 导入
#### Scenario: content 和 keyed 断言位置
- **WHEN** 任何 checker 需要执行内容数组或键值表 expectation
- **THEN** SHALL 分别`src/server/checker/expect/content.ts``src/server/checker/expect/keyed.ts` 导入共享函数
#### Scenario: failure 构造器位置
- **WHEN** 任何 checker 需要使用 `errorFailure``mismatchFailure`
- **THEN** 这些函数 SHALL 从 `src/server/checker/expect/failure.ts` 导入
#### Scenario: operator 校验位置
- **WHEN** 任何 checker 的 validate 需要使用 `validateOperatorObject`
- **THEN** 函数 SHALL 从 `src/server/checker/expect/validate-operator.ts` 导入
#### Scenario: expectation 校验位置
- **WHEN** 任何 checker 的 validate 需要校验 Raw value、Raw content 或 Raw keyed expectation
- **THEN** 对应函数 SHALL 从 `src/server/checker/expect/validate.ts` 导入
#### Scenario: ExpectResult 类型位置
- **WHEN** 任何 checker 需要使用 `ExpectResult` 类型
#### Scenario: ExpectationResult 类型位置
- **WHEN** 任何 checker 需要使用 `ExpectationResult` 类型
- **THEN** 该类型 SHALL 从 `src/server/checker/expect/types.ts` 导入
### Requirement: Schema 目录结构
@@ -130,15 +130,15 @@ checker 系统内的模块依赖 SHALL 遵循严格的分层方向。
- **THEN** 开发者 SHALL 在 `runner/index.ts` 中添加一行 import 和一行数组项,无需修改其他文件
### Requirement: 公共类型文件瘦身
顶层 `src/server/checker/types.ts` SHALL 仅保留跨 checker 共享的 base 类型和存储相关类型。
顶层 `src/server/checker/types.ts` SHALL 仅保留跨 checker 共享的 base 类型和存储相关类型expect 专属类型 SHALL 放在 `src/server/checker/expect/types.ts`
#### Scenario: types.ts 不包含 checker 专属类型
#### Scenario: types.ts 不包含 checker 或 expect 专属类型
- **WHEN** 开发者查看顶层 `types.ts`
- **THEN** 该文件 SHALL NOT 包含 `HttpTargetConfig``ResolvedHttpTarget``CommandExpectConfig``BodyRule``TextRule` 等 checker 专属类型
- **THEN** 该文件 SHALL NOT 包含 `HttpTargetConfig``ResolvedHttpTarget``RawCommandExpectConfig``ContentExpectation` 等 checker 专属或 expect 专属类型
#### Scenario: types.ts 保留 base 类型
- **WHEN** 开发者查看顶层 `types.ts`
- **THEN** 该文件 SHALL 包含 `ResolvedTargetBase``RawTargetConfig``DefaultsConfig``CheckResult``ExpectOperator``CheckFailure``StoredTarget``StoredCheckResult``JsonValue` 等公共类型
- **THEN** 该文件 SHALL 包含 `ResolvedTargetBase``RawTargetConfig``DefaultsConfig``CheckResult``CheckFailure``StoredTarget``StoredCheckResult``JsonValue` 等公共类型
#### Scenario: ResolvedTargetBase 替代联合类型
- **WHEN** engine、store、config-loader 需要引用 resolved target 类型

View File

@@ -146,38 +146,57 @@
- **THEN** HttpChecker 的语义校验 SHALL 抛出校验错误,提示 URL 格式不合法
### Requirement: 存储序列化通过 registry 获取展示格式
系统 SHALL 在 `ProbeStore.syncTargets()` 中通过 `checkerRegistry.get(t.type).serialize(t)` 获取每个 target 的展示摘要(`target` 列)和配置 JSON`config` 列),替代 `buildTargetDisplay()` / `buildTargetConfig()` 中的类型分支。
系统 SHALL 在 `ProbeStore.syncTargets()` 中通过 `checkerRegistry.get(t.type).serialize(t)` 获取每个 target 的展示摘要(`target` 列)和配置 JSON`config` 列),替代 `buildTargetDisplay()` / `buildTargetConfig()` 中的类型分支。系统 SHALL 将 `targets.expect` 持久化为 resolved target 上的 Raw expect 快照,而不是运行期 Resolved expect 执行计划。
#### Scenario: 序列化委托 checker
- **WHEN** store 同步 targets 表
- **THEN** store SHALL 对每个 target 调用对应 checker 的 `serialize()` 方法获取 `{ target, config }`
#### Scenario: expect 持久化使用 rawExpect
- **WHEN** store 同步带 expect 的 target 到 targets 表
- **THEN** store SHALL 将 `rawExpect` 序列化写入 `targets.expect`MUST NOT 将包含 `kind` 的 Resolved content expectation 写入该列
### Requirement: Checker resolve 生成 Raw 与 Resolved expect
每个 checker 的 `resolve()` SHALL 在解析 checker 专属 target 配置时,同时保留变量替换后的 Raw expect 快照并生成运行期 Resolved expect 执行计划。Raw expect SHALL 用于配置快照持久化Resolved expect SHALL 用于 checker `execute()``config-loader` SHALL 继续通过 registry 委托 checker resolveMUST NOT 在中间层理解 checker 专属 expect 字段。
#### Scenario: resolve 输出双 expect 模型
- **WHEN** config-loader 解析一个带 `expect.durationMs: 1000` 的 target
- **THEN** 对应 checker 的 resolved target SHALL 包含 Raw expect 中的 `durationMs: 1000`,并在 Resolved expect 中包含 `{equals: 1000}` 形式的运行期 matcher
#### Scenario: 中间层不感知 checker expect 字段
- **WHEN** 新增 checker 定义自己的 Raw/Resolved expect 字段
- **THEN** config-loader SHALL 只调用该 checker 的 `validate()``resolve()`,不新增 checker 类型分支
### Requirement: 共享 expect 断言函数
系统 SHALL 在 `src/server/checker/expect/` 中提供可被多个 checker 复用的 expect 基础设施。共享基础设施 SHALL 包含 matcher、content rules、key-value expect、failure 构造和 ReDoS 校验。checker 专用的状态类断言 SHALL 保留在对应 checker 目录,或在多个 checker 复用时移动到共享模块。仅被单个 checker 使用且不属于通用 matcher/content/key-value 模型的断言模块 SHALL 位于该 checker 目录内。
系统 SHALL 在 `src/server/checker/expect/` 中提供可被多个 checker 复用的 expect 基础设施。共享基础设施 SHALL 包含 value expectation、content expectations、keyed expectations、status code 断言、headers keyed 断言、failure 构造和 ReDoS 校验。checker 专用的状态类断言 SHALL 保留在对应 checker 目录,或在多个 checker 复用时移动到共享模块。仅被单个 checker 使用且不属于通用 value/content/keyed/status/header 模型的断言模块 SHALL 位于该 checker 目录内。
#### Scenario: 共享 ValueMatcher 断言
#### Scenario: 共享 ValueExpectation 断言
- **WHEN** 任何 checker 需要对数字、字符串、布尔或 JSON value 执行 matcher 匹配
- **THEN** SHALL 调用共享 matcher 工具执行 `equals``contains``regex``exists``empty``gt``gte``lt``lte` 语义
- **THEN** SHALL 调用共享 value expectation 工具执行 `equals``contains``regex``exists``empty``gt``gte``lt``lte` 语义
#### Scenario: 共享 ContentRules 断言
#### Scenario: 共享 ContentExpectations 断言
- **WHEN** HTTP body、LLM output、Cmd stdout/stderr、UDP response 或 TCP banner 需要执行返回内容校验
- **THEN** SHALL 调用共享 content rules 工具,而不是在 checker 目录内复制 contains/regex/json/css/xpath 逻辑
- **THEN** SHALL 调用共享 content expectations 工具,而不是在 checker 目录内复制 contains/regex/json/css/xpath 逻辑
#### Scenario: 共享 KeyValueExpect 断言
#### Scenario: 共享 KeyedExpectations 断言
- **WHEN** HTTP 或 LLM checker 需要校验响应 headers或 DB checker 需要校验 rows 中的列值
- **THEN** SHALL 调用共享 key-value expect 工具,并按调用方规则决定 key 是否大小写敏感
- **THEN** SHALL 调用共享 keyed expectations 工具,并按调用方规则决定 key 是否大小写敏感
#### Scenario: 共享 headers 断言
- **WHEN** HTTP 或 LLM checker 需要校验响应 headers
- **THEN** SHALL 调用共享 header expectation 包装函数,确保 header key 大小写不敏感
#### Scenario: 共享 regex ReDoS 校验
- **WHEN** 任一 matcher 或 content rule 配置 `regex`
- **WHEN** 任一 matcher 或 content expectation 配置 `regex`
- **THEN** SHALL 调用共享 ReDoS 校验工具在启动期拒绝危险正则
#### Scenario: 共享 failure 构造
- **WHEN** 任何 checker 需要构造 CheckFailure 对象
- **THEN** SHALL 调用 `expect/failure.ts` 中的 `errorFailure()``mismatchFailure()`,并保留 actual 截断策略
#### Scenario: HTTP 专用 status 断言
#### Scenario: 共享 status 断言
- **WHEN** HTTP 或 LLM checker 需要校验响应状态码
- **THEN** SHALL 复用同一 status 断言函数,支持精确状态码和 `1xx``5xx` 范围模式
- **THEN** SHALL 复用共享 status code 断言函数,支持精确状态码和 `1xx``5xx` 范围模式
### Requirement: 超时控制由引擎注入 signal
Checker 实现的 `execute()` MUST 使用 `ctx.signal` 感知超时,不得自行创建 `AbortController``setTimeout` 用于超时控制。Cmd checker 和 ping checker 可在 signal abort 时 `proc.kill()` 以确保子进程被终止。

View File

@@ -55,11 +55,11 @@
- **THEN** 系统 MUST 停止收集输出并终止该检查,记录 `matched=false`,并在 failure 中写入输出超限信息observation SHALL 包含已截断输出预览和 error
### Requirement: cmd expect 校验
系统 SHALL 支持 cmd 专用 expect包括 `exitCode``durationMs``stdout``stderr`,并按 exitCode、durationMs、stdout、stderr 的阶段顺序快速失败。`exitCode` SHALL 保持有限整数数组语义,未配置时默认 `[0]``durationMs` SHALL 使用共享 `ValueMatcher` 校验完整命令执行耗时。`stdout``stderr` MUST 使用共享 `ContentRules` 数组,直接 matcher 作用于对应输出文本,`json` extractor SHALL 支持对 JSON CLI 输出执行 JSONPath 断言。
系统 SHALL 支持 cmd 专用 expect包括 `exitCode``durationMs``stdout``stderr`,并按 exitCode、durationMs、stdout、stderr 的阶段顺序快速失败。`exitCode` SHALL 保持有限整数数组语义,未配置时在 Resolved expect 中默认 `[0]``durationMs` SHALL 使用共享 `RawValueExpectation` 输入并在运行期使用 `ValueExpectation` 校验完整命令执行耗时。`stdout``stderr` MUST 使用共享 `RawContentExpectations` 数组输入并在运行期使用 `ContentExpectations`,直接 matcher 作用于对应输出文本,`json` extractor SHALL 支持对 JSON CLI 输出执行 JSONPath 断言。
#### Scenario: 默认 exitCode 成功语义
- **WHEN** cmd target 未显式配置 `expect.exitCode`
- **THEN** 系统 SHALL 使用默认 `expect.exitCode: [0]` 进行校验
- **THEN** 系统 SHALL 在 Resolved cmd expect 中使用默认 `exitCode: [0]` 进行校验
#### Scenario: 显式 exitCode 校验
- **WHEN** cmd target 配置 `expect.exitCode: [0, 2]` 且实际 exit code 为 2
@@ -74,8 +74,8 @@
- **THEN** 系统 SHALL 返回 `matched=false`failure 的 phase 为 `duration`
#### Scenario: stdout 按配置顺序校验
- **WHEN** cmd target 配置 `expect.stdout` 为两个 ContentRules第一条通过且第二条失败
- **THEN** 系统 SHALL 先执行第一条 stdout 规则,再执行第二条,并将 failure.path 指向失败的 `stdout[1]`
- **WHEN** cmd target 配置 `expect.stdout` 为两个 ContentExpectations第一条通过且第二条失败
- **THEN** 系统 SHALL 先执行第一条 stdout expectation,再执行第二条,并将 failure.path 指向失败的 `stdout[1]`
#### Scenario: stderr 校验为空
- **WHEN** cmd target 配置 `expect.stderr: [{empty: true}]` 且实际 stderr 为空字符串
@@ -86,11 +86,11 @@
- **THEN** 系统 SHALL 判定 stdout 阶段通过
#### Scenario: stdout 失败后不检查 stderr
- **WHEN** cmd target 同时配置 stdout 和 stderr 规则,且 stdout 规则失败
- **THEN** 系统 SHALL 快速失败并 MUST NOT 继续执行 stderr 规则
- **WHEN** cmd target 同时配置 stdout 和 stderr expectation且 stdout expectation 失败
- **THEN** 系统 SHALL 快速失败并 MUST NOT 继续执行 stderr expectation
### Requirement: cmd checker 启动期配置校验
系统 SHALL 在启动期对 cmd checker 的配置契约和语义执行严格校验。Cmd target 的 `cmd` 分组 SHALL 只允许 `exec``args``cwd``env``maxOutputBytes` 字段。Cmd expect SHALL 只允许 `exitCode``durationMs``stdout``stderr` 字段。未知字段、非法类型、不可编译正则和 ReDoS 风险正则 MUST 导致启动期配置错误。`expect.exitCode` SHALL 保留原有有限整数数组语义,不限制到特定平台范围。
系统 SHALL 在启动期对 cmd checker 的配置契约和语义执行严格校验。Cmd target 的 `cmd` 分组 SHALL 只允许 `exec``args``cwd``env``maxOutputBytes` 字段。Cmd expect SHALL 只允许 `exitCode``durationMs``stdout``stderr` 字段。未知字段、非法类型、不可编译正则和 ReDoS 风险正则 MUST 导致启动期配置错误。`expect.exitCode` SHALL 保留原有有限整数数组语义,不限制到特定平台范围。语义校验 MUST NOT 修改 Raw cmd expect 输入。
#### Scenario: cmd args 类型非法
- **WHEN** YAML 中 cmd target 配置 `cmd.args` 不是字符串数组
@@ -105,24 +105,24 @@
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.exitCode 必须为整数数组
#### Scenario: cmd expect durationMs 非法
- **WHEN** YAML 中 cmd target 配置 `expect.durationMs` 不是合法 `ValueMatcher`
- **WHEN** YAML 中 cmd target 配置 `expect.durationMs` 不是合法 `RawValueExpectation`
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.durationMs 格式错误
#### Scenario: stdout 必须为 ContentRules 数组
#### Scenario: stdout 必须为 ContentExpectations 数组
- **WHEN** YAML 中 cmd target 配置 `expect.stdout` 但其值不是数组
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.stdout 必须为数组
#### Scenario: stderr 必须为 ContentRules 数组
#### Scenario: stderr 必须为 ContentExpectations 数组
- **WHEN** YAML 中 cmd target 配置 `expect.stderr` 但其值不是数组
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.stderr 必须为数组
#### Scenario: stdout text rule 空对象非法
#### Scenario: stdout text expectation 空对象非法
- **WHEN** YAML 中 cmd target 配置 `expect.stdout: [{}]`
- **THEN** 系统 SHALL 以配置错误退出,提示 stdout 规则必须包含至少一个合法 matcher 或 extractor
- **THEN** 系统 SHALL 以配置错误退出,提示 stdout expectation 必须包含至少一个合法 matcher 或 extractor
#### Scenario: stderr text rule 未知字段非法
#### Scenario: stderr text expectation 未知字段非法
- **WHEN** YAML 中 cmd target 配置 `expect.stderr: [{foo: "bar"}]`
- **THEN** 系统 SHALL 以配置错误退出,提示 stderr 规则包含未知 matcher 或未知 extractor
- **THEN** 系统 SHALL 以配置错误退出,提示 stderr expectation 包含未知 matcher 或未知 extractor
#### Scenario: stdout regex 正则非法
- **WHEN** YAML 中 cmd target 配置 `expect.stdout: [{regex: "[invalid"}]`

View File

@@ -75,7 +75,7 @@
- **THEN** 系统 SHALL 立即关闭数据库连接
### Requirement: db expect 校验
系统 SHALL 支持 db 专用 expect包括 `durationMs``rowCount``rows``result`,按 durationMs、rowCount、rows、result 的阶段顺序快速失败。`durationMs``rowCount` SHALL 使用共享 `ValueMatcher``rows` SHALL 保留按行索引匹配列值的语义,类型为 `Array<KeyValueExpect>`(外层数组按行索引,内层每个元素为一个 `KeyValueExpect` 表达该行的列值断言),每个行规则中列值字面量等价于 `{equals: <literal>}``result` MUST 使用共享 `ContentRules` 数组,对查询结果对象 `{ rows, rowCount }` 执行断言。
系统 SHALL 支持 db 专用 expect包括 `durationMs``rowCount``rows``result`,按 durationMs、rowCount、rows、result 的阶段顺序快速失败。`durationMs``rowCount` SHALL 使用共享 `RawValueExpectation` 输入,并在 resolve 阶段转换为运行期 `ValueExpectation``rows` SHALL 保留按行索引匹配列值的语义,Raw 类型为 `Array<RawKeyedExpectations>`外层数组按行索引内层每个元素表达该行的列值断言Resolved 类型为 `Array<KeyedExpectations>`。每个行规则中列值 primitive 字面量等价于 `{equals: <literal>}``result` MUST 使用共享 `RawContentExpectations` 数组输入,并在运行期以 `ContentExpectations` 对查询结果对象 `{ rows, rowCount }` 执行断言。
#### Scenario: durationMs 校验
- **WHEN** db target 配置 `expect.durationMs: {lte: 3000}` 且实际执行耗时 4000ms
@@ -95,7 +95,7 @@
#### Scenario: rows 按索引匹配列值字面量形式
- **WHEN** db target 配置 `expect.rows: [{ status: "active" }]` 且查询首行 status 列值为 `"active"`
- **THEN** 系统 SHALL 判定该行该列通过(字面量等价于 `{ equals: "active" }`
- **THEN** 系统 SHALL 在 resolve 阶段将该列值解析为 `{equals: "active"}` 并判定该行该列通过
#### Scenario: rows 只检查声明的列
- **WHEN** db target 配置 `expect.rows: [{ cnt: { gte: 1 } }]` 且查询首行包含 cnt、name、age 三列
@@ -122,14 +122,14 @@
- **THEN** 系统 SHALL 按 durationMs → rowCount → rows → result 顺序校验,任一阶段失败立即返回
### Requirement: db checker 启动期配置校验
系统 SHALL 在启动期对 db checker 的配置契约和语义执行严格校验。Db target 的 `db` 分组 SHALL 只允许 `url``query` 字段。Db expect SHALL 只允许 `durationMs``rowCount``rows``result` 字段。未知字段、非法 matcher、非法 ContentRules、非法 regex 和 ReDoS 风险正则 MUST 导致启动期配置错误。
系统 SHALL 在启动期对 db checker 的配置契约和语义执行严格校验。Db target 的 `db` 分组 SHALL 只允许 `url``query` 字段。Db expect SHALL 只允许 `durationMs``rowCount``rows``result` 字段。未知字段、非法 ValueExpectation、非法 ContentExpectations、非法 regex 和 ReDoS 风险正则 MUST 导致启动期配置错误。语义校验 MUST NOT 修改 Raw db expect 输入。
#### Scenario: db expect durationMs 非法
- **WHEN** YAML 中 db target 配置 `expect.durationMs` 不是合法 `ValueMatcher`
- **WHEN** YAML 中 db target 配置 `expect.durationMs` 不是合法 `RawValueExpectation`
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.durationMs 格式错误
#### Scenario: db expect rowCount 非法
- **WHEN** YAML 中 db target 配置 `expect.rowCount` 不是合法 `ValueMatcher`
- **WHEN** YAML 中 db target 配置 `expect.rowCount` 不是合法 `RawValueExpectation`
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.rowCount 格式错误
#### Scenario: db expect rows 非法
@@ -140,8 +140,12 @@
- **WHEN** YAML 中 db target 配置 `expect.rows: [{ cnt: { foo: 1 } }]`,其中 foo 不是合法 matcher
- **THEN** 系统 SHALL 以配置错误退出,提示 rows 中包含未知 matcher
#### Scenario: db expect rows 对象值必须显式 equals
- **WHEN** YAML 中 db target 配置 `expect.rows: [{ payload: { status: "ok" } }]` 且 payload 值不是合法 matcher 对象
- **THEN** 系统 SHALL 以配置错误退出,提示对象值必须显式写成 `{equals: {status: "ok"}}`
#### Scenario: db expect result 非法
- **WHEN** YAML 中 db target 配置 `expect.result` 不是合法 ContentRules 数组
- **WHEN** YAML 中 db target 配置 `expect.result` 不是合法 ContentExpectations 数组
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.result 格式错误
#### Scenario: db expect 未知字段失败

View File

@@ -5,54 +5,54 @@
## Requirements
### Requirement: 响应体多种校验方法
系统 SHALL 支持通过共享 `ContentRules` 对 HTTP 响应体进行有序内容校验。`expect.body` MUST 为规则数组。每个规则 SHALL 为直接 `ValueMatcher`,或 `json``css``xpath` extractor 规则之一。直接 matcher SHALL 作用于完整响应体文本。`json` SHALL 解析响应体为 JSON 并用 JSONPath 子集提取值。`css` SHALL 使用 CSS selector 从 HTML 中提取元素文本或属性。`xpath` SHALL 使用 XPath 从 XML/HTML 中提取节点值。Extractor 未配置 matcher 时 SHALL 等价于 `exists: true`
系统 SHALL 支持通过共享 `ContentExpectations` 对 HTTP 响应体进行有序内容校验。`expect.body` MUST 为 expectation 数组。每个 expectation SHALL 为直接 `ValueMatcher`,或 `json``css``xpath` extractor expectation 之一。直接 matcher SHALL 作用于完整响应体文本。`json` SHALL 解析响应体为 JSON 并用 JSONPath 子集提取值。`css` SHALL 使用 CSS selector 从 HTML 中提取元素文本或属性。`xpath` SHALL 使用 XPath 从 XML/HTML 中提取节点值。Extractor 未配置 matcher 时resolve 阶段 SHALL 将其 matcher 物化为 `{exists: true}`
#### Scenario: contains 子串匹配
- **WHEN** HTTP target 配置 `expect.body: [{contains: "healthy"}]`,且响应体包含 `"healthy"`
- **THEN** 系统 SHALL 判定该 body 规则通过
- **THEN** 系统 SHALL 判定该 body expectation 通过
#### Scenario: regex 正则匹配
- **WHEN** HTTP target 配置 `expect.body: [{regex: '"status"\\s*:\\s*"ok"'}]`,且响应体匹配该正则
- **THEN** 系统 SHALL 判定该 body 规则通过
- **THEN** 系统 SHALL 判定该 body expectation 通过
#### Scenario: json JSONPath 等值匹配
- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.status", equals: "ok"}}]`,且响应 JSON 中 `$.status` 值为 `"ok"`
- **THEN** 系统 SHALL 判定该 body 规则通过
- **THEN** 系统 SHALL 判定该 body expectation 通过
#### Scenario: json JSONPath 存在性匹配
- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.status"}}]`,且响应 JSON 中存在 `$.status`
- **THEN** 系统 SHALL 将该规则`exists: true` 语义判定通过
- **THEN** resolve 阶段 SHALL 将该 expectation `exists: true` 语义物化并在运行期判定通过
#### Scenario: css 选择器匹配
- **WHEN** HTTP target 配置 `expect.body: [{css: {selector: "div#health", equals: "OK"}}]`,且 HTML 中存在 `div#health` 元素文本为 `"OK"`
- **THEN** 系统 SHALL 判定该 body 规则通过
- **THEN** 系统 SHALL 判定该 body expectation 通过
#### Scenario: css 选择器匹配属性值
- **WHEN** HTTP target 配置 css 规则`attr: "content"` 用于提取属性,且属性值匹配期望
- **THEN** 系统 SHALL 判定该 body 规则通过
- **WHEN** HTTP target 配置 css expectation `attr: "content"` 用于提取属性,且属性值匹配期望
- **THEN** 系统 SHALL 判定该 body expectation 通过
#### Scenario: xpath 表达式匹配
- **WHEN** HTTP target 配置 `expect.body: [{xpath: {path: "/root/status/text()", equals: "ok"}}]`,且 XML 中 `/root/status` 节点文本为 `"ok"`
- **THEN** 系统 SHALL 判定该 body 规则通过
- **THEN** 系统 SHALL 判定该 body expectation 通过
#### Scenario: 提取器无匹配目标失败
- **WHEN** HTTP target 配置了 json、css 或 xpath 规则且对应路径、元素或节点不存在,并且规则未配置 `exists: false`
- **WHEN** HTTP target 配置了 json、css 或 xpath expectation 且对应路径、元素或节点不存在,并且 expectation 未配置 `exists: false`
- **THEN** 系统 SHALL 判定 matched 为 false
### Requirement: 多种 body 校验方法 AND 组合
系统 SHALL 支持在 `expect.body` 数组中同时配置多条内容规则,所有规则均通过时 matched 方为 true。系统 SHALL 按数组顺序执行规则,任一规则失败后 MUST NOT 继续执行后续规则
系统 SHALL 支持在 `expect.body` 数组中同时配置多条内容 expectation所有 expectation 均通过时 matched 方为 true。系统 SHALL 按数组顺序执行 expectation任一 expectation 失败后 MUST NOT 继续执行后续 expectation
#### Scenario: 多种方法全部通过
- **WHEN** HTTP target 的 `expect.body` 数组依次配置 contains、json、regex且全部通过
- **THEN** 系统 SHALL 判定 matched 为 true
#### Scenario: 多种方法任一失败
- **WHEN** HTTP target 的 `expect.body` 数组第一条 contains 不通过,后续还有 json 规则
- **THEN** 系统 SHALL 判定 matched 为 false且不再检查后续 json 规则
- **WHEN** HTTP target 的 `expect.body` 数组第一条 contains 不通过,后续还有 json expectation
- **THEN** 系统 SHALL 判定 matched 为 false且不再检查后续 json expectation
#### Scenario: 直接 matcher 多字段组合
- **WHEN** HTTP target 配置 `expect.body: [{contains: "ok", regex: "status"}]`,且响应体同时满足 contains 和 regex
- **THEN** 系统 SHALL 判定该规则通过
- **THEN** 系统 SHALL 判定该 expectation 通过
### Requirement: 操作符系统
系统 SHALL 支持通过共享 `ValueMatcher` 对提取值和文本值进行比较:`equals`(深度等值)、`contains`(子串包含)、`regex`(正则匹配)、`empty`(空值判断)、`exists`(存在性判断)、`gte`/`lte`/`gt`/`lt`(数值比较)。系统 MUST NOT 支持旧 `match` 字段。
@@ -82,7 +82,7 @@
- **THEN** 系统 SHALL 对同一字段进行多 matcher 复合比较,全部通过则该规则通过
### Requirement: 响应头校验
系统 SHALL 支持通过共享 `KeyValueExpect` 配置 `expect.headers` 对 HTTP 响应头进行键值规则校验header 名称匹配 MUST 不区分大小写。header 期望值 MAY 为字符串字面量或 `ValueMatcher`。字符串字面量 SHALL 等价 `{equals: <value>}`
系统 SHALL 支持通过共享 `KeyedExpectations` 配置 `expect.headers` 对 HTTP 响应头进行键值断言header 名称匹配 MUST 不区分大小写。header 期望值 MUST 为 `RawValueExpectation`primitive 字面量 SHALL 在 resolve 阶段等价 `{equals: <value>}`
#### Scenario: 响应头字面量匹配
- **WHEN** HTTP target 配置 `expect.headers: {"Content-Type": "application/json"}`,且响应包含该 header 且值精确匹配
@@ -155,18 +155,18 @@
- **THEN** 系统 SHALL 在启动期配置校验失败
### Requirement: HTTP expect 规则启动期校验
系统 SHALL 在启动期校验 HTTP expect 中已支持字段的类型、格式、未知字段和可编译表达式。HTTP expect SHALL 只允许 `status``headers``body``durationMs` 字段。`expect.body` MUST 为 `ContentRules` 数组。直接 `ValueMatcher` 对象 MUST 至少包含一个合法 matcher。Extractor 规则 MUST 只包含 `json``css``xpath` 中的一种 extractor。Extractor 内部可以不配置 matcher并 SHALL 在运行期以存在性作为通过语义。`equals` matcher SHALL 支持任意 JSON value包括数组和对象。系统 SHALL 在启动期对所有 `regex` 执行静态 ReDoS 检测。
系统 SHALL 在启动期校验 HTTP expect 中已支持字段的类型、格式、未知字段和可编译表达式。HTTP expect SHALL 只允许 `status``headers``body``durationMs` 字段。`expect.body` MUST 为 `RawContentExpectations` 数组。直接 `ValueMatcher` 对象 MUST 至少包含一个合法 matcher。Extractor expectation MUST 只包含 `json``css``xpath` 中的一种 extractor。Extractor 内部可以不配置 matcher并 SHALL 在 resolve 阶段以存在性 matcher 作为通过语义。`equals` matcher SHALL 支持任意 JSON value包括数组和对象。系统 SHALL 在启动期对所有 `regex` 执行静态 ReDoS 检测。语义校验 MUST NOT 修改 Raw HTTP expect 输入。
#### Scenario: body rule 使用 regex 字段
- **WHEN** HTTP target 配置 `expect.body: [{regex: "ok|healthy"}]` 且 regex 可编译且无 ReDoS 风险
- **THEN** 系统 SHALL 接受该配置,并在运行期按 regex 规则匹配响应体
- **THEN** 系统 SHALL 接受该配置,并在运行期按 regex expectation 匹配响应体
#### Scenario: body rule 不支持 match 字段
- **WHEN** HTTP target 配置 `expect.body: [{match: "ok"}]`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示 `match` 是未知字段或不支持字段
#### Scenario: body rule 多 extractor 非法
- **WHEN** HTTP target 的同一条 body rule 同时配置 `json``css`
- **WHEN** HTTP target 的同一条 body expectation 同时配置 `json``css`
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: matcher regex 正则非法
@@ -182,7 +182,7 @@
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: JSONPath 子集非法
- **WHEN** HTTP target 的 json body rule path 不符合系统支持的 JSONPath 子集
- **WHEN** HTTP target 的 json body expectation path 不符合系统支持的 JSONPath 子集
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: matcher 未知字段非法
@@ -190,20 +190,20 @@
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: durationMs matcher 非法
- **WHEN** HTTP target 配置 `expect.durationMs` 不是合法 `ValueMatcher` 或其中数值 matcher 不是有限数字
- **WHEN** HTTP target 配置 `expect.durationMs` 不是合法 `RawValueExpectation` 或其中数值 matcher 不是有限数字
- **THEN** 系统 SHALL 在启动期配置校验失败
### Requirement: HTTP body 运行期失败结构化
系统 SHALL 将 HTTP body 运行期失败记录为结构化 CheckFailure并保留与具体规则相关的 phase 和 path。响应内容不符合配置 SHALL 记录为 mismatch响应内容无法按配置解析或解码 SHALL 记录为 error。
系统 SHALL 将 HTTP body 运行期失败记录为结构化 CheckFailure并保留与具体 expectation 相关的 phase 和 path。响应内容不符合配置 SHALL 记录为 mismatch响应内容无法按配置解析或解码 SHALL 记录为 error。
#### Scenario: JSON 响应不是合法 JSON
- **WHEN** HTTP target 配置 json body rule,但响应体不是合法 JSON
- **THEN** 系统 SHALL 记录 `failure.kind="error"``failure.phase="body"`,且 failure.path SHALL 指向对应 json 规则
- **WHEN** HTTP target 配置 json body expectation,但响应体不是合法 JSON
- **THEN** 系统 SHALL 记录 `failure.kind="error"``failure.phase="body"`,且 failure.path SHALL 指向对应 json expectation
#### Scenario: CSS selector 无匹配元素
- **WHEN** HTTP target 配置 css body rule,但响应 HTML 中无匹配元素
- **THEN** 系统 SHALL 记录 `failure.kind="mismatch"``failure.phase="body"`,且 failure.path SHALL 指向对应 css 规则
- **WHEN** HTTP target 配置 css body expectation,但响应 HTML 中无匹配元素
- **THEN** 系统 SHALL 记录 `failure.kind="mismatch"``failure.phase="body"`,且 failure.path SHALL 指向对应 css expectation
#### Scenario: XPath 无匹配节点
- **WHEN** HTTP target 配置 xpath body rule,但响应 XML/HTML 中无匹配节点
- **THEN** 系统 SHALL 记录 `failure.kind="mismatch"``failure.phase="body"`,且 failure.path SHALL 指向对应 xpath 规则
- **WHEN** HTTP target 配置 xpath body expectation,但响应 XML/HTML 中无匹配节点
- **THEN** 系统 SHALL 记录 `failure.kind="mismatch"``failure.phase="body"`,且 failure.path SHALL 指向对应 xpath expectation

View File

@@ -1,13 +1,13 @@
## Purpose
定义共享 expect 断言规则系统的核心概念和基础设施ValueMatcher 统一匹配器、ContentRules 内容规则数组、KeyValueExpect 键值规则、以及相关的启动期校验和失败路径规范。
定义共享 expect 断言规则系统的核心概念和基础设施ValueMatcher 统一匹配器、ContentExpectations 内容断言数组、KeyedExpectations 键控断言数组、以及相关的启动期校验和失败路径规范。
## Requirements
### 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` 作为所有非状态类 value expectation 的基础匹配结构。`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 }`
所有类型为 `RawValueExpectation` 的 expect 字段 SHALL 同时接受 primitive 原始值string / number / boolean / null作为简写形式。原始值简写 SHALL 等价于 `{ equals: value }`。系统 SHALL 在 resolve 阶段将 primitive 原始值归一化为 `{ equals: value }` 对象形式,运行期逻辑 SHALL 仅处理 `ValueMatcher` 对象形式。数组和对象 MUST NOT 作为原始值简写;需要对数组或对象执行 equals 匹配时,配置 MUST 显式写成 `{ equals: value }`
#### Scenario: equals 匹配对象
- **WHEN** 实际值为 `{status: "ok", count: 1}` 且 matcher 为 `{equals: {status: "ok", count: 1}}`
@@ -27,26 +27,26 @@
#### Scenario: 字符串原始值简写等价 equals
- **WHEN** expect 字段配置为 `finishReason: "stop"` 且实际值为 `"stop"`
- **THEN** 系统 SHALL 将 `"stop"` 归一化为 `{equals: "stop"}` 并判定通过
- **THEN** 系统 SHALL 在 resolve 阶段`"stop"` 归一化为 `{equals: "stop"}` 并判定通过
#### Scenario: 数字原始值简写等价 equals
- **WHEN** expect 字段配置为 `rowCount: 1` 且实际值为 `1`
- **THEN** 系统 SHALL 将 `1` 归一化为 `{equals: 1}` 并判定通过
- **THEN** 系统 SHALL 在 resolve 阶段`1` 归一化为 `{equals: 1}` 并判定通过
#### Scenario: 布尔原始值简写等价 equals
- **WHEN** expect 字段配置为 ValueMatcher 类型且值为 `true`,实际值为 `true`
- **THEN** 系统 SHALL 将 `true` 归一化为 `{equals: true}` 并判定通过
- **WHEN** expect 字段配置为 RawValueExpectation 类型且值为 `true`,实际值为 `true`
- **THEN** 系统 SHALL 在 resolve 阶段`true` 归一化为 `{equals: true}` 并判定通过
#### Scenario: null 原始值简写等价 equals
- **WHEN** expect 字段配置为 ValueMatcher 类型且值为 `null`,实际值为 `null`
- **THEN** 系统 SHALL 将 `null` 归一化为 `{equals: null}` 并判定通过
- **WHEN** expect 字段配置为 RawValueExpectation 类型且值为 `null`,实际值为 `null`
- **THEN** 系统 SHALL 在 resolve 阶段`null` 归一化为 `{equals: null}` 并判定通过
#### Scenario: 原始值简写不匹配
- **WHEN** expect 字段配置为 `finishReason: "stop"` 且实际值为 `"error"`
- **THEN** 系统 SHALL 判定不通过并生成 mismatch failure
### Requirement: ValueMatcher 启动期校验
系统 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 导致启动期配置错误。
系统 SHALL 在启动期对所有 `RawValueExpectation` 字段执行严格的类型和语义校验。校验 SHALL 同时接受 primitive 原始值和 `ValueMatcher` 对象两种形式,但 MUST NOT 修改输入对象。当输入为 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 对象为空 `{}`
@@ -65,23 +65,23 @@
- **THEN** 系统 SHALL 在启动期配置校验失败,提示该字段必须为布尔值
#### Scenario: 字符串原始值校验通过
- **WHEN** YAML 配置中 ValueMatcher 字段值为字符串 `"stop"`
- **THEN** 系统 SHALL 接受该配置,视为 `{equals: "stop"}`
- **WHEN** YAML 配置中 RawValueExpectation 字段值为字符串 `"stop"`
- **THEN** 系统 SHALL 接受该配置,但 SHALL NOT 在语义校验阶段修改输入对象
#### Scenario: 数字原始值校验通过
- **WHEN** YAML 配置中 ValueMatcher 字段值为数字 `5000`
- **THEN** 系统 SHALL 接受该配置,视为 `{equals: 5000}`
- **WHEN** YAML 配置中 RawValueExpectation 字段值为数字 `5000`
- **THEN** 系统 SHALL 接受该配置,但 SHALL NOT 在语义校验阶段修改输入对象
#### Scenario: null 原始值校验通过
- **WHEN** YAML 配置中 ValueMatcher 字段值为 `null`
- **THEN** 系统 SHALL 接受该配置,视为 `{equals: null}`
- **WHEN** YAML 配置中 RawValueExpectation 字段值为 `null`
- **THEN** 系统 SHALL 接受该配置,但 SHALL NOT 在语义校验阶段修改输入对象
#### Scenario: 数组原始值被拒绝
- **WHEN** YAML 配置中 ValueMatcher 字段值为数组 `[1, 2]`
- **WHEN** YAML 配置中 RawValueExpectation 字段值为数组 `[1, 2]`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示必须为 primitive 原始值或 matcher 对象;如需数组 equals 匹配应写成 `{equals: [1, 2]}`
#### Scenario: 对象原始值必须显式 equals
- **WHEN** YAML 配置中 ValueMatcher 字段值为对象 `{foo: "bar"}`,且 `foo` 不是合法 matcher 字段
- **WHEN** YAML 配置中 RawValueExpectation 字段值为对象 `{foo: "bar"}`,且 `foo` 不是合法 matcher 字段
- **THEN** 系统 SHALL 在启动期配置校验失败,提示 `foo` 是未知 matcher如需对象 equals 匹配应写成 `{equals: {foo: "bar"}}`
### Requirement: empty matcher 语义
@@ -141,92 +141,108 @@
- **WHEN** YAML 配置中任一 `regex``"(a+)+$"`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示正则存在 ReDoS 风险
### Requirement: ContentRules 内容规则数组
系统 SHALL 提供共享 `ContentRules` 表达返回内容断言。`ContentRules` MUST 为有序数组,数组项 SHALL 为直接 `ValueMatcher`,或 `json``css``xpath` 三类 extractor 规则之一。系统 SHALL 按数组顺序执行全部规则,任一规则失败时 SHALL 立即停止并返回该规则的 failure。系统 MUST NOT 支持内容字段的非数组对象快捷写法。
### Requirement: ContentExpectations 内容断言数组
系统 SHALL 提供共享 `ContentExpectations` 表达返回内容断言。`ContentExpectations` MUST 为有序数组,数组项 SHALL 为直接 `ValueMatcher`,或 `json``css``xpath` 三类 extractor expectation 之一。系统 SHALL 在 resolve 阶段将 Raw content DSL 解析为带 `kind` 字段的 Resolved `ContentExpectation` 执行计划,并按数组顺序执行全部 expectation任一 expectation 失败时 SHALL 立即停止并返回该 expectation 的 failure。系统 MUST NOT 支持内容字段的非数组对象快捷写法。
#### Scenario: 直接 matcher 内容规则
- **WHEN** 内容字段配置 `[{contains: "ready"}, {regex: "listening on \\d+"}]` 且原始内容同时满足两条规则
#### Scenario: 直接 matcher 内容 expectation
- **WHEN** 内容字段配置 `[{contains: "ready"}, {regex: "listening on \\d+"}]` 且原始内容同时满足两条 expectation
- **THEN** 系统 SHALL 判定该内容字段通过
#### Scenario: 内容规则数组快速失败
- **WHEN** 内容字段配置三条规则且第二条规则失败
- **THEN** 系统 SHALL 返回第二条规则的 failure并 MUST NOT 执行第三条规则
#### Scenario: 内容 expectation 数组快速失败
- **WHEN** 内容字段配置三条 expectation 且第二条 expectation 失败
- **THEN** 系统 SHALL 返回第二条 expectation 的 failure并 MUST NOT 执行第三条 expectation
#### Scenario: 内容字段必须为数组
- **WHEN** YAML 中内容字段配置为 `{contains: "ok"}` 而不是数组
- **THEN** 系统 SHALL 在启动期配置校验失败,提示该内容字段必须为规则数组
- **THEN** 系统 SHALL 在启动期配置校验失败,提示该内容字段必须为 expectation 数组
### Requirement: ContentRule 互斥性约束
一条 `ContentRule` MUST 为直接 `ValueMatcher` 或恰好一个 extractor`json``css``xpath` 之一)。系统 MUST NOT 允许同一条规则同时包含多个 extractor。直接 `ValueMatcher` 规则 MUST NOT 包含 `json``css``xpath` 字段。系统 SHALL 在启动期对违反互斥性的规则报错。
#### Scenario: Resolved content expectation 使用 kind
- **WHEN** Raw 内容字段包含直接 matcher、json extractor、css extractor 和 xpath extractor
- **THEN** resolve 阶段 SHALL 分别生成 `kind="value"``kind="json"``kind="css"``kind="xpath"` 的 Resolved `ContentExpectation`
### Requirement: ContentExpectation 互斥性约束
一条 Raw `ContentExpectation` MUST 为直接 `ValueMatcher` 或恰好一个 extractor`json``css``xpath` 之一)。系统 MUST NOT 允许同一条 Raw expectation 同时包含多个 extractor。直接 `ValueMatcher` expectation MUST NOT 包含 `json``css``xpath` 字段。系统 SHALL 在启动期对违反互斥性的 Raw expectation 报错。
#### Scenario: 多 extractor 被拒绝
- **WHEN** YAML 中内容规则`{json: {path: "$.a"}, css: {selector: "div"}}`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示一条规则不能同时包含多个 extractor
- **WHEN** YAML 中内容 expectation `{json: {path: "$.a"}, css: {selector: "div"}}`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示一条 expectation 不能同时包含多个 extractor
#### Scenario: 直接 matcher 混入 extractor 被拒绝
- **WHEN** YAML 中内容规则`{contains: "ok", json: {path: "$.a"}}`
- **WHEN** YAML 中内容 expectation `{contains: "ok", json: {path: "$.a"}}`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示直接 matcher 不能与 extractor 混用
### Requirement: 空 ContentRules 数组语义
`ContentRules` 空数组 `[]` SHALL 被系统接受为合法配置。运行期空数组 SHALL 等价于无规则,即该内容字段的断言直接通过。
### Requirement: 空 ContentExpectations 数组语义
`ContentExpectations` 空数组 `[]` SHALL 被系统接受为合法配置。运行期空数组 SHALL 等价于无内容 expectation,即该内容字段的断言直接通过。
#### Scenario: 空 body 数组通过
- **WHEN** HTTP target 配置 `expect.body: []` 且响应体为任意内容
- **THEN** 系统 SHALL 判定 body 阶段通过
### Requirement: ContentRules 非字符串值序列化
`ContentRules` 的观测源为非字符串值(如对象或数组)时,直接 `ValueMatcher``contains``regex` SHALL 先将值 JSON 序列化为字符串后匹配。`equals` SHALL 直接在原始结构化值上使用深度相等比较,不进行序列化。
### Requirement: ContentExpectations 非字符串值序列化
`ContentExpectations` 的观测源为非字符串值(如对象或数组)时,直接 `ValueMatcher``contains``regex` SHALL 先将值 JSON 序列化为字符串后匹配。`equals` SHALL 直接在原始结构化值上使用深度相等比较,不进行序列化。
#### Scenario: 对象序列化后 contains 匹配
- **WHEN** ContentRules 观测源为 `{status: "ok"}`规则`{contains: "ok"}`
- **WHEN** ContentExpectations 观测源为 `{status: "ok"}` expectation `{contains: "ok"}`
- **THEN** 系统 SHALL 将对象 JSON 序列化后执行 contains 匹配
#### Scenario: 对象 equals 不序列化
- **WHEN** ContentRules 观测源为 `{status: "ok"}`规则`{equals: {status: "ok"}}`
- **WHEN** ContentExpectations 观测源为 `{status: "ok"}` expectation `{equals: {status: "ok"}}`
- **THEN** 系统 SHALL 直接在结构化值上使用深度相等比较
### Requirement: ContentRules 提取器
系统 SHALL 支持在 `ContentRules` 中使用 `json``css``xpath` extractor。`json.path` MUST 使用现有 JSONPath 子集。`css.selector` MUST 为非空字符串,并 MAY 配置 `attr` 提取属性值。`xpath.path` MUST 为非空字符串,并 SHALL 在启动期进行可编译校验。Extractor 内部 MAY 包含任意 `ValueMatcher` 字段。Extractor 规则未配置任何 matcher 时 SHALL 等价于 `exists: true`
### Requirement: ContentExpectations 提取器
系统 SHALL 支持在 `ContentExpectations` 中使用 `json``css``xpath` extractor。`json.path` MUST 使用现有 JSONPath 子集。`css.selector` MUST 为非空字符串,并 MAY 配置 `attr` 提取属性值。`xpath.path` MUST 为非空字符串,并 SHALL 在启动期进行可编译校验。Extractor 内部 MAY 包含任意 `ValueMatcher` 字段。Extractor expectation 未配置任何 matcher 时resolve 阶段 SHALL 将其 Resolved matcher 物化为 `{ exists: true }`
#### Scenario: json extractor 数字比较
- **WHEN** 原始内容为 JSON 字符串 `{"count": 2}`规则`{json: {path: "$.count", gte: 1}}`
- **THEN** 系统 SHALL 解析 JSON、提取 `$.count` 并判定该规则通过
- **WHEN** 原始内容为 JSON 字符串 `{"count": 2}` expectation `{json: {path: "$.count", gte: 1}}`
- **THEN** 系统 SHALL 解析 JSON、提取 `$.count` 并判定该 expectation 通过
#### Scenario: json extractor 存在性默认语义
- **WHEN** 原始内容为 JSON 字符串 `{"user": {"id": null}}`规则`{json: {path: "$.user.id"}}`
- **THEN** 系统 SHALL 将该规则视为 `{json: {path: "$.user.id", exists: true}}` 并判定通过
- **WHEN** 原始内容为 JSON 字符串 `{"user": {"id": null}}` expectation `{json: {path: "$.user.id"}}`
- **THEN** resolve 阶段 SHALL 将该 expectation 的 matcher 物化为 `{exists: true}`在运行期判定通过
#### Scenario: css attr 存在性默认语义
- **WHEN** 原始内容包含 `<meta name="status" content="ok">`规则`{css: {selector: "meta[name=status]", attr: "content"}}`
- **THEN** 系统 SHALL 在属性存在时判定该规则通过
- **WHEN** 原始内容包含 `<meta name="status" content="ok">` expectation `{css: {selector: "meta[name=status]", attr: "content"}}`
- **THEN** resolve 阶段 SHALL 将该 expectation 的 matcher 物化为 `{exists: true}`在属性存在时判定通过
#### Scenario: xpath 无匹配节点失败
- **WHEN** XML 内容中不存在 XPath 指向的节点,且规则`{xpath: {path: "/root/status"}}`
- **THEN** 系统 SHALL 判定该规则不通过并生成 phase 对应内容字段的 mismatch failure
- **WHEN** XML 内容中不存在 XPath 指向的节点,且 expectation `{xpath: {path: "/root/status"}}`
- **THEN** 系统 SHALL 判定该 expectation 不通过并生成 phase 对应内容字段的 mismatch failure
### Requirement: KeyValueExpect 键值规则
系统 SHALL 提供共享 `KeyValueExpect` 表达键值型观测值断言。`KeyValueExpect` SHALL 为动态键对象,每个键对应的值 MAY`ValueMatcher` 或 JSON 字面量。字面量值 SHALL 等价于 `{equals: <literal>}`。调用方 MAY 指定 key 规范化策略HTTP 与 LLM headers MUST 使用大小写不敏感的 key 匹配。
### Requirement: KeyedExpectations 键控断言数组
系统 SHALL 提供共享 `KeyedExpectations` 表达键值型观测值断言。Raw `KeyedExpectations` SHALL 为动态键对象,每个键对应的值 MUST`RawValueExpectation`;数组和对象简写 MUST 被拒绝,数组或对象 equals 匹配 MUST 显式写为 `{equals: <value>}`。Resolved `KeyedExpectations` SHALL 为有序数组,每个元素包含原始 key 和已归一化的 `ValueExpectation` matcher。调用方 MAY 指定 key 规范化策略HTTP 与 LLM headers MUST 使用大小写不敏感的 key 匹配,并 MUST 在启动期校验或 resolve 阶段拒绝归一化后重复的 header key。DB rows 不做 key 归一化,按查询结果列名大小写敏感匹配。
#### Scenario: headers 字面量快捷写法
- **WHEN** 响应 headers 中 `content-type``application/json`,且配置为 `headers: {Content-Type: "application/json"}`
- **THEN** 系统 SHALL 按大小写不敏感 key 匹配并使用 equals 语义判定通过
- **THEN** resolve 阶段 SHALL 将该项解析为 keyed expectation `{key: "Content-Type", matcher: {equals: "application/json"}}`,运行期按大小写不敏感 key 匹配并判定通过
#### Scenario: headers matcher 写法
- **WHEN** 响应 headers 中 `content-type``application/json; charset=utf-8`,且配置为 `headers: {Content-Type: {contains: "application/json"}}`
- **THEN** 系统 SHALL 判定该 header 规则通过
- **THEN** 系统 SHALL 判定该 header expectation 通过
#### Scenario: 缺失键 exists false
- **WHEN** 观测键值表中不存在 `x-debug`,且配置为 `{x-debug: {exists: false}}`
- **THEN** 系统 SHALL 判定该键规则通过
- **THEN** 系统 SHALL 判定该 keyed expectation 通过
#### Scenario: keyed 对象值必须显式 equals
- **WHEN** Raw keyed expectation 的某个值是对象 `{foo: "bar"}` 且未写在 `equals`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示对象 equals 必须显式写成 `{equals: {foo: "bar"}}`
#### Scenario: keyed 数组值必须显式 equals
- **WHEN** Raw keyed expectation 的某个值是数组 `["a"]` 且未写在 `equals`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示数组 equals 必须显式写成 `{equals: ["a"]}`
#### Scenario: header 归一化重复 key 被拒绝
- **WHEN** HTTP 或 LLM `expect.headers` 同时配置 `Content-Type``content-type`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示 header key 归一化后重复
### Requirement: 结构化失败路径
系统 SHALL 在共享 matcher、content 和 key-value 断言失败时生成结构化 `CheckFailure`。failure SHALL 包含 `kind``phase``path``message`,并在 mismatch 场景包含 `expected``actual`。内容规则 failure path SHALL 包含数组下标key-value failure path SHALL 包含键名extractor failure path SHALL 包含 extractor 类型和 path/selector 信息。
系统 SHALL 在共享 matcher、content 和 keyed expectation 断言失败时生成结构化 `CheckFailure`。failure SHALL 包含 `kind``phase``path``message`,并在 mismatch 场景包含 `expected``actual`。内容 expectation failure path SHALL 包含数组下标keyed expectation failure path SHALL 包含键名extractor failure path SHALL 包含 extractor 类型和 path/selector 信息。failure.expected SHOULD 使用用户可理解的 matcher 或 expectation 片段MUST NOT 直接暴露 Resolved `kind` 执行计划;单字段 `equals` 包装 SHOULD 展示为原始 expected 值。
#### Scenario: ContentRules 失败路径
- **WHEN** `expect.body[1].json` 规则失败
#### Scenario: ContentExpectations 失败路径
- **WHEN** `expect.body[1].json` expectation 失败
- **THEN** failure.path SHALL 指向 `body[1].json($.path)` 或等价可定位路径failure.phase SHALL 为 `body`
#### Scenario: KeyValueExpect 失败路径
#### Scenario: KeyedExpectations 失败路径
- **WHEN** `expect.headers.Content-Type` 不匹配
- **THEN** failure.path SHALL 指向 `headers.Content-Type`failure.phase SHALL 为 `headers`

View File

@@ -110,11 +110,11 @@
- **THEN** 系统 SHALL 记录 `matched=false`failure 的 kind 为 `error`phase 为 `icmp`path 为 `parse`message 包含 "无法解析 icmp 输出"
### Requirement: icmp expect 校验
系统 SHALL 支持 icmp 专属 expect包括 `alive``packetLossPercent``avgLatencyMs``maxLatencyMs``durationMs`,并按 alive、packetLossPercent、avgLatencyMs、maxLatencyMs、durationMs 的阶段顺序快速失败。`alive` SHALL 保持布尔状态语义,未配置时默认 `true``packetLossPercent` SHALL 表示 0 到 100 的丢包率百分比,并使用共享 `ValueMatcher``avgLatencyMs``maxLatencyMs``durationMs` SHALL 使用共享 `ValueMatcher`
系统 SHALL 支持 icmp 专属 expect包括 `alive``packetLossPercent``avgLatencyMs``maxLatencyMs``durationMs`,并按 alive、packetLossPercent、avgLatencyMs、maxLatencyMs、durationMs 的阶段顺序快速失败。`alive` SHALL 保持布尔状态语义,未配置时在 Resolved expect 中默认 `true``packetLossPercent` SHALL 表示 0 到 100 的丢包率百分比,并使用共享 `RawValueExpectation` 输入、运行期 `ValueExpectation` 执行`avgLatencyMs``maxLatencyMs``durationMs` SHALL 使用共享 `RawValueExpectation` 输入、运行期 `ValueExpectation` 执行
#### Scenario: 默认 alive 成功语义
- **WHEN** icmp target 未显式配置 `expect.alive`
- **THEN** 系统 SHALL 使用默认 `expect.alive: true` 进行校验
- **THEN** 系统 SHALL 在 Resolved icmp expect 中使用默认 `alive: true` 进行校验
#### Scenario: alive 校验通过
- **WHEN** icmp target 配置 `expect.alive: true`,且目标主机可达
@@ -165,17 +165,21 @@
- **THEN** 系统 SHALL 以配置错误退出,提示 expect 包含未知字段
#### Scenario: packetLossPercent 类型非法
- **WHEN** YAML 中 icmp target 的 `expect.packetLossPercent` 不是合法 `ValueMatcher`,或其数值范围无法用于 0 到 100 的百分比断言
- **WHEN** YAML 中 icmp target 的 `expect.packetLossPercent` 不是合法 `RawValueExpectation`,或其数值范围无法用于 0 到 100 的百分比断言
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.packetLossPercent 格式错误
#### Scenario: avgLatencyMs 类型非法
- **WHEN** YAML 中 icmp target 的 `expect.avgLatencyMs` 不是合法 `ValueMatcher`
- **WHEN** YAML 中 icmp target 的 `expect.avgLatencyMs` 不是合法 `RawValueExpectation`
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.avgLatencyMs 格式错误
#### Scenario: maxLatencyMs 类型非法
- **WHEN** YAML 中 icmp target 的 `expect.maxLatencyMs` 不是合法 `ValueMatcher`
- **WHEN** YAML 中 icmp target 的 `expect.maxLatencyMs` 不是合法 `RawValueExpectation`
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.maxLatencyMs 格式错误
#### Scenario: Raw icmp expect 不被校验阶段修改
- **WHEN** YAML 中 icmp target 配置 `expect.durationMs: 5000`
- **THEN** 语义校验 SHALL 接受该 Raw primitive 简写且 MUST NOT 将输入对象原地改写为 `{equals: 5000}`
### Requirement: icmp detail 摘要
系统 SHALL 在 icmp API 序列化时从 observation 动态生成结构化 detail 摘要展示关键指标。API registry type SHALL 仍为 `icmp`

View File

@@ -108,18 +108,18 @@ LLM checker SHALL 在 SDK 调用结果和 expect 断言之间构建 `LlmCheckObs
- **THEN** LLM checker SHALL 返回 `phase: "request"` 的 error failure
### Requirement: LLM Expect 断言
LLM checker SHALL 支持 `expect.status``expect.headers``expect.output``expect.finishReason``expect.rawFinishReason``expect.usage.inputTokens``expect.usage.outputTokens``expect.usage.totalTokens``expect.stream.completed``expect.stream.firstTokenMs``expect.durationMs``expect.status` SHALL 保持 HTTP 状态码数组语义并复用 HTTP status 断言。`expect.headers` SHALL 使用共享 `KeyValueExpect`header key 大小写不敏感。`expect.output` MUST 使用共享 `ContentRules``expect.finishReason``expect.rawFinishReason` SHALL 使用共享 `ValueMatcher``expect.usage.*``expect.stream.firstTokenMs``expect.durationMs` SHALL 使用共享 `ValueMatcher`。LLM checker MUST 按固定顺序快速失败,非流式顺序为 status、headers、output、finishReason、rawFinishReason、usage、durationMs流式顺序为 status、headers、stream.completed、stream.firstTokenMs、output、finishReason、rawFinishReason、usage、durationMs。
LLM checker SHALL 支持 `expect.status``expect.headers``expect.output``expect.finishReason``expect.rawFinishReason``expect.usage.inputTokens``expect.usage.outputTokens``expect.usage.totalTokens``expect.stream.completed``expect.stream.firstTokenMs``expect.durationMs``expect.status` SHALL 保持 HTTP 状态码数组语义并复用共享 status code 断言,未配置时在 Resolved expect 中物化默认 `[200]``expect.headers` SHALL 使用共享 `RawKeyedExpectations` 输入并在运行期使用 `KeyedExpectations`header key 大小写不敏感。`expect.output` MUST 使用共享 `RawContentExpectations` 输入并在运行期使用 `ContentExpectations``expect.finishReason``expect.rawFinishReason` SHALL 使用共享 `RawValueExpectation` 输入并在运行期使用 `ValueExpectation``expect.usage.*``expect.stream.firstTokenMs``expect.durationMs` SHALL 使用共享 `RawValueExpectation` 输入并在运行期使用 `ValueExpectation`。LLM checker MUST 按固定顺序快速失败,非流式顺序为 status、headers、output、finishReason、rawFinishReason、usage、durationMs流式顺序为 status、headers、stream.completed、stream.firstTokenMs、output、finishReason、rawFinishReason、usage、durationMs。
#### Scenario: 默认 status 断言
- **WHEN** LLM target 未配置 `expect.status`
- **THEN** LLM checker SHALL 使用默认 `status: [200]` 语义
- **THEN** LLM checker SHALL 在 Resolved expect 中使用默认 `status: [200]` 语义
#### Scenario: expect headers 通过
- **WHEN** observing fetch 捕获的响应 headers 满足 `expect.headers` 配置
- **THEN** LLM checker SHALL 判定 headers 断言通过
- **THEN** LLM checker SHALL 通过共享 header expectation 包装函数判定 headers 断言通过
#### Scenario: output ContentRules 通过
- **WHEN** LLM 输出文本满足 `expect.output` 中配置的全部 ContentRules
#### Scenario: output ContentExpectations 通过
- **WHEN** LLM 输出文本满足 `expect.output` 中配置的全部 ContentExpectations
- **THEN** LLM checker SHALL 判定 output 阶段通过
#### Scenario: finishReason ValueMatcher 通过
@@ -175,18 +175,22 @@ LLM checker SHALL 使用共享 `ContentRules` 校验 `expect.output`。每个 ou
#### Scenario: output JSONPath 存在性默认语义
- **WHEN** `outputText` 是 JSON 字符串且 target 配置 `expect.output: [{json: {path: "$.status"}}]`
- **THEN** LLM checker SHALL 将该规则按 `exists: true` 语义执行
- **THEN** resolve 阶段 SHALL 将该 expectation 的 matcher 物化为 `{exists: true}`,运行期按存在性语义执行
#### Scenario: output 规则按顺序快速失败
- **WHEN** `expect.output` 包含多个规则且第一条规则失败
- **THEN** LLM checker SHALL 返回第一条失败规则的 mismatch failure不继续校验后续 output 规则
- **WHEN** `expect.output` 包含多个 expectation 且第一条 expectation 失败
- **THEN** LLM checker SHALL 返回第一条失败 expectation 的 mismatch failure不继续校验后续 output expectation
### Requirement: LLM Stream 断言
LLM checker SHALL 仅允许 `mode: stream` 使用 `expect.stream``expect.stream.completed` 未配置时LLM checker SHALL 在 stream observation 路径使用默认 `true` 语义`expect.stream.firstTokenMs` SHALL 使用共享 `ValueMatcher`仅统计第一个非空 `text-delta` 事件耗时,不统计 reasoning、tool call 或 source 事件。
LLM checker SHALL 仅允许 `mode: stream` 使用 `expect.stream`仅当用户配置了 `expect.stream` 且未配置 `expect.stream.completed`resolve 阶段 SHALL 在 Resolved expect 中物化默认 `completed: true`LLM checker MUST NOT 因为 `llm.mode: stream` 自动添加 `stream.completed` 断言`expect.stream.firstTokenMs` SHALL 使用共享 `RawValueExpectation` 输入并在运行期使用 `ValueExpectation`仅统计第一个非空 `text-delta` 事件耗时,不统计 reasoning、tool call 或 source 事件。
#### Scenario: stream completed 默认值
- **WHEN** target 配置 `llm.mode: stream`配置 `expect.stream.completed`
- **THEN** LLM checker SHALL 要求 SDK stream 正常完成
- **WHEN** target 配置 `llm.mode: stream` 且配置 `expect.stream: {}`
- **THEN** resolve 阶段 SHALL 在 Resolved expect 中物化 `stream.completed: true`要求 SDK stream 正常完成
#### Scenario: 未配置 expect.stream 不添加 completed
- **WHEN** target 配置 `llm.mode: stream` 但未配置 `expect.stream`
- **THEN** resolve 阶段 SHALL NOT 自动添加 `stream.completed` 断言
#### Scenario: stream error
- **WHEN** `fullStream` 产生 error part

View File

@@ -101,7 +101,7 @@
`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}`
所有 `RawValueExpectation` 类型的 expect 字段 SHALL 在 JSON Schema 契约中声明为 `anyOf: [primitiveValue, matcherObject]` 联合类型,同时接受 primitive 原始值string / number / boolean / null`ValueMatcher` 对象。语义 validator SHALL 在不修改输入的前提下校验 `RawValueExpectation`primitive 原始值 SHALL 被视为合法 Raw 输入,并在 checker resolve 阶段转换为 `{equals: value}` 对象形式。数组和对象 MUST NOT 作为 `RawValueExpectation` 原始值简写;需要对数组或对象执行 equals 匹配时,配置 MUST 显式写成 `{equals: value}``RawKeyedExpectations` 的动态键值 schema SHALL 复用 `RawValueExpectation`MUST NOT 通过额外 JSON value 分支接受数组或对象简写。
#### Scenario: target 缺少必填字段
- **WHEN** YAML 中某个 target 缺少 id 或 type 字段
@@ -196,20 +196,20 @@
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 status 数字不合法
#### Scenario: durationMs matcher 非法
- **WHEN** YAML 中某个 target 的 `expect.durationMs` 不是合法 `ValueMatcher` 也不是 primitive 原始值
- **WHEN** YAML 中某个 target 的 `expect.durationMs` 不是合法 `RawValueExpectation`
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 expect.durationMs 格式错误
#### Scenario: durationMs 原始值简写合法
- **WHEN** YAML 中某个 target 配置 `expect.durationMs: 5000`
- **THEN** 系统 SHALL 接受该配置,归一化`{equals: 5000}` 后校验通过
- **THEN** 语义校验 SHALL 接受该 Raw primitive 简写且 MUST NOT 修改输入checker resolve 阶段 SHALL 将其解析`{equals: 5000}`
#### Scenario: ValueMatcher 字段字符串简写合法
- **WHEN** YAML 中某个 target 配置 `expect.finishReason: "stop"`
- **THEN** 系统 SHALL 接受该配置,归一化`{equals: "stop"}` 后校验通过
- **THEN** 语义校验 SHALL 接受该 Raw primitive 简写且 MUST NOT 修改输入checker resolve 阶段 SHALL 将其解析`{equals: "stop"}`
#### Scenario: ValueMatcher 字段 null 简写合法
- **WHEN** YAML 中某个 target 配置 ValueMatcher 字段值为 `null`
- **THEN** 系统 SHALL 接受该配置,归一化`{equals: null}` 后校验通过
- **THEN** 语义校验 SHALL 接受该 Raw primitive 简写且 MUST NOT 修改输入checker resolve 阶段 SHALL 将其解析`{equals: null}`
#### Scenario: ValueMatcher 字段数组简写非法
- **WHEN** YAML 中某个 target 配置 ValueMatcher 字段值为数组 `[1, 2]`
@@ -223,40 +223,40 @@
- **WHEN** YAML 中某个 target 配置 `type: icmp` 但缺少 `icmp.host`
- **THEN** 系统 SHALL 以错误退出,提示该 target 缺少 icmp.host 字段
#### Scenario: ping expect 未知字段
- **WHEN** YAML 中 ping target 的 expect 包含非 ping expect 字段
#### Scenario: icmp expect 未知字段
- **WHEN** YAML 中 icmp target 的 expect 包含非 icmp expect 字段
- **THEN** 系统 SHALL 以配置错误退出,提示 expect 包含未知字段
#### Scenario: HTTP expect headers 非法
- **WHEN** YAML 中某个 HTTP target 的 `expect.headers` 不是对象,或某个 header 期望既不是字符串也不是合法 operator
- **WHEN** YAML 中某个 HTTP target 的 `expect.headers` 不是对象,或某个 header 期望不是合法 `RawValueExpectation`
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 expect.headers 格式错误
#### Scenario: HTTP expect body 必须为数组
- **WHEN** YAML 中某个 HTTP target 的 `expect.body` 已配置但不是数组
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 expect.body 必须为数组
#### Scenario: HTTP body rule 缺少支持字段
#### Scenario: HTTP body expectation 缺少支持字段
- **WHEN** YAML 中某个 HTTP target 的 `expect.body` 数组项未包含 contains、regex、json、css、xpath 任一支持字段
- **THEN** 系统 SHALL 以错误退出,提示该 body rule 缺少支持的规则类型
- **THEN** 系统 SHALL 以错误退出,提示该 body expectation 缺少支持的 expectation 类型
#### Scenario: HTTP body rule 同时配置多个支持字段
- **WHEN** YAML 中某个 HTTP target 的同一条 body rule 同时包含 contains、regex、json、css、xpath 中的多个支持字段
- **THEN** 系统 SHALL 以错误退出,提示每条 body rule 只能配置一种规则类型
#### Scenario: HTTP body expectation 同时配置多个支持字段
- **WHEN** YAML 中某个 HTTP target 的同一条 body expectation 同时包含 contains、regex、json、css、xpath 中的多个支持字段
- **THEN** 系统 SHALL 以错误退出,提示每条 body expectation 只能配置一种 expectation 类型
#### Scenario: HTTP body regex 非法
- **WHEN** YAML 中某个 HTTP target 的 body regex 规则不是字符串或不是可编译正则表达式
- **WHEN** YAML 中某个 HTTP target 的 body regex expectation 不是字符串或不是可编译正则表达式
- **THEN** 系统 SHALL 以错误退出,提示该 body regex 不合法
#### Scenario: HTTP body json path 非法
- **WHEN** YAML 中某个 HTTP target 的 body json 规则缺少 path或 path 不符合系统支持的 JSONPath 子集
- **WHEN** YAML 中某个 HTTP target 的 body json expectation 缺少 path或 path 不符合系统支持的 JSONPath 子集
- **THEN** 系统 SHALL 以错误退出,提示该 body json path 不合法
#### Scenario: HTTP body css selector 非法
- **WHEN** YAML 中某个 HTTP target 的 body css 规则缺少 selector或 selector 不是非空字符串
- **WHEN** YAML 中某个 HTTP target 的 body css expectation 缺少 selector或 selector 不是非空字符串
- **THEN** 系统 SHALL 以错误退出,提示该 body css selector 不合法
#### Scenario: HTTP body xpath path 非法
- **WHEN** YAML 中某个 HTTP target 的 body xpath 规则缺少 path或 path 不是非空字符串,或可被现有 XPath 库静态判定为语法错误
- **WHEN** YAML 中某个 HTTP target 的 body xpath expectation 缺少 path或 path 不是非空字符串,或可被现有 XPath 库静态判定为语法错误
- **THEN** 系统 SHALL 以错误退出,提示该 body xpath path 不合法
#### Scenario: expect matcher 类型非法
@@ -293,23 +293,27 @@
#### Scenario: 导出配置 JSON Schema
- **WHEN** 仓库生成或检查配置契约
- **THEN** 根目录 SHALL 存在 draft-07 `probe-config.schema.json`,且其内容 SHALL 与当前公共 fragments 和已注册 checker fragments 组装出的完整 schema 一致(包含 variables 段和 target 的 id/name 字段)。所有 ValueMatcher 字段的 schema SHALL 声明为 `anyOf: [primitiveValue, matcherObject]` 联合类型
- **THEN** 根目录 SHALL 存在 draft-07 `probe-config.schema.json`,且其内容 SHALL 与当前公共 fragments 和已注册 checker fragments 组装出的完整 schema 一致(包含 variables 段和 target 的 id/name 字段)。所有 `RawValueExpectation` 字段的 schema SHALL 声明为 `anyOf: [primitiveValue, matcherObject]` 联合类型`RawKeyedExpectations` 的 dynamic value schema SHALL 复用 `RawValueExpectation`
#### Scenario: JSON Schema ValueMatcher 接受原始值
- **WHEN** 使用 JSON Schema 校验配置文件中 ValueMatcher 字段值为数字 `5000`
- **THEN** JSON Schema 校验 SHALL 通过,因为 ValueMatcher schema 声明为 `anyOf: [primitiveValue, matcherObject]`
#### Scenario: JSON Schema RawValueExpectation 接受原始值
- **WHEN** 使用 JSON Schema 校验配置文件中 RawValueExpectation 字段值为数字 `5000`
- **THEN** JSON Schema 校验 SHALL 通过,因为 RawValueExpectation schema 声明为 `anyOf: [primitiveValue, matcherObject]`
#### Scenario: JSON Schema ValueMatcher 接受 matcher 对象
- **WHEN** 使用 JSON Schema 校验配置文件中 ValueMatcher 字段值为 `{lte: 5000}`
#### Scenario: JSON Schema RawValueExpectation 接受 matcher 对象
- **WHEN** 使用 JSON Schema 校验配置文件中 RawValueExpectation 字段值为 `{lte: 5000}`
- **THEN** JSON Schema 校验 SHALL 通过
#### Scenario: JSON Schema ValueMatcher 拒绝数组原始值
- **WHEN** 使用 JSON Schema 校验配置文件中 ValueMatcher 字段值为数组 `[1, 2]`
#### Scenario: JSON Schema RawValueExpectation 拒绝数组原始值
- **WHEN** 使用 JSON Schema 校验配置文件中 RawValueExpectation 字段值为数组 `[1, 2]`
- **THEN** JSON Schema 校验 SHALL 失败,因为数组不属于 primitive 原始值或 matcher 对象
#### Scenario: JSON Schema ValueMatcher 接受 equals 数组对象
- **WHEN** 使用 JSON Schema 校验配置文件中 ValueMatcher 字段值为 `{equals: [1, 2]}``{equals: {status: "ok"}}`
#### Scenario: JSON Schema RawValueExpectation 接受 equals 数组对象
- **WHEN** 使用 JSON Schema 校验配置文件中 RawValueExpectation 字段值为 `{equals: [1, 2]}``{equals: {status: "ok"}}`
- **THEN** JSON Schema 校验 SHALL 通过,因为 `equals` 支持任意 JSON value
#### Scenario: JSON Schema RawKeyedExpectations 拒绝数组对象简写
- **WHEN** 使用 JSON Schema 校验配置文件中 `expect.headers.X` 或 DB row 列值为数组 `["a"]` 或对象 `{nested: "ok"}`
- **THEN** JSON Schema 校验 SHALL 失败,除非该值显式写在 `{equals: ...}`
系统 SHALL 支持使用单位字符串配置读取上限,单位包括 `B``KB``MB``GB`
#### Scenario: 解析 MB
@@ -339,43 +343,45 @@
- **THEN** 系统 SHALL 调用 `Bun.YAML.parse()` 将内容解析为配置对象
### Requirement: expect 配置增强
系统 SHALL 支持 typed target 的领域专用 expect 配置,并通过共享 `ValueMatcher``ContentRules``KeyValueExpect` 表达可复用断言能力。状态类字段 SHALL 保持枚举或布尔语义,包括 HTTP/LLM 的 `status`支持精确数字和范围模式、cmd 的 `exitCode`、tcp 的 `connected`ping`alive` 和 udp 的 `responded`数字指标字段 SHALL 使用 `ValueMatcher`,包括通用 `durationMs`、db 的 `rowCount`、udp 的 `responseSize`/`sourceHost`/`sourcePort`ping`packetLossPercent`/`avgLatencyMs`/`maxLatencyMs`、llm 的 usage token 与 stream 首 token 耗时。内容类字段 MUST 使用 `ContentRules` 数组表达配置顺序,包括 HTTP `body`、cmd `stdout`/`stderr`、tcp `banner`、udp `response`、llm `output` 和 db `result`。LLM `finishReason``rawFinishReason` SHALL 使用 `ValueMatcher`(非 ContentRules因为它们是单值字符串元数据。键值类字段 SHALL 使用 `KeyValueExpect`,包括 HTTP/LLM `headers` 和 db `rows` 中的列值断言db `rows` 的类型为 `Array<KeyValueExpect>`,外层数组按行索引,内层每个元素为 KeyValueExpect)。
系统 SHALL 支持 typed target 的领域专用 expect 配置,并通过共享 `ValueMatcher``ContentExpectations``KeyedExpectations` 表达可复用断言能力。状态类字段 SHALL 保持枚举或布尔语义,包括 HTTP/LLM 的 `status`支持精确数字和范围模式、cmd 的 `exitCode`、tcp 的 `connected`icmp`alive` 和 udp 的 `responded`value 类指标字段 SHALL 使用 `RawValueExpectation` 输入,并在 resolve 阶段归一化为运行期 `ValueExpectation`,包括通用 `durationMs`、db 的 `rowCount`、udp 的 `responseSize`/`sourceHost`/`sourcePort`icmp`packetLossPercent`/`avgLatencyMs`/`maxLatencyMs`、llm 的 usage token 与 stream 首 token 耗时。内容类字段 MUST 使用 `RawContentExpectations` 数组表达配置顺序,包括 HTTP `body`、cmd `stdout`/`stderr`、tcp `banner`、udp `response`、llm `output` 和 db `result`。LLM `finishReason``rawFinishReason` SHALL 使用 `RawValueExpectation`(非 ContentExpectations因为它们是单值字符串元数据。键值类字段 SHALL 使用 `RawKeyedExpectations`,包括 HTTP/LLM `headers` 和 db `rows` 中的列值断言db `rows` 的类型为 `Array<RawKeyedExpectations>`,外层数组按行索引,内层每个元素表达该行的列值断言)。
配置加载流程 SHALL 保留变量替换后的 Raw expect 作为用户配置快照,同时生成 Resolved expect 作为运行期执行计划。语义校验 SHALL 只读取 Raw expect 并报告问题MUST NOT 原地归一化或修改 Raw expect。Store 持久化 SHALL 写入 Raw expectchecker execute SHALL 只消费 Resolved expect。
#### Scenario: 解析 HTTP expect 配置
- **WHEN** YAML 配置文件中 HTTP target 的 expect 包含 status、headers、body 规则数组和 durationMs matcher
- **THEN** 系统 SHALL 正确解析并存储为 HTTP target 的 expect 字段
- **WHEN** YAML 配置文件中 HTTP target 的 expect 包含 status、headers、body expectation 数组和 durationMs matcher
- **THEN** 系统 SHALL 保留 Raw HTTP expect 快照,并生成包含默认 status、resolved keyed headers、resolved content body 和 resolved durationMs 的 HTTP Resolved expect
#### Scenario: 解析 cmd expect 配置
- **WHEN** YAML 配置文件中 cmd target 的 expect 包含 exitCode、stdout、stderr 和 durationMs matcher
- **THEN** 系统 SHALL 正确解析并存储为 cmd target 的 expect 字段
- **THEN** 系统 SHALL 保留 Raw cmd expect 快照,并生成包含默认 exitCode、resolved stdout/stderr content expectations 和 resolved durationMs 的 cmd Resolved expect
#### Scenario: 解析 db expect 配置
- **WHEN** YAML 配置文件中 db target 的 expect 包含 durationMs、rowCount、rows 和 result
- **THEN** 系统 SHALL 正确解析并存储为 db target 的 expect 字段
- **THEN** 系统 SHALL 保留 Raw db expect 快照,并生成包含 resolved rowCount、rows keyed expectations 和 result content expectations 的 db Resolved expect
#### Scenario: 解析 tcp expect 配置
- **WHEN** YAML 配置文件中 tcp target 的 expect 包含 connected、banner 规则数组和 durationMs matcher
- **THEN** 系统 SHALL 正确解析并存储为 tcp target 的 expect 字段
- **WHEN** YAML 配置文件中 tcp target 的 expect 包含 connected、banner expectation 数组和 durationMs matcher
- **THEN** 系统 SHALL 保留 Raw tcp expect 快照,并生成包含默认 connected、resolved banner content expectations 和 resolved durationMs 的 tcp Resolved expect
#### Scenario: 解析 ping expect 配置
- **WHEN** YAML 配置文件中 ping target 的 expect 包含 alive、packetLossPercent、avgLatencyMs、maxLatencyMs 和 durationMs matcher
- **THEN** 系统 SHALL 正确解析并存储为 ping target 的 expect 字段
#### Scenario: 解析 icmp expect 配置
- **WHEN** YAML 配置文件中 icmp target 的 expect 包含 alive、packetLossPercent、avgLatencyMs、maxLatencyMs 和 durationMs matcher
- **THEN** 系统 SHALL 保留 Raw icmp expect 快照,并生成包含默认 alive 和 resolved 数值 expectations 的 icmp Resolved expect
#### Scenario: 解析 udp expect 配置
- **WHEN** YAML 配置文件中 udp target 的 expect 包含 responded、response、responseSize、sourceHost、sourcePort 和 durationMs matcher
- **THEN** 系统 SHALL 正确解析并存储为 udp target 的 expect 字段
- **THEN** 系统 SHALL 保留 Raw udp expect 快照,并生成包含默认 responded、resolved response content expectations 和 resolved value expectations 的 udp Resolved expect
#### Scenario: 解析 llm expect 配置
- **WHEN** YAML 配置文件中 llm target 的 expect 包含 status、headers、output、finishReason、rawFinishReason、usage、stream 和 durationMs matcher
- **THEN** 系统 SHALL 正确解析并存储为 llm target 的 expect 字段,并保留 output 内容规则数组顺序
- **THEN** 系统 SHALL 保留 Raw llm expect 快照,并生成包含默认 status、resolved headers、output、finishReason、rawFinishReason、usage、stream 和 durationMs 的 llm Resolved expect并保留 output 内容 expectation 数组顺序
#### Scenario: 解析有序 ContentRules 数组
#### Scenario: 解析有序 ContentExpectations 数组
- **WHEN** YAML 中任一内容类 expect 配置 contains、json、regex 三个数组项
- **THEN** 系统 SHALL 保留数组顺序,供执行阶段按配置顺序快速失败
- **THEN** 系统 SHALL 在 Raw expect 中保留数组顺序,并在 Resolved expect 中保留执行顺序,供执行阶段按配置顺序快速失败
#### Scenario: 不配置 HTTP status
- **WHEN** HTTP target 未配置 `expect.status`
- **THEN** 系统 SHALL 在执行 expect 时使用默认 `status: [200]` 语义
- **THEN** 系统 SHALL 在 Resolved HTTP expect 中物化默认 `status: [200]` 语义
#### Scenario: 配置 HTTP status 范围模式
- **WHEN** HTTP target 配置 `expect.status: ["2xx"]`
@@ -383,19 +389,23 @@
#### Scenario: 不配置 cmd exitCode
- **WHEN** cmd target 未配置 `expect.exitCode`
- **THEN** 系统 SHALL 在执行 expect 时使用默认 `exitCode: [0]` 语义
- **THEN** 系统 SHALL 在 Resolved cmd expect 中物化默认 `exitCode: [0]` 语义
#### Scenario: 不配置 expect
- **WHEN** target 未配置任何 expect 规则
- **THEN** 系统 SHALL 正常处理expect 字段为 undefined由各 checker 使用自身默认状态语义
- **THEN** 系统 SHALL 正常处理,Raw expect 快照为 undefinedResolved expect 由各 checker 物化自身默认状态语义
#### Scenario: Raw expect 不被语义校验修改
- **WHEN** YAML 中配置 `expect.durationMs: 1000`
- **THEN** 语义校验 SHALL 接受该 Raw primitive 简写且 MUST NOT 将 Raw 输入原地修改为 `{equals: 1000}`
#### Scenario: 旧 maxDurationMs 字段不再支持
- **WHEN** YAML 中任一 target 配置 `expect.maxDurationMs`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示该字段未知,并要求使用 `expect.durationMs`
#### Scenario: 旧 match 字段不再支持
- **WHEN** YAML 中任一 matcher 或内容规则配置 `match`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示该字段未知,并要求使用 `regex`
- **WHEN** YAML 中任一 matcher 或内容 expectation 配置 `match`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示该字段未知或不支持,并要求使用 `regex`
#### Scenario: durationMs matcher 配置
- **WHEN** YAML 中任一 target 配置 `expect.durationMs: {lte: 1000}`
@@ -405,14 +415,18 @@
- **WHEN** YAML 中 `http.headers``defaults.http.headers``llm.headers``defaults.llm.headers``expect.headers` 包含任意 header 名称,且对应值符合契约
- **THEN** 系统 SHALL 接受这些动态 header 名称
#### Scenario: ContentRules 字段必须为数组
#### Scenario: ContentExpectations 字段必须为数组
- **WHEN** YAML 中任一内容类 expect 字段配置为非数组
- **THEN** 系统 SHALL 在启动期配置校验失败,提示该字段必须为规则数组
- **THEN** 系统 SHALL 在启动期配置校验失败,提示该字段必须为 expectation 数组
#### Scenario: regex 字段非法
- **WHEN** YAML 中任一 `regex` 不是字符串、不是可编译正则或存在 ReDoS 风险
- **THEN** 系统 SHALL 在启动期配置校验失败,提示对应 regex 不合法
#### Scenario: Store 持久化 Raw expect
- **WHEN** 系统同步已解析 target 到 targets 表
- **THEN** `targets.expect` SHALL 存储变量替换后的 Raw expect JSON而不是包含 `kind` 或 resolved matcher 的运行期执行计划
### Requirement: 数据保留配置字段
配置 schema 的 `runtime` 段 SHALL 支持 `retention` 字段,类型为字符串,格式为 `<数字><单位>`(单位:`d` 天、`h` 小时、`m` 分钟),用于指定历史数据保留时长。
@@ -498,7 +512,7 @@
- **THEN** target schema SHALL 声明 `id` 的 minLength 为 1、maxLength 为 30并声明 `name` 为可选字段,类型为 string 或 null字符串的 minLength 为 1、maxLength 为 30
### Requirement: TCP 配置校验
系统 SHALL 在启动期对 tcp checker 的配置契约和语义执行严格校验。Tcp target 的 `tcp` 分组 SHALL 只允许 `host``port``readBanner``bannerReadTimeout``maxBannerBytes` 字段Tcp expect SHALL 只允许 `connected``durationMs``banner` 字段。`banner` MUST 为 `ContentRules` 数组。未知字段、非法类型、非法端口、非法 size 和不可编译正则 MUST 导致启动期配置错误
系统 SHALL 在启动期对 tcp checker 的配置契约和语义执行严格校验。Tcp target 的 `tcp` 分组 SHALL 只允许 `host``port``readBanner``bannerReadTimeout``maxBannerBytes` 字段Tcp expect SHALL 只允许 `connected``durationMs``banner` 字段。`banner` MUST 为 `RawContentExpectations` 数组,`durationMs` SHALL 为 `RawValueExpectation`。未知字段、非法类型、非法端口、非法 size、非法 ContentExpectations 和不可编译正则 MUST 导致启动期配置错误。语义校验 MUST NOT 修改 Raw tcp expect 输入
#### Scenario: tcp host 类型非法
- **WHEN** YAML 中 tcp target 的 `tcp.host` 不是非空字符串
@@ -525,7 +539,7 @@
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.connected 必须为布尔值
#### Scenario: tcp expect banner 非法
- **WHEN** YAML 中 tcp target 的 `expect.banner` 不是合法 ContentRules 数组
- **WHEN** YAML 中 tcp target 的 `expect.banner` 不是合法 `RawContentExpectations` 数组
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.banner 格式错误
#### Scenario: tcp expect banner regex 正则非法
@@ -541,7 +555,7 @@
- **THEN** 系统 SHALL 以配置错误退出,提示 defaults.tcp 包含未知字段
### Requirement: LLM 配置校验
系统 SHALL 在启动期对 llm checker 的配置契约和语义执行严格校验。LLM target 的 `llm` 分组 SHALL 只允许 `provider``url``model``prompt``mode``key``authToken``headers``ignoreSSL``options``providerOptions` 字段。`defaults.llm` 分组 SHALL 只允许 `mode``headers``ignoreSSL``options``providerOptions` 字段。LLM expect SHALL 只允许 `status``headers``output``finishReason``rawFinishReason``usage``stream``durationMs` 字段。`expect.output` MUST 为 `ContentRules` 数组。`expect.finishReason``expect.rawFinishReason` SHALL 使用 `ValueMatcher``expect.usage.*``expect.stream.firstTokenMs` SHALL 使用 `ValueMatcher`。未知字段、非法 provider、非法 URL、非法 mode、非法认证组合、非法 options、非法 output 规则`mode: http` 下配置 `expect.stream` MUST 导致启动期配置错误。
系统 SHALL 在启动期对 llm checker 的配置契约和语义执行严格校验。LLM target 的 `llm` 分组 SHALL 只允许 `provider``url``model``prompt``mode``key``authToken``headers``ignoreSSL``options``providerOptions` 字段。`defaults.llm` 分组 SHALL 只允许 `mode``headers``ignoreSSL``options``providerOptions` 字段。LLM expect SHALL 只允许 `status``headers``output``finishReason``rawFinishReason``usage``stream``durationMs` 字段。`expect.output` MUST 为 `RawContentExpectations` 数组。`expect.finishReason``expect.rawFinishReason``expect.usage.*``expect.stream.firstTokenMs``expect.durationMs` SHALL 使用 `RawValueExpectation`。未知字段、非法 provider、非法 URL、非法 mode、非法认证组合、非法 options、非法 output expectation `mode: http` 下配置 `expect.stream` MUST 导致启动期配置错误。语义校验 MUST NOT 修改 Raw llm expect 输入。
#### Scenario: llm provider 非法
- **WHEN** YAML 中 llm target 的 `llm.provider` 不是 `openai``openai-responses``anthropic`
@@ -591,24 +605,24 @@
- **WHEN** YAML 中 llm target 配置 `api``providerName``baseURL``apiKey``messages``maxRetries``request``maxBodyBytes``maxStreamBytes`
- **THEN** 系统 SHALL 以配置错误退出,提示 llm 分组包含未知字段
#### Scenario: llm output 规则缺少支持字段
#### Scenario: llm output expectation 缺少支持字段
- **WHEN** YAML 中 llm target 的 `expect.output` 数组项未包含任何合法 ValueMatcher 字段或 extractor
- **THEN** 系统 SHALL 以配置错误退出,提示 output rule 缺少支持的规则类型
- **THEN** 系统 SHALL 以配置错误退出,提示 output expectation 缺少支持的 expectation 类型
#### Scenario: llm output 规则同时配置多个 extractor
- **WHEN** YAML 中 llm target 的同一条 output rule 同时包含 json、css、xpath 中的多个 extractor
- **THEN** 系统 SHALL 以配置错误退出,提示每条 output rule 只能配置一种 extractor
#### Scenario: llm output expectation 同时配置多个 extractor
- **WHEN** YAML 中 llm target 的同一条 output expectation 同时包含 json、css、xpath 中的多个 extractor
- **THEN** 系统 SHALL 以配置错误退出,提示每条 output expectation 只能配置一种 extractor
#### Scenario: llm output regex 非法
- **WHEN** YAML 中 llm target 的 output regex 规则不是字符串、不是可编译正则表达式或存在 ReDoS 风险
- **WHEN** YAML 中 llm target 的 output regex expectation 不是字符串、不是可编译正则表达式或存在 ReDoS 风险
- **THEN** 系统 SHALL 以配置错误退出,提示该 output regex 不合法
#### Scenario: llm output json path 非法
- **WHEN** YAML 中 llm target 的 output json 规则缺少 path或 path 不符合系统支持的 JSONPath 子集
- **WHEN** YAML 中 llm target 的 output json expectation 缺少 path或 path 不符合系统支持的 JSONPath 子集
- **THEN** 系统 SHALL 以配置错误退出,提示该 output json path 不合法
#### Scenario: llm expect usage 非法
- **WHEN** YAML 中 llm target 的 `expect.usage.inputTokens``expect.usage.outputTokens``expect.usage.totalTokens` 不是合法 `ValueMatcher`
- **WHEN** YAML 中 llm target 的 `expect.usage.inputTokens``expect.usage.outputTokens``expect.usage.totalTokens` 不是合法 `RawValueExpectation`
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.usage 格式错误
#### Scenario: llm expect stream 仅允许 stream mode
@@ -616,5 +630,5 @@
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.stream 仅支持 stream mode
#### Scenario: llm expect stream firstTokenMs 非法
- **WHEN** YAML 中 llm target 的 `expect.stream.firstTokenMs` 不是合法 `ValueMatcher`
- **WHEN** YAML 中 llm target 的 `expect.stream.firstTokenMs` 不是合法 `RawValueExpectation`
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.stream.firstTokenMs 格式错误

View File

@@ -30,11 +30,11 @@
- **THEN** check_results 表的外键约束 SHALL 使用 `ON DELETE RESTRICT`,确保删除 target 时数据库层面阻止操作而非级联删除关联记录
### Requirement: targets 表同步
系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示名称元信息、展示摘要、领域配置、调度配置、expect 配置、分组信息和目标说明。配置中不存在的 target SHALL 被标记为非活跃而非删除。
系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示名称元信息、展示摘要、领域配置、调度配置、变量替换后的 Raw expect 配置快照、分组信息和目标说明。配置中不存在的 target SHALL 被标记为非活跃而非删除。`targets.expect` SHALL 存储 Raw expect JSON系统 MUST NOT 将 Resolved expect 执行计划、`ContentExpectation.kind` union 或已归一化 matcher 包装结构写入该列。
#### Scenario: 首次同步目标
- **WHEN** 数据库为空且 YAML 中定义了 N 个 typed target
- **THEN** 系统 SHALL 将所有目标插入 targets 表,包含 name、description、type、target、config、interval_ms、timeout_ms、expect、grp 和 active=1其中 name 和 description 均可为 NULL
- **THEN** 系统 SHALL 将所有目标插入 targets 表,包含 name、description、type、target、config、interval_ms、timeout_ms、expect、grp 和 active=1其中 name 和 description 均可为 NULLexpect 列保存变量替换后的 Raw expect JSON
#### Scenario: 配置变更后重新同步
- **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启
@@ -64,6 +64,14 @@
- **WHEN** YAML target 配置 `description: null`
- **THEN** targets 表 SHALL 将该目标的 description 存储为 NULL
#### Scenario: expect 列保存 Raw expect
- **WHEN** target 配置 `expect.body: [{json: {path: "$.status"}}]``expect.durationMs: 1000`
- **THEN** targets 表的 expect 列 SHALL 保存变量替换后的 Raw JSON包含原始 `json.path``durationMs: 1000`MUST NOT 保存 resolved `kind` 字段或 `{equals: 1000}` 执行计划
#### Scenario: 未配置 expect 写入 NULL
- **WHEN** target 未配置任何 expect
- **THEN** targets 表的 expect 列 SHALL 写入 NULL即使 Resolved expect 中存在 checker 默认状态语义
### Requirement: check_results 表追加写入
系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。

View File

@@ -121,11 +121,11 @@
- **THEN** 系统 SHALL 正常记录执行结果并进入 expect 校验
### Requirement: expect 校验
系统 SHALL 在 checker 执行完成后根据目标类型的 expect 配置校验观测结果,校验结果和首个失败原因记入 check result。HTTP checker 的 `durationMs` SHALL 表示完整 checker 执行耗时,包括重定向、响应体读取、响应体解码和 expect 校验。
系统 SHALL 在 checker 执行完成后根据目标类型的 Resolved expect 执行计划校验观测结果,校验结果和首个失败原因记入 check result。HTTP checker 的 `durationMs` SHALL 表示完整 checker 执行耗时,包括重定向、响应体读取、响应体解码和 expect 校验。HTTP `expect.durationMs` SHALL 使用 `RawValueExpectation` 输入并在 resolve 阶段转换为运行期 `ValueExpectation`;旧 `expect.maxDurationMs` MUST NOT 再作为运行期耗时阈值使用。
#### Scenario: HTTP 默认状态码
- **WHEN** HTTP target 未配置 `expect.status`
- **THEN** 系统 SHALL 默认 `status: [200]` 校验响应状态码
- **THEN** 系统 SHALL 在 Resolved HTTP expect 中物化默认 `status: [200]` 并按该语义校验响应状态码
#### Scenario: 校验 HTTP 状态码精确值
- **WHEN** HTTP target 配置了 `expect.status: [200, 201]`
@@ -144,39 +144,39 @@
- **THEN** 系统 SHALL 检查响应头是否符合指定规则,全部匹配时继续后续阶段
#### Scenario: 校验 HTTP 响应体
- **WHEN** HTTP target 配置了有序 `expect.body` 规则数组
- **THEN** 系统 SHALL 按数组顺序执行 body 规则,任一失败立即记录 failure 并停止后续规则
- **WHEN** HTTP target 配置了有序 `expect.body` ContentExpectations 数组
- **THEN** 系统 SHALL 按数组顺序执行 body expectations,任一失败立即记录 failure 并停止后续 expectation
#### Scenario: 校验 HTTP 完整耗时阈值
- **WHEN** 目标配置了 `expect.maxDurationMs`,且 HTTP checker 完整执行含重定向、body 读取、解码和 expect后的 durationMs 超过阈值
- **WHEN** 目标配置了 `expect.durationMs: {lte: 1000}`,且 HTTP checker 完整执行含重定向、body 读取、解码和 expect后的 durationMs 超过阈值
- **THEN** 系统 SHALL 判定 duration 不匹配,记录完整 durationMs 和 duration failure
#### Scenario: HTTP body 前耗时已超阈值
- **WHEN** HTTP target 配置了 body 校验和 `expect.maxDurationMs`,且进入 body 读取前的已耗时已超过阈值
#### Scenario: HTTP body 前耗时已不可能满足 durationMs 上界
- **WHEN** HTTP target 配置了 body 校验和 `expect.durationMs` 上界 matcher`{lte: 1000}`,且进入 body 读取前的已耗时已使该 matcher 不可能通过
- **THEN** 系统 SHALL 直接返回 duration failure且 MUST NOT 读取 response body
#### Scenario: HTTP body 失败优先于后续 duration 检查
- **WHEN** HTTP target 配置了 body 校验和 `expect.maxDurationMs`body 阶段存在失败,且完整执行后 duration 也超过阈值
- **WHEN** HTTP target 配置了 body 校验和 `expect.durationMs: {lte: 1000}`body 阶段存在失败,且完整执行后 duration 也超过阈值
- **THEN** 系统 SHALL 返回 body 阶段的失败首个失败为准durationMs SHALL 记录完整耗时
#### Scenario: HTTP 慢响应体计入耗时
- **WHEN** HTTP target 配置了 body 校验和 `expect.maxDurationMs`,且响应头很快返回但响应体读取导致完整执行耗时超过阈值
- **WHEN** HTTP target 配置了 body 校验和 `expect.durationMs: {lte: 1000}`,且响应头很快返回但响应体读取导致完整执行耗时超过阈值
- **THEN** 系统 SHALL 判定 duration 不匹配并记录完整 durationMs
#### Scenario: 多条 expect 规则
- **WHEN** 目标同时配置状态、duration、元数据和内容规则
- **THEN** 系统 SHALL 所有规则全部通过时 matched 为 true任一不通过则为 false 并记录首个失败原因
- **WHEN** 目标同时配置状态、duration、元数据和内容 expectations
- **THEN** 系统 SHALL 所有 expectations 全部通过时 matched 为 true任一不通过则为 false 并记录首个失败原因
#### Scenario: cmd 默认 exitCode
- **WHEN** cmd target 未配置 `expect.exitCode`
- **THEN** 系统 SHALL 默认 `exitCode: [0]` 校验命令退出码
- **THEN** 系统 SHALL 在 Resolved cmd expect 中物化默认 `exitCode: [0]` 并按该语义校验命令退出码
#### Scenario: 校验 cmd stdout
- **WHEN** cmd target 配置了有序 `expect.stdout` 规则数组
- **THEN** 系统 SHALL 按数组顺序执行 stdout 规则,任一失败立即记录 failure 并停止后续规则
- **WHEN** cmd target 配置了有序 `expect.stdout` ContentExpectations 数组
- **THEN** 系统 SHALL 按数组顺序执行 stdout expectations,任一失败立即记录 failure 并停止后续 expectation
### Requirement: Body 校验按需解析
系统 SHALL 仅在 HTTP target 配置了 body 校验,且 status、headers 阶段均通过,并且进入 body 前未确定 duration 已失败时才读取并解析响应体避免不必要的读取和解析开销。HTTP target 未配置 body 校验时,系统 SHALL NOT 读取 response body。
系统 SHALL 仅在 HTTP target 配置了 body 校验,且 status、headers 阶段均通过,并且进入 body 前未确定 `expect.durationMs` 已失败时才读取并解析响应体避免不必要的读取和解析开销。HTTP target 未配置 body 校验时,系统 SHALL NOT 读取 response body。仅当 Resolved `durationMs` 包含上界 matcher 且当前已耗时已经使其不可能通过时,系统 MAY 在读取 body 前返回 duration failure其他 duration matcher SHALL 在完整执行耗时可用后校验。
#### Scenario: status 失败时不读取 body
- **WHEN** HTTP target 的 status 阶段不匹配
@@ -186,17 +186,17 @@
- **WHEN** HTTP target 的 status 阶段匹配但 headers 阶段不匹配
- **THEN** 系统 SHALL 立即返回 matched=false且 MUST NOT 读取 response body
#### Scenario: 进入 body 前 duration 已失败时不读取 body
- **WHEN** HTTP target 已配置 `expect.maxDurationMs`,且进入 body 读取前的已耗时已经超过阈值
#### Scenario: 进入 body 前 durationMs 上界已失败时不读取 body
- **WHEN** HTTP target 已配置 `expect.durationMs` 上界 matcher,且进入 body 读取前的已耗时已经使该 matcher 不可能通过
- **THEN** 系统 SHALL 返回 duration failure且 MUST NOT 读取 response body
#### Scenario: 仅配置 contains 时不解析 JSON
- **WHEN** HTTP target 仅配置 body contains 规则而未配置 json/css/xpath 规则
- **WHEN** HTTP target 仅配置 body contains expectation 而未配置 json/css/xpath expectation
- **THEN** 系统 SHALL 不执行 JSON.parse 或 HTML/XML 解析
#### Scenario: 配置 json 时解析 JSON 失败
- **WHEN** HTTP target 配置了 body json 规则但响应体不是合法 JSON
- **THEN** 系统 SHALL 判定 matched 为 false并记录 json 规则对应的 failure.path
- **WHEN** HTTP target 配置了 body json expectation 但响应体不是合法 JSON
- **THEN** 系统 SHALL 判定 matched 为 false并记录 json expectation 对应的 failure.path
### Requirement: HTTP 运行期错误归属
HTTP checker SHALL 将运行期失败归属到实际失败阶段。请求、网络、TLS 和 timeout 错误 SHALL 记录为 request 阶段错误body 超限、响应体解码失败、响应内容解析失败 SHALL 记录为 body 阶段错误expect 不匹配 SHALL 记录为对应 mismatch 阶段。

View File

@@ -86,27 +86,27 @@
- **THEN** observation.banner SHALL 保存截断后的 banner 摘要API detail SHALL 展示截断后的 banner 摘要,避免 UI 展示过长文本
### Requirement: tcp expect 校验
系统 SHALL 支持 tcp 专属 expect包括 `connected``banner``durationMs`,并按 connected、banner、durationMs 的阶段顺序快速失败。`connected` SHALL 保持布尔状态语义,未配置时默认 `true``banner` MUST 使用共享 `ContentRules` 数组,并仅在 `tcp.readBanner: true` 时允许配置。`durationMs` SHALL 使用共享 `ValueMatcher` 校验包含连接和 banner 读取在内的完整执行耗时。
系统 SHALL 支持 tcp 专属 expect包括 `connected``banner``durationMs`,并按 connected、banner、durationMs 的阶段顺序快速失败。`connected` SHALL 保持布尔状态语义,未配置时在 Resolved expect 中默认 `true``banner` MUST 使用共享 `RawContentExpectations` 数组输入并在运行期使用 `ContentExpectations`,且仅在 `tcp.readBanner: true` 时允许配置。`durationMs` SHALL 使用共享 `RawValueExpectation` 输入并在运行期使用 `ValueExpectation` 校验包含连接和 banner 读取在内的完整执行耗时。
#### Scenario: 默认 connected 成功语义
- **WHEN** tcp target 未显式配置 `expect.connected`
- **THEN** 系统 SHALL 使用默认 `expect.connected: true` 进行校验
- **THEN** 系统 SHALL 在 Resolved tcp expect 中使用默认 `connected: true` 进行校验
#### Scenario: durationMs 校验
- **WHEN** tcp target 配置 `expect.durationMs: {lte: 100}`,且完整执行耗时超过 100ms
- **THEN** 系统 SHALL 返回 `matched=false`failure 的 phase 为 `duration`
#### Scenario: banner ContentRules 校验通过
#### Scenario: banner ContentExpectations 校验通过
- **WHEN** tcp target 配置 `readBanner: true``expect.banner: [{contains: "ESMTP"}]`,且实际 banner 包含 `ESMTP`
- **THEN** 系统 SHALL 判定 banner 阶段通过
#### Scenario: banner regex 校验失败
- **WHEN** tcp target 配置 `readBanner: true``expect.banner: [{regex: "^SSH-2\\.0"}]`,且实际 banner 不匹配该正则
- **THEN** 系统 SHALL 返回 `matched=false`failure 的 kind 为 `mismatch`phase 为 `banner`path 指向失败的 banner 规则
- **THEN** 系统 SHALL 返回 `matched=false`failure 的 kind 为 `mismatch`phase 为 `banner`path 指向失败的 banner expectation
#### Scenario: banner 多规则快速失败
- **WHEN** tcp target 配置两条 banner 规则且第一条失败
- **THEN** 系统 SHALL 返回第一条失败规则的 failure并 MUST NOT 执行第二条规则
- **WHEN** tcp target 配置两条 banner expectation 且第一条失败
- **THEN** 系统 SHALL 返回第一条失败 expectation 的 failure并 MUST NOT 执行第二条 expectation
#### Scenario: expect.banner 未开启 readBanner
- **WHEN** tcp target 配置 `expect.banner`,但 `tcp.readBanner` 未配置为 `true`

View File

@@ -133,13 +133,13 @@
- **THEN** 系统 SHALL 以配置错误退出,提示 maxResponseBytes 格式错误
### Requirement: udp expect 校验
系统 SHALL 支持 udp 专属 expect包括 `responded``response``responseSize``sourceHost``sourcePort``durationMs`,并按 responded、responseSize、response、sourceHost、sourcePort、durationMs 的阶段顺序快速失败。`responded` SHALL 保持布尔状态语义,未配置时默认 `true``response` MUST 使用共享 `ContentRules` 数组,并作用于按 `udp.responseEncoding` 转换后的响应文本。`responseSize``sourceHost``sourcePort``durationMs` SHALL 使用共享 `ValueMatcher`
系统 SHALL 支持 udp 专属 expect包括 `responded``response``responseSize``sourceHost``sourcePort``durationMs`,并按 responded、responseSize、response、sourceHost、sourcePort、durationMs 的阶段顺序快速失败。`responded` SHALL 保持布尔状态语义,未配置时在 Resolved expect 中默认 `true``response` MUST 使用共享 `RawContentExpectations` 数组输入并在运行期使用 `ContentExpectations`,且作用于按 `udp.responseEncoding` 转换后的响应文本。`responseSize``sourceHost``sourcePort``durationMs` SHALL 使用共享 `RawValueExpectation` 输入并在运行期使用 `ValueExpectation`
#### Scenario: 默认 responded 成功语义
- **WHEN** udp target 未显式配置 `expect.responded`
- **THEN** 系统 SHALL 使用默认 `expect.responded: true` 进行校验
- **THEN** 系统 SHALL 在 Resolved udp expect 中使用默认 `responded: true` 进行校验
#### Scenario: response ContentRules 校验通过
#### Scenario: response ContentExpectations 校验通过
- **WHEN** udp target 配置 `expect.response: [{ contains: "PONG" }]`,且按 `responseEncoding` 转换后的响应文本包含 `PONG`
- **THEN** 系统 SHALL 判定 response 阶段通过
@@ -147,13 +147,13 @@
- **WHEN** udp target 收到文本响应 `{"status":"ok"}` 且配置 `expect.response: [{json: {path: "$.status", equals: "ok"}}]`
- **THEN** 系统 SHALL 判定 response 阶段通过
#### Scenario: response ContentRules 校验失败
#### Scenario: response ContentExpectations 校验失败
- **WHEN** udp target 配置 `expect.response: [{ contains: "PONG" }]`,但按 `responseEncoding` 转换后的响应文本不包含 `PONG`
- **THEN** 系统 SHALL 返回 `matched=false`failure 的 kind 为 `mismatch`phase 为 `response`path 指向失败的 response 规则
- **THEN** 系统 SHALL 返回 `matched=false`failure 的 kind 为 `mismatch`phase 为 `response`path 指向失败的 response expectation
#### Scenario: responseEncoding 为 hex
- **WHEN** udp target 配置 `udp.responseEncoding: "hex"` 且收到字节内容 `PONG`
- **THEN** 系统 SHALL 将响应转换为小写 hex 字符串 `504f4e47` 后执行 `expect.response` 规则
- **THEN** 系统 SHALL 将响应转换为小写 hex 字符串 `504f4e47` 后执行 `expect.response` expectation
#### Scenario: responseSize matcher 校验通过
- **WHEN** udp target 配置 `expect.responseSize: { gte: 4 }`,且实际响应为 4 字节

View File

@@ -590,32 +590,6 @@
},
"headers": {
"additionalProperties": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"items": {},
"type": "array"
},
{
"additionalProperties": {},
"type": "object"
}
]
},
{
"anyOf": [
{
"anyOf": [
@@ -689,8 +663,6 @@
}
}
]
}
]
},
"type": "object"
},
@@ -1901,32 +1873,6 @@
"type": "array",
"items": {
"additionalProperties": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"items": {},
"type": "array"
},
{
"additionalProperties": {},
"type": "object"
}
]
},
{
"anyOf": [
{
"anyOf": [
@@ -2000,8 +1946,6 @@
}
}
]
}
]
},
"type": "object"
}
@@ -3702,32 +3646,6 @@
},
"headers": {
"additionalProperties": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"items": {},
"type": "array"
},
{
"additionalProperties": {},
"type": "object"
}
]
},
{
"anyOf": [
{
"anyOf": [
@@ -3801,8 +3719,6 @@
}
}
]
}
]
},
"type": "object"
},
@@ -4625,7 +4541,7 @@
"$id": "https://dial.local/probe-config.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ContentRules": {
"ContentExpectations": {
"type": "array",
"items": {
"additionalProperties": false,
@@ -4868,10 +4784,37 @@
}
}
},
"KeyValueExpect": {
"KeyedExpectations": {
"additionalProperties": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"additionalProperties": false,
"minProperties": 1,
"type": "object",
"properties": {
"contains": {
"type": "string"
},
"empty": {
"type": "boolean"
},
"equals": {
"anyOf": [
{
"type": "string"
@@ -4895,7 +4838,31 @@
}
]
},
{
"exists": {
"type": "boolean"
},
"gt": {
"type": "number"
},
"gte": {
"type": "number"
},
"lt": {
"type": "number"
},
"lte": {
"type": "number"
},
"regex": {
"type": "string"
}
}
}
]
},
"type": "object"
},
"ValueExpectation": {
"anyOf": [
{
"anyOf": [
@@ -4969,30 +4936,8 @@
}
}
]
}
]
},
"type": "object"
},
"ValueMatcher": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
{
"additionalProperties": false,
"minProperties": 1,
"type": "object",
@@ -5047,7 +4992,5 @@
}
}
}
]
}
}
}

View File

@@ -1,53 +1,67 @@
import { DOMParser } from "@xmldom/xmldom";
import * as cheerio from "cheerio";
import { isPlainObject } from "es-toolkit";
import * as xpath from "xpath";
import type { CheckFailure } from "../types";
import type {
ContentCssRule,
ContentJsonRule,
ContentRule,
ContentRules,
ContentXpathRule,
ExpectResult,
ContentCssExpectation,
ContentExpectation,
ContentExpectations,
ContentJsonExpectation,
ContentValueExpectation,
ContentXpathExpectation,
ExpectationResult,
RawContentCssExpectation,
RawContentExpectation,
RawContentExpectations,
RawContentJsonExpectation,
RawContentXpathExpectation,
ValueExpectation,
ValueMatcher,
} from "./types";
import { errorFailure, mismatchFailure } from "./failure";
import { applyMatcher, evaluateJsonPath } from "./matcher";
import { CONTENT_EXTRACTOR_KEY_SET, MATCHER_KEY_SET } from "./keys";
import { applyValueMatcher, displayValueExpectation, evaluateJsonPath } from "./value";
type ParsedJsonResult = { error: string; ok: false } | { ok: true; value: unknown };
export function checkContentRules(
export function checkContentExpectations(
source: unknown,
rules: ContentRules | undefined,
expectations: ContentExpectations | undefined,
options: { path?: string; phase: CheckFailure["phase"] },
): ExpectResult {
if (!rules || rules.length === 0) return { failure: null, matched: true };
): ExpectationResult {
if (!expectations || expectations.length === 0) return { failure: null, matched: true };
const basePath = options.path ?? options.phase;
let parsedJson: ParsedJsonResult | undefined;
for (let i = 0; i < rules.length; i++) {
const rule = rules[i]!;
if ("json" in rule && parsedJson === undefined) {
for (let i = 0; i < expectations.length; i++) {
const expectation = expectations[i]!;
if (expectation.kind === "json" && parsedJson === undefined) {
parsedJson = parseJsonSource(source);
}
const result = checkSingleContentRule(source, rule, `${basePath}[${i}]`, options.phase, parsedJson);
const result = checkSingleContentExpectation(source, expectation, `${basePath}[${i}]`, options.phase, parsedJson);
if (!result.matched) return result;
}
return { failure: null, matched: true };
}
function checkCssRule(
export function resolveContentExpectations(raw: RawContentExpectations | undefined): ContentExpectations | undefined {
if (raw === undefined) return undefined;
return raw.map((entry) => resolveContentExpectation(entry));
}
function checkCssExpectation(
source: unknown,
rule: ContentCssRule,
rulePath: string,
expectation: ContentCssExpectation,
expectationPath: string,
phase: CheckFailure["phase"],
): ExpectResult {
const { attr, selector, ...matcher } = rule;
const fullPath = `${rulePath}.css(${selector}${attr ? `@${attr}` : ""})`;
): ExpectationResult {
const fullPath = `${expectationPath}.css(${expectation.selector}${expectation.attr ? `@${expectation.attr}` : ""})`;
let $: cheerio.CheerioAPI;
try {
@@ -56,13 +70,18 @@ function checkCssRule(
return { failure: errorFailure(phase, fullPath, "failed to parse HTML"), matched: false };
}
const el = $(selector).first();
const actual = el.length === 0 ? undefined : attr ? el.attr(attr) : el.text();
const effectiveMatcher = Object.keys(matcher).length === 0 ? { exists: true } : matcher;
const el = $(expectation.selector).first();
const actual = el.length === 0 ? undefined : expectation.attr ? el.attr(expectation.attr) : el.text();
if (!applyMatcher(actual, effectiveMatcher)) {
if (!applyValueMatcher(actual, expectation.matcher)) {
return {
failure: mismatchFailure(phase, fullPath, effectiveMatcher, actual, `css selector ${selector} mismatch`),
failure: mismatchFailure(
phase,
fullPath,
displayValueExpectation(expectation.matcher),
actual,
`css selector ${expectation.selector} mismatch`,
),
matched: false,
};
}
@@ -70,24 +89,28 @@ function checkCssRule(
return { failure: null, matched: true };
}
function checkJsonRule(
rule: ContentJsonRule,
rulePath: string,
function checkJsonExpectation(
expectation: ContentJsonExpectation,
expectationPath: string,
phase: CheckFailure["phase"],
parsedJson?: ParsedJsonResult,
): ExpectResult {
const { path, ...matcher } = rule;
const fullPath = `${rulePath}.json(${path})`;
): ExpectationResult {
const fullPath = `${expectationPath}.json(${expectation.path})`;
if (!parsedJson?.ok) {
return { failure: errorFailure(phase, fullPath, parsedJson?.error ?? "content is not valid JSON"), matched: false };
}
const actual = evaluateJsonPath(parsedJson.value, path);
const effectiveMatcher = Object.keys(matcher).length === 0 ? { exists: true } : matcher;
if (!applyMatcher(actual, effectiveMatcher)) {
const actual = evaluateJsonPath(parsedJson.value, expectation.path);
if (!applyValueMatcher(actual, expectation.matcher)) {
return {
failure: mismatchFailure(phase, fullPath, effectiveMatcher, actual, `json path ${path} mismatch`),
failure: mismatchFailure(
phase,
fullPath,
displayValueExpectation(expectation.matcher),
actual,
`json path ${expectation.path} mismatch`,
),
matched: false,
};
}
@@ -95,34 +118,53 @@ function checkJsonRule(
return { failure: null, matched: true };
}
function checkSingleContentRule(
function checkSingleContentExpectation(
source: unknown,
rule: ContentRule,
rulePath: string,
expectation: ContentExpectation,
expectationPath: string,
phase: CheckFailure["phase"],
parsedJson?: ParsedJsonResult,
): ExpectResult {
if ("json" in rule) return checkJsonRule(rule.json, rulePath, phase, parsedJson);
if ("css" in rule) return checkCssRule(source, rule.css, rulePath, phase);
if ("xpath" in rule) return checkXpathRule(source, rule.xpath, rulePath, phase);
): ExpectationResult {
switch (expectation.kind) {
case "css":
return checkCssExpectation(source, expectation, expectationPath, phase);
case "json":
return checkJsonExpectation(expectation, expectationPath, phase, parsedJson);
case "value":
return checkValueContentExpectation(source, expectation, expectationPath, phase);
case "xpath":
return checkXpathExpectation(source, expectation, expectationPath, phase);
}
}
if (!applyMatcher(source, rule, { stringifyNonString: true })) {
function checkValueContentExpectation(
source: unknown,
expectation: ContentValueExpectation,
expectationPath: string,
phase: CheckFailure["phase"],
): ExpectationResult {
if (!applyValueMatcher(source, expectation.matcher, { stringifyNonString: true })) {
return {
failure: mismatchFailure(phase, rulePath, rule, source, `${phase} rule mismatch`),
failure: mismatchFailure(
phase,
expectationPath,
displayValueExpectation(expectation.matcher),
source,
`${phase} expectation mismatch`,
),
matched: false,
};
}
return { failure: null, matched: true };
}
function checkXpathRule(
function checkXpathExpectation(
source: unknown,
rule: ContentXpathRule,
rulePath: string,
expectation: ContentXpathExpectation,
expectationPath: string,
phase: CheckFailure["phase"],
): ExpectResult {
const { path, ...matcher } = rule;
const fullPath = `${rulePath}.xpath(${path})`;
): ExpectationResult {
const fullPath = `${expectationPath}.xpath(${expectation.path})`;
let doc: ReturnType<DOMParser["parseFromString"]>;
try {
@@ -131,13 +173,18 @@ function checkXpathRule(
return { failure: errorFailure(phase, fullPath, "failed to parse XML/HTML"), matched: false };
}
const result = xpath.select(path, doc as unknown as Node);
const result = xpath.select(expectation.path, doc as unknown as Node);
const actual = xpathValue(result);
const effectiveMatcher = Object.keys(matcher).length === 0 ? { exists: true } : matcher;
if (!applyMatcher(actual, effectiveMatcher)) {
if (!applyValueMatcher(actual, expectation.matcher)) {
return {
failure: mismatchFailure(phase, fullPath, effectiveMatcher, actual, `xpath ${path} mismatch`),
failure: mismatchFailure(
phase,
fullPath,
displayValueExpectation(expectation.matcher),
actual,
`xpath ${expectation.path} mismatch`,
),
matched: false,
};
}
@@ -154,6 +201,28 @@ function contentText(source: unknown): string {
return JSON.stringify(source) ?? "";
}
function extractDirectMatcher(raw: Record<string, unknown>): ValueMatcher {
const matcher: ValueMatcher = {};
for (const [key, value] of Object.entries(raw)) {
if (MATCHER_KEY_SET.has(key) && value !== undefined) {
(matcher as Record<string, unknown>)[key] = value;
}
}
return matcher;
}
function extractExtractorMatcher(raw: Record<string, unknown>, ownFields: ReadonlySet<string>): ValueExpectation {
const matcher: ValueMatcher = {};
for (const [key, value] of Object.entries(raw)) {
if (ownFields.has(key)) continue;
if (MATCHER_KEY_SET.has(key) && value !== undefined) {
(matcher as Record<string, unknown>)[key] = value;
}
}
if (Object.keys(matcher).length === 0) return { exists: true };
return matcher;
}
function parseJsonSource(source: unknown): ParsedJsonResult {
if (typeof source !== "string") return { ok: true, value: source };
try {
@@ -163,6 +232,44 @@ function parseJsonSource(source: unknown): ParsedJsonResult {
}
}
function resolveContentExpectation(raw: RawContentExpectation): ContentExpectation {
if (!isPlainObject(raw)) {
return { kind: "value", matcher: { equals: raw } };
}
const record = raw as Record<string, unknown>;
if (CONTENT_EXTRACTOR_KEY_SET.has("json") && isPlainObject(record["json"])) {
const json = record["json"] as RawContentJsonExpectation;
return {
kind: "json",
matcher: extractExtractorMatcher(json as unknown as Record<string, unknown>, new Set(["path"])),
path: json.path,
};
}
if (isPlainObject(record["css"])) {
const css = record["css"] as RawContentCssExpectation;
const expectation: ContentCssExpectation = {
kind: "css",
matcher: extractExtractorMatcher(css as unknown as Record<string, unknown>, new Set(["attr", "selector"])),
selector: css.selector,
};
if (css.attr !== undefined) expectation.attr = css.attr;
return expectation;
}
if (isPlainObject(record["xpath"])) {
const xpathExpectation = record["xpath"] as RawContentXpathExpectation;
return {
kind: "xpath",
matcher: extractExtractorMatcher(xpathExpectation as unknown as Record<string, unknown>, new Set(["path"])),
path: xpathExpectation.path,
};
}
return { kind: "value", matcher: extractDirectMatcher(record) };
}
function xpathValue(result: unknown): unknown {
if (!Array.isArray(result)) return result;
if (result.length === 0) return undefined;

View File

@@ -0,0 +1,14 @@
import type { ExpectationResult, KeyedExpectations } from "./types";
import { checkKeyedExpectations } from "./keyed";
export function checkHeaderExpectations(
headers: Record<string, unknown>,
expectations?: KeyedExpectations,
): ExpectationResult {
return checkKeyedExpectations(headers, expectations, {
normalizeKey: (key) => key.toLowerCase(),
path: "headers",
phase: "headers",
});
}

View File

@@ -1,32 +0,0 @@
import type { CheckFailure } from "../types";
import type { ExpectResult, KeyValueExpect } from "./types";
import { mismatchFailure } from "./failure";
import { checkExpectValue } from "./matcher";
export function checkKeyValueExpect(
actual: Record<string, unknown>,
expected: KeyValueExpect | undefined,
options: { normalizeKey?: (key: string) => string; path?: string; phase: CheckFailure["phase"] },
): ExpectResult {
if (!expected) return { failure: null, matched: true };
const normalizeKey = options.normalizeKey ?? ((key: string) => key);
const basePath = options.path ?? options.phase;
const actualMap = new Map<string, unknown>();
for (const [key, value] of Object.entries(actual)) {
actualMap.set(normalizeKey(key), value);
}
for (const [key, expectedValue] of Object.entries(expected)) {
const actualValue = actualMap.get(normalizeKey(key));
if (!checkExpectValue(actualValue, expectedValue)) {
return {
failure: mismatchFailure(options.phase, `${basePath}.${key}`, expectedValue, actualValue, `${key} mismatch`),
matched: false,
};
}
}
return { failure: null, matched: true };
}

View File

@@ -0,0 +1,46 @@
import type { CheckFailure } from "../types";
import type { ExpectationResult, KeyedExpectations, RawKeyedExpectations } from "./types";
import { mismatchFailure } from "./failure";
import { applyValueMatcher, displayValueExpectation, resolveValueExpectation } from "./value";
export function checkKeyedExpectations(
actual: Record<string, unknown>,
expectations: KeyedExpectations | undefined,
options: { normalizeKey?: (key: string) => string; path?: string; phase: CheckFailure["phase"] },
): ExpectationResult {
if (!expectations || expectations.length === 0) return { failure: null, matched: true };
const normalizeKey = options.normalizeKey ?? ((key: string) => key);
const basePath = options.path ?? options.phase;
const actualMap = new Map<string, unknown>();
for (const [key, value] of Object.entries(actual)) {
actualMap.set(normalizeKey(key), value);
}
for (const expectation of expectations) {
const actualValue = actualMap.get(normalizeKey(expectation.key));
if (!applyValueMatcher(actualValue, expectation.matcher)) {
return {
failure: mismatchFailure(
options.phase,
`${basePath}.${expectation.key}`,
displayValueExpectation(expectation.matcher),
actualValue,
`${expectation.key} mismatch`,
),
matched: false,
};
}
}
return { failure: null, matched: true };
}
export function resolveKeyedExpectations(raw: RawKeyedExpectations | undefined): KeyedExpectations | undefined {
if (raw === undefined) return undefined;
return Object.entries(raw).map(([key, value]) => ({
key,
matcher: resolveValueExpectation(value),
}));
}

View File

@@ -0,0 +1,7 @@
export const MatcherKeys = ["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "regex"] as const;
export const MATCHER_KEY_SET: ReadonlySet<string> = new Set<string>(MatcherKeys);
export const ContentExtractorKeys = ["css", "json", "xpath"] as const;
export const CONTENT_EXTRACTOR_KEY_SET: ReadonlySet<string> = new Set<string>(ContentExtractorKeys);

View File

@@ -1,22 +0,0 @@
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

@@ -0,0 +1,27 @@
import { isNumber } from "es-toolkit";
import type { ExpectationResult } from "./types";
import { mismatchFailure } from "./failure";
export function checkStatusCode(statusCode: number, allowed: Array<number | string>): ExpectationResult {
const matched = allowed.some((pattern) => {
if (isNumber(pattern)) return statusCode === pattern;
const base = parseInt(pattern[0]!, 10) * 100;
return statusCode >= base && statusCode < base + 100;
});
if (!matched) {
return {
failure: mismatchFailure(
"status",
"status",
allowed,
statusCode,
`status ${statusCode} not in [${allowed.join(", ")}]`,
),
matched: false,
};
}
return { failure: null, matched: true };
}

View File

@@ -1,32 +1,75 @@
import type { CheckFailure, JsonValue } from "../types";
export interface ContentCssRule extends ValueMatcher {
export interface ContentCssExpectation {
attr?: string;
kind: "css";
matcher: ValueExpectation;
selector: string;
}
export interface ContentJsonRule extends ValueMatcher {
export type ContentExpectation =
| ContentCssExpectation
| ContentJsonExpectation
| ContentValueExpectation
| ContentXpathExpectation;
export type ContentExpectations = ContentExpectation[];
export interface ContentJsonExpectation {
kind: "json";
matcher: ValueExpectation;
path: string;
}
export type ContentRule =
| ValueMatcher
| { css: ContentCssRule }
| { json: ContentJsonRule }
| { xpath: ContentXpathRule };
export interface ContentValueExpectation {
kind: "value";
matcher: ValueExpectation;
}
export type ContentRules = ContentRule[];
export interface ContentXpathRule extends ValueMatcher {
export interface ContentXpathExpectation {
kind: "xpath";
matcher: ValueExpectation;
path: string;
}
export interface ExpectResult {
export interface ExpectationResult {
failure: CheckFailure | null;
matched: boolean;
}
export type KeyValueExpect = Record<string, JsonValue | ValueMatcher>;
export interface KeyedExpectation {
key: string;
matcher: ValueExpectation;
}
export type KeyedExpectations = KeyedExpectation[];
export interface RawContentCssExpectation extends ValueMatcher {
attr?: string;
selector: string;
}
export type RawContentExpectation =
| ValueMatcher
| { css: RawContentCssExpectation }
| { json: RawContentJsonExpectation }
| { xpath: RawContentXpathExpectation };
export type RawContentExpectations = RawContentExpectation[];
export interface RawContentJsonExpectation extends ValueMatcher {
path: string;
}
export interface RawContentXpathExpectation extends ValueMatcher {
path: string;
}
export type RawKeyedExpectations = Record<string, RawValueExpectation>;
export type RawValueExpectation = ValueMatcher | ValueMatcherPrimitive;
export type ValueExpectation = ValueMatcher;
export interface ValueMatcher {
contains?: string;
@@ -40,6 +83,4 @@ export interface ValueMatcher {
regex?: string;
}
export type ValueMatcherInput = ValueMatcher | ValueMatcherPrimitive;
export type ValueMatcherPrimitive = boolean | null | number | string;

View File

@@ -6,14 +6,9 @@ import type { ConfigValidationIssue } from "../schema/issues";
import type { JsonValue } from "../types";
import { issue, joinPath } from "../schema/issues";
import { isValueMatcherPrimitive } from "./normalize";
import { CONTENT_EXTRACTOR_KEY_SET, MATCHER_KEY_SET } from "./keys";
import { isUnsafeRegex } from "./redos";
export const MatcherKeys = ["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "regex"] as const;
const MATCHER_KEY_SET = new Set<string>(MatcherKeys);
const EXTRACTOR_KEYS = ["css", "json", "xpath"] as const;
const EXTRACTOR_KEY_SET = new Set<string>(EXTRACTOR_KEYS);
import { isValueMatcherPrimitive } from "./value";
export function isJsonValue(value: unknown): value is JsonValue {
if (value === null) return true;
@@ -28,45 +23,64 @@ export function isPlainRecord(value: unknown): value is Record<string, unknown>
return isPlainObject(value);
}
export function validateContentRules(rules: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!Array.isArray(rules)) return [issue("invalid-type", path, "必须为数组", targetName)];
return rules.flatMap((rule, index) => validateContentRule(rule, `${path}[${index}]`, targetName));
}
export function validateJsonPath(path: string, rulePath: string, targetName?: string): ConfigValidationIssue[] {
export function validateJsonPath(path: string, expectationPath: string, targetName?: string): ConfigValidationIssue[] {
if (!path.startsWith("$.") || path.length <= 2) {
return [issue("invalid-jsonpath", joinPath(rulePath, "path"), '必须为以 "$." 开头的有效 JSONPath', targetName)];
return [
issue("invalid-jsonpath", joinPath(expectationPath, "path"), '必须为以 "$." 开头的有效 JSONPath', targetName),
];
}
const issues: ConfigValidationIssue[] = [];
const segments = path.slice(2).split(".");
for (const seg of segments) {
if (seg === "") {
issues.push(issue("invalid-jsonpath", joinPath(rulePath, "path"), "包含空段", targetName));
issues.push(issue("invalid-jsonpath", joinPath(expectationPath, "path"), "包含空段", targetName));
}
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
if (bracketMatch?.[1]!.trim() === "") {
issues.push(issue("invalid-jsonpath", joinPath(rulePath, "path"), "数组访问缺少属性名", targetName));
issues.push(issue("invalid-jsonpath", joinPath(expectationPath, "path"), "数组访问缺少属性名", targetName));
}
}
return issues;
}
export function validateKeyValueExpect(value: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
export function validateRawContentExpectations(
expectations: unknown,
path: string,
targetName?: string,
): ConfigValidationIssue[] {
if (!Array.isArray(expectations)) return [issue("invalid-type", path, "必须为数组", targetName)];
return expectations.flatMap((entry, index) => validateRawContentExpectation(entry, `${path}[${index}]`, targetName));
}
export function validateRawKeyedExpectations(
value: unknown,
path: string,
targetName?: string,
options?: { caseInsensitive?: boolean },
): ConfigValidationIssue[] {
if (!isPlainRecord(value)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (options?.caseInsensitive) {
const seen = new Map<string, string>();
for (const key of Object.keys(value)) {
const lower = key.toLowerCase();
const prev = seen.get(lower);
if (prev !== undefined) {
issues.push(issue("duplicate-key", joinPath(path, key), `与 "${prev}" 大小写归一化后重复`, targetName));
} else {
seen.set(lower, key);
}
}
}
for (const [key, item] of Object.entries(value)) {
const itemPath = joinPath(path, key);
if (isPlainRecord(item)) {
issues.push(...validateValueMatcher(item, itemPath, targetName));
} else if (!isJsonValue(item)) {
issues.push(issue("invalid-type", itemPath, "必须为 JSON value 或 matcher 对象", targetName));
}
issues.push(...validateRawValueExpectation(item, itemPath, targetName));
}
return issues;
}
export function validateValueMatcher(
export function validateRawValueExpectation(
matcher: unknown,
path: string,
targetName?: string,
@@ -74,6 +88,16 @@ export function validateValueMatcher(
): ConfigValidationIssue[] {
const requireAtLeastOne = options.requireAtLeastOne ?? true;
if (isValueMatcherPrimitive(matcher)) return [];
if (Array.isArray(matcher)) {
return [
issue(
"invalid-type",
path,
"必须为 primitive 原始值或 matcher 对象;如需数组 equals 匹配应写成 {equals: [...]}",
targetName,
),
];
}
if (!isPlainRecord(matcher))
return [issue("invalid-type", path, "必须为 primitive 原始值或 matcher 对象", targetName)];
@@ -100,82 +124,46 @@ export function validateValueMatcher(
return issues;
}
function validateContentRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
const extractors = Object.keys(rule).filter((key) => EXTRACTOR_KEY_SET.has(key));
const directMatchers = Object.keys(rule).filter((key) => MATCHER_KEY_SET.has(key));
for (const key of Object.keys(rule)) {
if (!MATCHER_KEY_SET.has(key) && !EXTRACTOR_KEY_SET.has(key)) {
issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName));
}
}
if (extractors.length > 1) {
issues.push(issue("multiple-content-rules", path, "一条规则不能同时包含多个 extractor", targetName));
}
if (extractors.length === 1 && directMatchers.length > 0) {
issues.push(issue("invalid-content-rule", path, "直接 matcher 不能与 extractor 混用", targetName));
}
if (issues.length > 0) return issues;
if (extractors.length === 0) return validateValueMatcher(rule, path, targetName);
const extractor = extractors[0]!;
switch (extractor) {
case "css":
return validateCssRule(rule["css"], joinPath(path, "css"), targetName);
case "json":
return validateJsonRule(rule["json"], joinPath(path, "json"), targetName);
case "xpath":
return validateXpathRule(rule["xpath"], joinPath(path, "xpath"), targetName);
}
return [];
}
function validateCssRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
function validateCssExpectation(expectation: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(expectation)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (!isString(rule["selector"]) || rule["selector"].trim() === "") {
if (!isString(expectation["selector"]) || expectation["selector"].trim() === "") {
issues.push(issue("invalid-type", joinPath(path, "selector"), "必须为非空字符串", targetName));
}
if ("attr" in rule && !isString(rule["attr"])) {
if ("attr" in expectation && !isString(expectation["attr"])) {
issues.push(issue("invalid-type", joinPath(path, "attr"), "必须为字符串", targetName));
}
issues.push(...validateExtractorMatcher(rule, new Set(["attr", "selector"]), path, targetName));
issues.push(...validateExtractorMatcher(expectation, new Set(["attr", "selector"]), path, targetName));
return issues;
}
function validateExtractorMatcher(
rule: Record<string, unknown>,
expectation: Record<string, unknown>,
allowedFields: Set<string>,
path: string,
targetName?: string,
): ConfigValidationIssue[] {
const matcher: Record<string, unknown> = {};
const issues: ConfigValidationIssue[] = [];
for (const [key, value] of Object.entries(rule)) {
for (const [key, value] of Object.entries(expectation)) {
if (allowedFields.has(key)) continue;
matcher[key] = value;
}
issues.push(...validateValueMatcher(matcher, path, targetName, { requireAtLeastOne: false }));
issues.push(...validateRawValueExpectation(matcher, path, targetName, { requireAtLeastOne: false }));
return issues;
}
function validateJsonRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
function validateJsonExpectation(expectation: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(expectation)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (!isString(rule["path"])) {
if (!isString(expectation["path"])) {
issues.push(issue("invalid-type", joinPath(path, "path"), "必须为字符串", targetName));
} else {
issues.push(...validateJsonPath(rule["path"], path, targetName));
issues.push(...validateJsonPath(expectation["path"], path, targetName));
}
issues.push(...validateExtractorMatcher(rule, new Set(["path"]), path, targetName));
issues.push(...validateExtractorMatcher(expectation, new Set(["path"]), path, targetName));
return issues;
}
@@ -208,20 +196,62 @@ function validateMatcherValue(key: string, value: unknown, path: string, targetN
}
}
function validateXpathRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
function validateRawContentExpectation(
expectation: unknown,
path: string,
targetName?: string,
): ConfigValidationIssue[] {
if (!isPlainRecord(expectation)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
const extractors = Object.keys(expectation).filter((key) => CONTENT_EXTRACTOR_KEY_SET.has(key));
const directMatchers = Object.keys(expectation).filter((key) => MATCHER_KEY_SET.has(key));
for (const key of Object.keys(expectation)) {
if (!MATCHER_KEY_SET.has(key) && !CONTENT_EXTRACTOR_KEY_SET.has(key)) {
issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName));
}
}
if (extractors.length > 1) {
issues.push(
issue("multiple-content-expectations", path, "一条 expectation 不能同时包含多个 extractor", targetName),
);
}
if (extractors.length === 1 && directMatchers.length > 0) {
issues.push(issue("invalid-content-expectation", path, "直接 matcher 不能与 extractor 混用", targetName));
}
if (issues.length > 0) return issues;
if (extractors.length === 0) return validateRawValueExpectation(expectation, path, targetName);
const extractor = extractors[0]!;
switch (extractor) {
case "css":
return validateCssExpectation(expectation["css"], joinPath(path, "css"), targetName);
case "json":
return validateJsonExpectation(expectation["json"], joinPath(path, "json"), targetName);
case "xpath":
return validateXpathExpectation(expectation["xpath"], joinPath(path, "xpath"), targetName);
}
return [];
}
function validateXpathExpectation(expectation: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(expectation)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (!isString(rule["path"]) || rule["path"].trim() === "") {
if (!isString(expectation["path"]) || expectation["path"].trim() === "") {
issues.push(issue("invalid-type", joinPath(path, "path"), "必须为非空字符串", targetName));
} else {
try {
const doc = new DOMParser().parseFromString("<x/>", "text/xml");
xpath.select(rule["path"], doc as unknown as Node);
xpath.select(expectation["path"], doc as unknown as Node);
} catch {
issues.push(issue("invalid-xpath", joinPath(path, "path"), "xpath 不合法", targetName));
}
}
issues.push(...validateExtractorMatcher(rule, new Set(["path"]), path, targetName));
issues.push(...validateExtractorMatcher(expectation, new Set(["path"]), path, targetName));
return issues;
}

View File

@@ -1,15 +1,12 @@
import { isEmptyObject, isEqual, isNil, isPlainObject } from "es-toolkit";
import type { CheckFailure, JsonValue } from "../types";
import type { ExpectResult, ValueMatcher, ValueMatcherInput } from "./types";
import type { CheckFailure } from "../types";
import type { ExpectationResult, RawValueExpectation, ValueExpectation, ValueMatcher } from "./types";
import { mismatchFailure } from "./failure";
import { MATCHER_KEY_SET } from "./keys";
export const MatcherKeys = ["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "regex"] as const;
const MATCHER_KEY_SET = new Set<string>(MatcherKeys);
export function applyMatcher(
export function applyValueMatcher(
actual: unknown,
matcher: ValueMatcher,
options: { stringifyNonString?: boolean } = {},
@@ -57,28 +54,20 @@ export function applyMatcher(
return true;
}
export function checkExpectValue(actual: unknown, expected: JsonValue | ValueMatcher): boolean {
if (isValueMatcherObject(expected)) {
return applyMatcher(actual, expected);
}
return applyMatcher(actual, { equals: expected });
}
export function checkValueMatcher(
export function checkValueExpectation(
actual: unknown,
matcher: undefined | ValueMatcherInput,
expectation: undefined | ValueExpectation,
options: { message?: string; path: string; phase: CheckFailure["phase"]; stringifyNonString?: boolean },
): ExpectResult {
if (matcher === undefined) return { failure: null, matched: true };
const normalized = isValueMatcherObject(matcher) ? matcher : { equals: matcher };
if (applyMatcher(actual, normalized, { stringifyNonString: options.stringifyNonString })) {
): ExpectationResult {
if (expectation === undefined) return { failure: null, matched: true };
if (applyValueMatcher(actual, expectation, { stringifyNonString: options.stringifyNonString })) {
return { failure: null, matched: true };
}
return {
failure: mismatchFailure(
options.phase,
options.path,
normalized,
displayValueExpectation(expectation),
actual,
options.message ?? `${options.path} mismatch`,
),
@@ -86,6 +75,12 @@ export function checkValueMatcher(
};
}
export function displayValueExpectation(expectation: ValueExpectation): unknown {
const entries = Object.entries(expectation).filter(([, value]) => value !== undefined);
if (entries.length === 1 && entries[0]?.[0] === "equals") return entries[0][1];
return expectation;
}
export function evaluateJsonPath(json: unknown, path: string): unknown {
if (!path.startsWith("$.")) return undefined;
@@ -113,6 +108,18 @@ export function isValueMatcherObject(value: unknown): value is ValueMatcher {
return isPlainObject(value) && Object.keys(value).some((key) => MATCHER_KEY_SET.has(key));
}
export function isValueMatcherPrimitive(value: unknown): value is boolean | null | number | string {
return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
}
export function resolveValueExpectation(raw: RawValueExpectation): ValueExpectation;
export function resolveValueExpectation(raw: RawValueExpectation | undefined): undefined | ValueExpectation;
export function resolveValueExpectation(raw: RawValueExpectation | undefined): undefined | ValueExpectation {
if (raw === undefined) return undefined;
if (isValueMatcherObject(raw)) return raw;
return { equals: raw };
}
function compareNumber(
actual: unknown,
expected: number,

View File

@@ -3,11 +3,16 @@ import { resolve } from "node:path";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { CommandExpectConfig, CommandTargetConfig, ResolvedCommandTarget } from "./types";
import type {
CommandTargetConfig,
RawCommandExpectConfig,
ResolvedCommandExpectConfig,
ResolvedCommandTarget,
} from "./types";
import { checkContentRules } from "../../expect/content";
import { checkContentExpectations, resolveContentExpectations } from "../../expect/content";
import { errorFailure } from "../../expect/failure";
import { checkValueMatcher } from "../../expect/matcher";
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value";
import { parseSize } from "../../utils";
import { checkExitCode } from "./expect";
import { commandCheckerSchemas } from "./schema";
@@ -138,7 +143,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
};
}
const durationResult = checkValueMatcher(durationMs, t.expect?.durationMs, {
const durationResult = checkValueExpectation(durationMs, t.expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
@@ -156,7 +161,10 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
}
if (t.expect?.stdout && t.expect.stdout.length > 0) {
const stdoutResult = checkContentRules(outputResult.stdout, t.expect.stdout, { path: "stdout", phase: "stdout" });
const stdoutResult = checkContentExpectations(outputResult.stdout, t.expect.stdout, {
path: "stdout",
phase: "stdout",
});
if (!stdoutResult.matched) {
return {
detail: null,
@@ -171,7 +179,10 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
}
if (t.expect?.stderr && t.expect.stderr.length > 0) {
const stderrResult = checkContentRules(outputResult.stderr, t.expect.stderr, { path: "stderr", phase: "stderr" });
const stderrResult = checkContentExpectations(outputResult.stderr, t.expect.stderr, {
path: "stderr",
phase: "stderr",
});
if (!stderrResult.matched) {
return {
detail: null,
@@ -207,6 +218,16 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
const env = { ...process.env, ...(t.cmd.env ?? {}) } as Record<string, string>;
const rawExpect = target.expect as RawCommandExpectConfig | undefined;
const resolvedExpect: ResolvedCommandExpectConfig = rawExpect
? {
durationMs: resolveValueExpectation(rawExpect.durationMs),
exitCode: rawExpect.exitCode ?? [0],
stderr: resolveContentExpectations(rawExpect.stderr),
stdout: resolveContentExpectations(rawExpect.stdout),
}
: { exitCode: [0] };
return {
cmd: {
args: t.cmd.args ?? [],
@@ -216,11 +237,12 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
maxOutputBytes,
},
description: null,
expect: target.expect as CommandExpectConfig | undefined,
expect: resolvedExpect,
group: target.group ?? "default",
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? null,
rawExpect,
timeoutMs: context.defaultTimeoutMs,
type: "cmd",
} satisfies ResolvedCommandTarget;

View File

@@ -1,8 +1,8 @@
import type { ExpectResult } from "../../expect/types";
import type { ExpectationResult } from "../../expect/types";
import { mismatchFailure } from "../../expect/failure";
export function checkExitCode(exitCode: number, allowed: number[]): ExpectResult {
export function checkExitCode(exitCode: number, allowed: number[]): ExpectationResult {
if (!allowed.includes(exitCode)) {
return {
failure: mismatchFailure(

View File

@@ -3,8 +3,8 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import {
createContentRulesSchema,
createValueMatcherSchema,
createRawContentExpectationsSchema,
createRawValueExpectationSchema,
sizeSchema,
stringMapSchema,
} from "../../schema/fragments";
@@ -29,10 +29,10 @@ export const commandCheckerSchemas: CheckerSchemas = {
),
expect: Type.Object(
{
durationMs: Type.Optional(createValueMatcherSchema()),
durationMs: Type.Optional(createRawValueExpectationSchema()),
exitCode: Type.Optional(Type.Array(Type.Integer())),
stderr: Type.Optional(createContentRulesSchema()),
stdout: Type.Optional(createContentRulesSchema()),
stderr: Type.Optional(createRawContentExpectationsSchema()),
stdout: Type.Optional(createRawContentExpectationsSchema()),
},
{ additionalProperties: false },
),

View File

@@ -1,4 +1,9 @@
import type { ContentRules, ValueMatcherInput } from "../../expect/types";
import type {
ContentExpectations,
RawContentExpectations,
RawValueExpectation,
ValueExpectation,
} from "../../expect/types";
import type { ResolvedTargetBase } from "../../types";
export interface CommandDefaultsConfig {
@@ -6,13 +11,6 @@ export interface CommandDefaultsConfig {
maxOutputBytes?: string;
}
export interface CommandExpectConfig {
durationMs?: ValueMatcherInput;
exitCode?: number[];
stderr?: ContentRules;
stdout?: ContentRules;
}
export interface CommandTargetConfig {
args?: string[];
cwd?: string;
@@ -21,6 +19,13 @@ export interface CommandTargetConfig {
maxOutputBytes?: string;
}
export interface RawCommandExpectConfig {
durationMs?: RawValueExpectation;
exitCode?: number[];
stderr?: RawContentExpectations;
stdout?: RawContentExpectations;
}
export interface ResolvedCommandConfig {
args: string[];
cwd: string;
@@ -29,12 +34,20 @@ export interface ResolvedCommandConfig {
maxOutputBytes: number;
}
export interface ResolvedCommandExpectConfig {
durationMs?: ValueExpectation;
exitCode: number[];
stderr?: ContentExpectations;
stdout?: ContentExpectations;
}
export interface ResolvedCommandTarget extends ResolvedTargetBase {
cmd: ResolvedCommandConfig;
expect?: CommandExpectConfig;
expect?: ResolvedCommandExpectConfig;
group: string;
intervalMs: number;
name: null | string;
rawExpect?: RawCommandExpectConfig;
timeoutMs: number;
type: "cmd";
}

View File

@@ -1,17 +1,16 @@
import { isNumber, isPlainObject, isString } from "es-toolkit";
import { isNumber, 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 { isPlainRecord, validateRawContentExpectations, validateRawValueExpectation } from "../../expect/validate";
import { issue, joinPath } from "../../schema/issues";
import { parseSize } from "../../utils";
export function validateCommandConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const defaults =
isPlainObject(input.defaults) && isPlainObject(input.defaults["cmd"]) ? input.defaults["cmd"] : undefined;
isPlainRecord(input.defaults) && isPlainRecord(input.defaults["cmd"]) ? input.defaults["cmd"] : undefined;
if (isSizeInput(defaults?.["maxOutputBytes"])) {
issues.push(...validateSizeValue(defaults["maxOutputBytes"], "defaults.cmd.maxOutputBytes"));
@@ -19,7 +18,7 @@ export function validateCommandConfig(input: CheckerValidationInput): ConfigVali
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isPlainObject(target)) continue;
if (!isPlainRecord(target)) continue;
if (target["type"] !== "cmd") continue;
issues.push(...validateCommandTarget(target, `targets[${i}]`));
}
@@ -39,20 +38,18 @@ function isSizeInput(value: unknown): value is number | string {
function validateCommandExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const targetName = getTargetName(target);
const expect = target["expect"];
if (expect === undefined || expect === null || !isPlainObject(expect)) return [];
if (expect === undefined || expect === null || !isPlainRecord(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));
issues.push(...validateRawContentExpectations(expect["stdout"], joinPath(expectPath, "stdout"), targetName));
}
if (expect["stderr"] !== undefined) {
issues.push(...validateContentRules(expect["stderr"], joinPath(expectPath, "stderr"), targetName));
issues.push(...validateRawContentExpectations(expect["stderr"], joinPath(expectPath, "stderr"), targetName));
}
if (expect["durationMs"] !== undefined) {
issues.push(...validateValueMatcher(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
issues.push(...validateRawValueExpectation(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
}
return issues;
}
@@ -61,7 +58,7 @@ function validateCommandTarget(target: Record<string, unknown>, path: string): C
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const cmd = target["cmd"];
if (!isPlainObject(cmd)) {
if (!isPlainRecord(cmd)) {
issues.push(issue("required", joinPath(path, "cmd"), "缺少 cmd.exec 字段", targetName));
issues.push(...validateCommandExpect(target, path));
return issues;

View File

@@ -3,11 +3,12 @@ import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { DbExpectConfig, DbTargetConfig, ResolvedDbTarget } from "./types";
import type { DbTargetConfig, RawDbExpectConfig, ResolvedDbExpectConfig, ResolvedDbTarget } from "./types";
import { checkContentRules } from "../../expect/content";
import { checkContentExpectations, resolveContentExpectations } from "../../expect/content";
import { errorFailure } from "../../expect/failure";
import { checkValueMatcher } from "../../expect/matcher";
import { resolveKeyedExpectations } from "../../expect/keyed";
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value";
import { checkRowCount, checkRows } from "./expect";
import { dbCheckerSchemas } from "./schema";
import { validateDbConfig } from "./validate";
@@ -77,7 +78,7 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
rowCount: null,
rowsPreview: null,
};
const durationResult = checkValueMatcher(durationMs, t.expect?.durationMs, {
const durationResult = checkValueExpectation(durationMs, t.expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
@@ -138,7 +139,7 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
};
}
const durationResult = checkValueMatcher(durationMs, t.expect?.durationMs, {
const durationResult = checkValueExpectation(durationMs, t.expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
@@ -186,7 +187,10 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
}
if (t.expect?.result && t.expect.result.length > 0) {
const resultCheck = checkContentRules({ rowCount, rows }, t.expect.result, { path: "result", phase: "result" });
const resultCheck = checkContentExpectations({ rowCount, rows }, t.expect.result, {
path: "result",
phase: "result",
});
if (!resultCheck.matched) {
return {
detail: null,
@@ -223,17 +227,28 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedDbTarget {
const t = target as RawTargetConfig & { db: DbTargetConfig; type: "db" };
const rawExpect = target.expect as RawDbExpectConfig | undefined;
const resolvedExpect: ResolvedDbExpectConfig | undefined = rawExpect
? {
durationMs: resolveValueExpectation(rawExpect.durationMs),
result: resolveContentExpectations(rawExpect.result),
rowCount: resolveValueExpectation(rawExpect.rowCount),
rows: rawExpect.rows?.map((r) => resolveKeyedExpectations(r)!),
}
: undefined;
return {
db: {
query: t.db.query,
url: t.db.url,
},
description: null,
expect: target.expect as DbExpectConfig | undefined,
expect: resolvedExpect,
group: target.group ?? "default",
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? null,
rawExpect,
timeoutMs: context.defaultTimeoutMs,
type: "db",
} satisfies ResolvedDbTarget;

View File

@@ -1,20 +1,20 @@
import { isPlainObject } from "es-toolkit";
import type { ExpectResult, KeyValueExpect, ValueMatcherInput } from "../../expect/types";
import type { ExpectationResult, KeyedExpectations, ValueExpectation } from "../../expect/types";
import { mismatchFailure } from "../../expect/failure";
import { checkKeyValueExpect } from "../../expect/key-value";
import { checkValueMatcher } from "../../expect/matcher";
import { checkKeyedExpectations } from "../../expect/keyed";
import { checkValueExpectation } from "../../expect/value";
export function checkRowCount(actual: number, matcher: ValueMatcherInput): ExpectResult {
return checkValueMatcher(actual, matcher, {
export function checkRowCount(actual: number, matcher: ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: `rowCount ${actual} 不满足条件`,
path: "rowCount",
phase: "rowCount",
});
}
export function checkRows(rows: unknown, rules: KeyValueExpect[]): ExpectResult {
export function checkRows(rows: unknown, rules: KeyedExpectations[]): ExpectationResult {
if (!Array.isArray(rows)) {
return {
failure: mismatchFailure("row", "rows", rules, rows, "查询结果不是数组"),
@@ -39,7 +39,7 @@ export function checkRows(rows: unknown, rules: KeyValueExpect[]): ExpectResult
};
}
const result = checkKeyValueExpect(row, rule, { path: `rows[${i}]`, phase: "row" });
const result = checkKeyedExpectations(row, rule, { path: `rows[${i}]`, phase: "row" });
if (!result.matched) return result;
}

View File

@@ -2,7 +2,11 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import { createContentRulesSchema, createKeyValueExpectSchema, createValueMatcherSchema } from "../../schema/fragments";
import {
createRawContentExpectationsSchema,
createRawKeyedExpectationsSchema,
createRawValueExpectationSchema,
} from "../../schema/fragments";
export const dbCheckerSchemas: CheckerSchemas = {
config: Type.Object(
@@ -19,10 +23,10 @@ export const dbCheckerSchemas: CheckerSchemas = {
defaults: Type.Object({}, { additionalProperties: false }),
expect: Type.Object(
{
durationMs: Type.Optional(createValueMatcherSchema()),
result: Type.Optional(createContentRulesSchema()),
rowCount: Type.Optional(createValueMatcherSchema()),
rows: Type.Optional(Type.Array(createKeyValueExpectSchema())),
durationMs: Type.Optional(createRawValueExpectationSchema()),
result: Type.Optional(createRawContentExpectationsSchema()),
rowCount: Type.Optional(createRawValueExpectationSchema()),
rows: Type.Optional(Type.Array(createRawKeyedExpectationsSchema())),
},
{ additionalProperties: false },
),

View File

@@ -1,29 +1,44 @@
import type { ContentRules, KeyValueExpect, ValueMatcherInput } from "../../expect/types";
import type {
ContentExpectations,
KeyedExpectations,
RawContentExpectations,
RawKeyedExpectations,
RawValueExpectation,
ValueExpectation,
} from "../../expect/types";
import type { ResolvedTargetBase } from "../../types";
export interface DbExpectConfig {
durationMs?: ValueMatcherInput;
result?: ContentRules;
rowCount?: ValueMatcherInput;
rows?: KeyValueExpect[];
}
export interface DbTargetConfig {
query?: string;
url: string;
}
export interface RawDbExpectConfig {
durationMs?: RawValueExpectation;
result?: RawContentExpectations;
rowCount?: RawValueExpectation;
rows?: RawKeyedExpectations[];
}
export interface ResolvedDbConfig {
query?: string;
url: string;
}
export interface ResolvedDbExpectConfig {
durationMs?: ValueExpectation;
result?: ContentExpectations;
rowCount?: ValueExpectation;
rows?: KeyedExpectations[];
}
export interface ResolvedDbTarget extends ResolvedTargetBase {
db: ResolvedDbConfig;
expect?: DbExpectConfig;
expect?: ResolvedDbExpectConfig;
group: string;
intervalMs: number;
name: null | string;
rawExpect?: RawDbExpectConfig;
timeoutMs: number;
type: "db";
}

View File

@@ -1,10 +1,14 @@
import { isPlainObject, isString } from "es-toolkit";
import { 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 {
isPlainRecord,
validateRawContentExpectations,
validateRawKeyedExpectations,
validateRawValueExpectation,
} from "../../expect/validate";
import { issue, joinPath } from "../../schema/issues";
export function validateDbConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
@@ -12,7 +16,7 @@ export function validateDbConfig(input: CheckerValidationInput): ConfigValidatio
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isPlainObject(target)) continue;
if (!isPlainRecord(target)) continue;
if (target["type"] !== "db") continue;
issues.push(...validateDbTarget(target, `targets[${i}]`));
}
@@ -24,11 +28,11 @@ function collectRowExpects(rows: unknown[], path: string, targetName?: string):
const issues: ConfigValidationIssue[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;
if (!isPlainObject(row)) {
if (!isPlainRecord(row)) {
issues.push(issue("invalid-type", `${path}[${i}]`, "必须为对象", targetName));
continue;
}
issues.push(...validateKeyValueExpect(row, `${path}[${i}]`, targetName));
issues.push(...validateRawKeyedExpectations(row, `${path}[${i}]`, targetName));
}
return issues;
}
@@ -41,18 +45,16 @@ function getTargetName(target: Record<string, unknown>): string | undefined {
function validateDbExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const targetName = getTargetName(target);
const expect = target["expect"];
if (expect === undefined || expect === null || !isPlainObject(expect)) return [];
if (expect === undefined || expect === null || !isPlainRecord(expect)) return [];
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));
issues.push(...validateRawValueExpectation(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
}
if (expect["rowCount"] !== undefined) {
issues.push(...validateValueMatcher(expect["rowCount"], joinPath(expectPath, "rowCount"), targetName));
issues.push(...validateRawValueExpectation(expect["rowCount"], joinPath(expectPath, "rowCount"), targetName));
}
if (expect["rows"] !== undefined) {
@@ -64,10 +66,9 @@ function validateDbExpect(target: Record<string, unknown>, path: string): Config
}
if (expect["result"] !== undefined) {
issues.push(...validateContentRules(expect["result"], joinPath(expectPath, "result"), targetName));
issues.push(...validateRawContentExpectations(expect["result"], joinPath(expectPath, "result"), targetName));
}
// 检查未知字段
const allowedKeys = new Set(["durationMs", "result", "rowCount", "rows"]);
for (const key of Object.keys(expect)) {
if (!allowedKeys.has(key)) {
@@ -83,18 +84,16 @@ function validateDbTarget(target: Record<string, unknown>, path: string): Config
const targetName = getTargetName(target);
const db = target["db"];
if (!isPlainObject(db)) {
if (!isPlainRecord(db)) {
issues.push(issue("required", joinPath(path, "db"), "缺少 db.url 字段", targetName));
issues.push(...validateDbExpect(target, path));
return issues;
}
// url 必填
if (!isString(db["url"]) || db["url"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "db"), "url"), "缺少 db.url 字段", targetName));
}
// query 可选但不能为空字符串
if (db["query"] !== undefined) {
if (!isString(db["query"])) {
issues.push(issue("invalid-type", joinPath(joinPath(path, "db"), "query"), "必须为字符串", targetName));
@@ -110,7 +109,6 @@ function validateDbTarget(target: Record<string, unknown>, path: string): Config
}
}
// 检查未知字段
const allowedDbKeys = new Set(["query", "url"]);
for (const key of Object.keys(db)) {
if (!allowedDbKeys.has(key)) {

View File

@@ -2,18 +2,19 @@ import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { HttpExpectConfig, HttpTargetConfig, ResolvedHttpTarget } from "./types";
import type { HttpTargetConfig, RawHttpExpectConfig, ResolvedHttpExpectConfig, ResolvedHttpTarget } from "./types";
import { checkContentRules } from "../../expect/content";
import { checkContentExpectations, resolveContentExpectations } from "../../expect/content";
import { errorFailure, mismatchFailure } from "../../expect/failure";
import { checkValueMatcher, isValueMatcherObject } from "../../expect/matcher";
import { checkHeaderExpectations } from "../../expect/headers";
import { resolveKeyedExpectations } from "../../expect/keyed";
import { checkStatusCode } from "../../expect/status";
import { checkValueExpectation, displayValueExpectation, resolveValueExpectation } from "../../expect/value";
import { parseSize } from "../../utils";
import { checkHeaders, checkStatus } from "./expect";
import { httpCheckerSchemas } from "./schema";
import { validateHttpConfig } from "./validate";
const CHARSET_RE = /charset="?([^";\s]+)"?/i;
const BODY_PREVIEW_BYTES = 1024;
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
const SENSITIVE_HEADERS = new Set(["authorization", "cookie"]);
@@ -46,27 +47,11 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
const statusCode = response.status;
const responseHeaders = truncateHeaders(Object.fromEntries(response.headers));
const hasBodyRules = !!(expect?.body && expect.body.length > 0);
const bodyReadResult = await readBodyStream(
response,
hasBodyRules ? t.http.maxBodyBytes : BODY_PREVIEW_BYTES,
!hasBodyRules,
);
let bodyPreview: null | string = null;
let bodyText: null | string = null;
let bodyDecodeFailure: CheckResult["failure"] = null;
const hasBodyExpectations = !!(expect?.body && expect.body.length > 0);
if (bodyReadResult.data.byteLength > 0) {
const decodeResult = decodeBody(bodyReadResult.data, response.headers);
if (decodeResult.ok) {
bodyText = decodeResult.text;
bodyPreview = truncateBodyPreview(decodeResult.text);
} else {
bodyDecodeFailure = decodeResult.failure;
}
}
const statusResult = checkStatus(statusCode, expect?.status ?? [200]);
const statusResult = checkStatusCode(statusCode, expect?.status ?? [200]);
if (!statusResult.matched) {
return makeResult(
t,
@@ -79,7 +64,7 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
);
}
const headersResult = checkHeaders(Object.fromEntries(response.headers), expect?.headers);
const headersResult = checkHeaderExpectations(Object.fromEntries(response.headers), expect?.headers);
if (!headersResult.matched) {
return makeResult(
t,
@@ -92,7 +77,7 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
);
}
const earlyTimeout = hasBodyRules ? checkEarlyTimeout(start, expect?.durationMs) : null;
const earlyTimeout = hasBodyExpectations ? checkEarlyTimeout(start, expect?.durationMs) : null;
if (earlyTimeout) {
return makeResult(
t,
@@ -105,6 +90,20 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
);
}
if (hasBodyExpectations) {
const bodyReadResult = await readBodyStream(response, t.http.maxBodyBytes);
let bodyDecodeFailure: CheckResult["failure"] = null;
if (bodyReadResult.data.byteLength > 0) {
const decodeResult = decodeBody(bodyReadResult.data, response.headers);
if (decodeResult.ok) {
bodyText = decodeResult.text;
bodyPreview = truncateBodyPreview(decodeResult.text);
} else {
bodyDecodeFailure = decodeResult.failure;
}
}
if (!bodyReadResult.ok) {
return makeResult(
t,
@@ -129,8 +128,7 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
);
}
if (hasBodyRules) {
const bodyResult = checkContentRules(bodyText ?? "", expect.body, { path: "body", phase: "body" });
const bodyResult = checkContentExpectations(bodyText ?? "", expect.body, { path: "body", phase: "body" });
if (!bodyResult.matched) {
return makeResult(
t,
@@ -145,7 +143,7 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
}
const durationMs = Math.round(performance.now() - start);
const durationResult = checkValueMatcher(durationMs, expect?.durationMs, {
const durationResult = checkValueExpectation(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
@@ -184,9 +182,19 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
const method = t.http.method ?? "GET";
const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB");
const rawExpect = target.expect as RawHttpExpectConfig | undefined;
const resolvedExpect: ResolvedHttpExpectConfig = rawExpect
? {
body: resolveContentExpectations(rawExpect.body),
durationMs: resolveValueExpectation(rawExpect.durationMs),
headers: resolveKeyedExpectations(rawExpect.headers),
status: rawExpect.status ?? [200],
}
: { status: [200] };
return {
description: null,
expect: target.expect as HttpExpectConfig | undefined,
expect: resolvedExpect,
group: target.group ?? "default",
http: {
body: t.http.body,
@@ -200,6 +208,7 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? null,
rawExpect,
timeoutMs: context.defaultTimeoutMs,
type: "http",
} satisfies ResolvedHttpTarget;
@@ -277,20 +286,16 @@ function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: strin
function checkEarlyTimeout(
start: number,
durationMatcher: HttpExpectConfig["durationMs"] | undefined,
durationMatcher: ResolvedHttpExpectConfig["durationMs"] | undefined,
): null | { elapsed: number; failure: CheckResult["failure"] } {
if (!isValueMatcherObject(durationMatcher)) return null;
const limit = Math.min(
durationMatcher.lte ?? Number.POSITIVE_INFINITY,
durationMatcher.lt ?? Number.POSITIVE_INFINITY,
);
if (!Number.isFinite(limit)) return null;
if (!durationMatcher) return null;
const elapsed = performance.now() - start;
if (durationMatcher.lt !== undefined ? elapsed < limit : elapsed <= limit) return null;
const lteFailed = durationMatcher.lte !== undefined && elapsed > durationMatcher.lte;
const ltFailed = durationMatcher.lt !== undefined && elapsed >= durationMatcher.lt;
if (!lteFailed && !ltFailed) return null;
const durationMs = Math.round(elapsed);
const durationResult = checkValueMatcher(durationMs, durationMatcher, {
const durationResult = checkValueExpectation(durationMs, durationMatcher, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
@@ -299,7 +304,13 @@ function checkEarlyTimeout(
elapsed,
failure:
durationResult.failure ??
mismatchFailure("duration", "durationMs", durationMatcher, durationMs, "durationMs mismatch"),
mismatchFailure(
"duration",
"durationMs",
displayValueExpectation(durationMatcher),
durationMs,
"durationMs mismatch",
),
};
}

View File

@@ -1,36 +0,0 @@
import { isNumber } from "es-toolkit";
import type { ExpectResult, KeyValueExpect } from "../../expect/types";
import { mismatchFailure } from "../../expect/failure";
import { checkKeyValueExpect } from "../../expect/key-value";
export function checkHeaders(headers: Record<string, string>, headerExpects?: KeyValueExpect): ExpectResult {
return checkKeyValueExpect(headers, headerExpects, {
normalizeKey: (key) => key.toLowerCase(),
path: "headers",
phase: "headers",
});
}
export function checkStatus(statusCode: number, allowed: Array<number | string>): ExpectResult {
const matched = allowed.some((pattern) => {
if (isNumber(pattern)) return statusCode === pattern;
const base = parseInt(pattern[0]!, 10) * 100;
return statusCode >= base && statusCode < base + 100;
});
if (!matched) {
return {
failure: mismatchFailure(
"status",
"status",
allowed,
statusCode,
`status ${statusCode} not in [${allowed.join(", ")}]`,
),
matched: false,
};
}
return { failure: null, matched: true };
}

View File

@@ -3,9 +3,9 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import {
createContentRulesSchema,
createKeyValueExpectSchema,
createValueMatcherSchema,
createRawContentExpectationsSchema,
createRawKeyedExpectationsSchema,
createRawValueExpectationSchema,
httpMethodSchema,
sizeSchema,
statusCodePatternSchema,
@@ -34,9 +34,9 @@ export const httpCheckerSchemas: CheckerSchemas = {
),
expect: Type.Object(
{
body: Type.Optional(createContentRulesSchema()),
durationMs: Type.Optional(createValueMatcherSchema()),
headers: Type.Optional(createKeyValueExpectSchema()),
body: Type.Optional(createRawContentExpectationsSchema()),
durationMs: Type.Optional(createRawValueExpectationSchema()),
headers: Type.Optional(createRawKeyedExpectationsSchema()),
status: Type.Optional(Type.Array(statusCodePatternSchema)),
},
{ additionalProperties: false },

View File

@@ -1,4 +1,11 @@
import type { ContentRules, KeyValueExpect, ValueMatcherInput } from "../../expect/types";
import type {
ContentExpectations,
KeyedExpectations,
RawContentExpectations,
RawKeyedExpectations,
RawValueExpectation,
ValueExpectation,
} from "../../expect/types";
import type { ResolvedTargetBase } from "../../types";
export interface HttpDefaultsConfig {
@@ -7,13 +14,6 @@ export interface HttpDefaultsConfig {
method?: string;
}
export interface HttpExpectConfig {
body?: ContentRules;
durationMs?: ValueMatcherInput;
headers?: KeyValueExpect;
status?: Array<number | string>;
}
export interface HttpTargetConfig {
body?: string;
headers?: Record<string, string>;
@@ -24,6 +24,13 @@ export interface HttpTargetConfig {
url: string;
}
export interface RawHttpExpectConfig {
body?: RawContentExpectations;
durationMs?: RawValueExpectation;
headers?: RawKeyedExpectations;
status?: Array<number | string>;
}
export interface ResolvedHttpConfig {
body?: string;
headers: Record<string, string>;
@@ -34,12 +41,20 @@ export interface ResolvedHttpConfig {
url: string;
}
export interface ResolvedHttpExpectConfig {
body?: ContentExpectations;
durationMs?: ValueExpectation;
headers?: KeyedExpectations;
status: Array<number | string>;
}
export interface ResolvedHttpTarget extends ResolvedTargetBase {
expect?: HttpExpectConfig;
expect?: ResolvedHttpExpectConfig;
group: string;
http: ResolvedHttpConfig;
intervalMs: number;
name: null | string;
rawExpect?: RawHttpExpectConfig;
timeoutMs: number;
type: "http";
}

View File

@@ -3,13 +3,12 @@ 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,
validateKeyValueExpect,
validateValueMatcher,
} from "../../expect/validate-matcher";
validateRawContentExpectations,
validateRawKeyedExpectations,
validateRawValueExpectation,
} from "../../expect/validate";
import { issue, joinPath } from "../../schema/issues";
import { parseSize } from "../../utils";
@@ -34,24 +33,6 @@ export function validateHttpConfig(input: CheckerValidationInput): ConfigValidat
return issues;
}
export function validateJsonPath(path: string, rulePath: string, targetName?: string): ConfigValidationIssue[] {
if (!path.startsWith("$.") || path.length <= 2) {
return [issue("invalid-jsonpath", joinPath(rulePath, "path"), '必须为以 "$." 开头的有效 JSONPath', targetName)];
}
const issues: ConfigValidationIssue[] = [];
const segments = path.slice(2).split(".");
for (const seg of segments) {
if (seg === "") {
issues.push(issue("invalid-jsonpath", joinPath(rulePath, "path"), "包含空段", targetName));
}
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
if (bracketMatch?.[1]!.trim() === "") {
issues.push(issue("invalid-jsonpath", joinPath(rulePath, "path"), "数组访问缺少属性名", targetName));
}
}
return issues;
}
function getTargetName(target: Record<string, unknown>): string | undefined {
if (isString(target["name"])) return target["name"];
return isString(target["id"]) ? target["id"] : undefined;
@@ -68,14 +49,16 @@ function validateHttpExpect(target: Record<string, unknown>, 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));
issues.push(
...validateRawKeyedExpectations(expect["headers"], joinPath(expectPath, "headers"), targetName, {
caseInsensitive: true,
}),
);
}
if (expect["body"] !== undefined) {
issues.push(...validateContentRules(expect["body"], joinPath(expectPath, "body"), targetName));
issues.push(...validateRawContentExpectations(expect["body"], joinPath(expectPath, "body"), targetName));
}
if (Array.isArray(expect["status"])) {
@@ -83,7 +66,7 @@ function validateHttpExpect(target: Record<string, unknown>, path: string): Conf
}
if (expect["durationMs"] !== undefined) {
issues.push(...validateValueMatcher(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
issues.push(...validateRawValueExpectation(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
}
return issues;

View File

@@ -2,10 +2,16 @@ import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { PingExpectConfig, PingStats, PingTargetConfig, ResolvedPingTarget } from "./types";
import type {
PingStats,
PingTargetConfig,
RawIcmpExpectConfig,
ResolvedIcmpExpectConfig,
ResolvedPingTarget,
} from "./types";
import { errorFailure } from "../../expect/failure";
import { checkValueMatcher } from "../../expect/matcher";
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value";
import { buildPingCommand } from "./command";
import { checkAlive, checkAvgLatency, checkMaxLatency, checkPacketLoss } from "./expect";
import { parsePingOutput } from "./parse";
@@ -155,9 +161,21 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedPingTarget {
const t = target as RawTargetConfig & { icmp: PingTargetConfig; type: "icmp" };
const rawExpect = target.expect as RawIcmpExpectConfig | undefined;
const resolvedExpect: ResolvedIcmpExpectConfig = rawExpect
? {
alive: rawExpect.alive ?? true,
avgLatencyMs: resolveValueExpectation(rawExpect.avgLatencyMs),
durationMs: resolveValueExpectation(rawExpect.durationMs),
maxLatencyMs: resolveValueExpectation(rawExpect.maxLatencyMs),
packetLossPercent: resolveValueExpectation(rawExpect.packetLossPercent),
}
: { alive: true };
return {
description: null,
expect: target.expect as PingExpectConfig | undefined,
expect: resolvedExpect,
group: target.group ?? "default",
icmp: {
count: t.icmp.count ?? DEFAULT_COUNT,
@@ -167,6 +185,7 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? null,
rawExpect,
timeoutMs: context.defaultTimeoutMs,
type: "icmp",
} satisfies ResolvedPingTarget;
@@ -184,7 +203,7 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
}
}
function checkStats(stats: PingStats, expect: PingExpectConfig | undefined, durationMs: number) {
function checkStats(stats: PingStats, expect: ResolvedIcmpExpectConfig | undefined, durationMs: number) {
const aliveResult = checkAlive(stats.alive, expect?.alive ?? true);
if (!aliveResult.matched) return aliveResult;
const packetLossResult = checkPacketLoss(stats.packetLoss, expect?.packetLossPercent);
@@ -193,7 +212,7 @@ function checkStats(stats: PingStats, expect: PingExpectConfig | undefined, dura
if (!avgLatencyResult.matched) return avgLatencyResult;
const maxLatencyResult = checkMaxLatency(stats.maxLatencyMs, expect?.maxLatencyMs);
if (!maxLatencyResult.matched) return maxLatencyResult;
return checkValueMatcher(durationMs, expect?.durationMs, {
return checkValueExpectation(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",

View File

@@ -1,9 +1,9 @@
import type { ExpectResult, ValueMatcherInput } from "../../expect/types";
import type { ExpectationResult, ValueExpectation } from "../../expect/types";
import { mismatchFailure } from "../../expect/failure";
import { checkValueMatcher } from "../../expect/matcher";
import { checkValueExpectation } from "../../expect/value";
export function checkAlive(actual: boolean, expected: boolean): ExpectResult {
export function checkAlive(actual: boolean, expected: boolean): ExpectationResult {
if (actual === expected) return { failure: null, matched: true };
return {
failure: mismatchFailure(
@@ -17,24 +17,24 @@ export function checkAlive(actual: boolean, expected: boolean): ExpectResult {
};
}
export function checkAvgLatency(actual: null | number, matcher: undefined | ValueMatcherInput): ExpectResult {
return checkValueMatcher(actual, matcher, {
export function checkAvgLatency(actual: null | number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "平均延迟不满足条件",
path: "avgLatencyMs",
phase: "avgLatency",
});
}
export function checkMaxLatency(actual: null | number, matcher: undefined | ValueMatcherInput): ExpectResult {
return checkValueMatcher(actual, matcher, {
export function checkMaxLatency(actual: null | number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "最大延迟不满足条件",
path: "maxLatencyMs",
phase: "maxLatency",
});
}
export function checkPacketLoss(actual: number, matcher: undefined | ValueMatcherInput): ExpectResult {
return checkValueMatcher(actual, matcher, {
export function checkPacketLoss(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "丢包率不满足条件",
path: "packetLossPercent",
phase: "packetLoss",

View File

@@ -2,7 +2,7 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import { createValueMatcherSchema } from "../../schema/fragments";
import { createRawValueExpectationSchema } from "../../schema/fragments";
export const icmpCheckerSchemas: CheckerSchemas = {
config: Type.Object(
@@ -17,10 +17,10 @@ export const icmpCheckerSchemas: CheckerSchemas = {
expect: Type.Object(
{
alive: Type.Optional(Type.Boolean()),
avgLatencyMs: Type.Optional(createValueMatcherSchema()),
durationMs: Type.Optional(createValueMatcherSchema()),
maxLatencyMs: Type.Optional(createValueMatcherSchema()),
packetLossPercent: Type.Optional(createValueMatcherSchema()),
avgLatencyMs: Type.Optional(createRawValueExpectationSchema()),
durationMs: Type.Optional(createRawValueExpectationSchema()),
maxLatencyMs: Type.Optional(createRawValueExpectationSchema()),
packetLossPercent: Type.Optional(createRawValueExpectationSchema()),
},
{ additionalProperties: false },
),

View File

@@ -1,14 +1,6 @@
import type { ValueMatcherInput } from "../../expect/types";
import type { RawValueExpectation, ValueExpectation } from "../../expect/types";
import type { ResolvedTargetBase } from "../../types";
export interface PingExpectConfig {
alive?: boolean;
avgLatencyMs?: ValueMatcherInput;
durationMs?: ValueMatcherInput;
maxLatencyMs?: ValueMatcherInput;
packetLossPercent?: ValueMatcherInput;
}
export interface PingStats {
alive: boolean;
avgLatencyMs: null | number;
@@ -25,6 +17,22 @@ export interface PingTargetConfig {
packetSize?: number;
}
export interface RawIcmpExpectConfig {
alive?: boolean;
avgLatencyMs?: RawValueExpectation;
durationMs?: RawValueExpectation;
maxLatencyMs?: RawValueExpectation;
packetLossPercent?: RawValueExpectation;
}
export interface ResolvedIcmpExpectConfig {
alive: boolean;
avgLatencyMs?: ValueExpectation;
durationMs?: ValueExpectation;
maxLatencyMs?: ValueExpectation;
packetLossPercent?: ValueExpectation;
}
export interface ResolvedPingConfig {
count: number;
host: string;
@@ -32,11 +40,12 @@ export interface ResolvedPingConfig {
}
export interface ResolvedPingTarget extends ResolvedTargetBase {
expect?: PingExpectConfig;
expect?: ResolvedIcmpExpectConfig;
group: string;
icmp: ResolvedPingConfig;
intervalMs: number;
name: null | string;
rawExpect?: RawIcmpExpectConfig;
timeoutMs: number;
type: "icmp";
}

View File

@@ -1,10 +1,9 @@
import { isNumber, isPlainObject, isString } from "es-toolkit";
import { isNumber, 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 { isPlainRecord, validateRawValueExpectation } from "../../expect/validate";
import { issue, joinPath } from "../../schema/issues";
export function validatePingConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
@@ -13,10 +12,10 @@ export function validatePingConfig(input: CheckerValidationInput): ConfigValidat
const defaults = input.defaults["icmp"];
if (defaults !== undefined && defaults !== null) {
const targetName = "defaults.icmp";
if (!isPlainObject(defaults)) {
if (!isPlainRecord(defaults)) {
issues.push(issue("invalid-type", "defaults.icmp", "必须为对象", targetName));
} else {
const icmpDefaults = defaults as Record<string, unknown>;
const icmpDefaults = defaults;
for (const key of Object.keys(icmpDefaults)) {
issues.push(issue("unknown-field", joinPath("defaults.icmp", key), "是未知字段", targetName));
}
@@ -25,8 +24,8 @@ export function validatePingConfig(input: CheckerValidationInput): ConfigValidat
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isPlainObject(target)) continue;
const targetRecord = target as Record<string, unknown>;
if (!isPlainRecord(target)) continue;
const targetRecord = target;
if (targetRecord["type"] !== "icmp") continue;
issues.push(...validatePingTarget(targetRecord, `targets[${i}]`));
}
@@ -41,20 +40,18 @@ function getTargetName(target: Record<string, unknown>): string | undefined {
function validatePingExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const rawExpect = target["expect"];
if (rawExpect === undefined || rawExpect === null || !isPlainObject(rawExpect)) return [];
const expect = rawExpect as Record<string, unknown>;
if (rawExpect === undefined || rawExpect === null || !isPlainRecord(rawExpect)) return [];
const expect = rawExpect;
const issues: ConfigValidationIssue[] = [];
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));
}
for (const key of ["packetLossPercent", "avgLatencyMs", "maxLatencyMs", "durationMs"]) {
if (expect[key] !== undefined) {
issues.push(...validateValueMatcher(expect[key], joinPath(expectPath, key), targetName));
issues.push(...validateRawValueExpectation(expect[key], joinPath(expectPath, key), targetName));
}
}
@@ -73,12 +70,12 @@ function validatePingTarget(target: Record<string, unknown>, path: string): Conf
const targetName = getTargetName(target);
const rawIcmp = target["icmp"];
if (!isPlainObject(rawIcmp)) {
if (!isPlainRecord(rawIcmp)) {
issues.push(issue("required", joinPath(path, "icmp"), "缺少 icmp 配置分组", targetName));
issues.push(...validatePingExpect(target, path));
return issues;
}
const icmp = rawIcmp as Record<string, unknown>;
const icmp = rawIcmp;
if (!isString(icmp["host"]) || icmp["host"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "icmp"), "host"), "缺少 icmp.host 字段", targetName));

View File

@@ -5,10 +5,12 @@ import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { LlmExpectConfig, LlmTargetConfig, ResolvedLlmTarget } from "./types";
import type { LlmTargetConfig, RawLlmExpectConfig, ResolvedLlmExpectConfig, ResolvedLlmTarget } from "./types";
import { resolveContentExpectations } from "../../expect/content";
import { errorFailure } from "../../expect/failure";
import { checkValueMatcher } from "../../expect/matcher";
import { resolveKeyedExpectations } from "../../expect/keyed";
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value";
import { runExpects } from "./expect";
import {
buildObservationFromApiCallError,
@@ -93,7 +95,7 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
};
}
const durationResult = checkValueMatcher(durationMs, expect?.durationMs, {
const durationResult = checkValueExpectation(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
@@ -171,14 +173,40 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
url: t.llm.url,
};
const rawExpect = target.expect as RawLlmExpectConfig | undefined;
const resolvedExpect: ResolvedLlmExpectConfig = rawExpect
? {
durationMs: resolveValueExpectation(rawExpect.durationMs),
finishReason: resolveValueExpectation(rawExpect.finishReason),
headers: resolveKeyedExpectations(rawExpect.headers),
output: resolveContentExpectations(rawExpect.output),
rawFinishReason: resolveValueExpectation(rawExpect.rawFinishReason),
status: rawExpect.status ?? [200],
stream: rawExpect.stream
? {
completed: rawExpect.stream.completed ?? true,
firstTokenMs: resolveValueExpectation(rawExpect.stream.firstTokenMs),
}
: undefined,
usage: rawExpect.usage
? {
inputTokens: resolveValueExpectation(rawExpect.usage.inputTokens),
outputTokens: resolveValueExpectation(rawExpect.usage.outputTokens),
totalTokens: resolveValueExpectation(rawExpect.usage.totalTokens),
}
: undefined,
}
: { status: [200] };
return {
description: (target.description as null | string) ?? null,
expect: target.expect as LlmExpectConfig | undefined,
expect: resolvedExpect,
group: target.group ?? "default",
id: t.id,
intervalMs: context.defaultIntervalMs,
llm: resolvedConfig,
name: (target.name as null | string) ?? null,
rawExpect,
timeoutMs: context.defaultTimeoutMs,
type: "llm",
} satisfies ResolvedLlmTarget;
@@ -210,7 +238,7 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
t: ResolvedLlmTarget,
model: ReturnType<typeof createProviderModel>["model"],
httpMeta: null | { headers: Record<string, string>; status: number; statusText: string },
expect: LlmExpectConfig | undefined,
expect: ResolvedLlmExpectConfig | undefined,
ctx: CheckerContext,
timestamp: string,
start: number,
@@ -254,7 +282,7 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
);
const durationMs = Math.round(performance.now() - start);
const durationResult = checkValueMatcher(durationMs, expect?.durationMs, {
const durationResult = checkValueExpectation(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
@@ -277,7 +305,7 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
t: ResolvedLlmTarget,
model: ReturnType<typeof createProviderModel>["model"],
httpMeta: null | { headers: Record<string, string>; status: number; statusText: string },
expect: LlmExpectConfig | undefined,
expect: ResolvedLlmExpectConfig | undefined,
ctx: CheckerContext,
timestamp: string,
start: number,
@@ -301,7 +329,7 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
);
const durationMs = Math.round(performance.now() - start);
const durationResult = checkValueMatcher(durationMs, expect?.durationMs, {
const durationResult = checkValueExpectation(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",

View File

@@ -1,15 +1,19 @@
import type { ExpectResult } from "../../expect/types";
import type { LlmCheckObservation, LlmExpectConfig, LlmUsageExpect } from "./types";
import type { ExpectationResult } from "../../expect/types";
import type { LlmCheckObservation, ResolvedLlmExpectConfig, ResolvedLlmUsageExpect } from "./types";
import { checkContentRules } from "../../expect/content";
import { checkContentExpectations } from "../../expect/content";
import { mismatchFailure } from "../../expect/failure";
import { checkValueMatcher } from "../../expect/matcher";
import { checkHeaders, checkStatus } from "../http/expect";
import { checkHeaderExpectations } from "../../expect/headers";
import { checkStatusCode } from "../../expect/status";
import { checkValueExpectation } from "../../expect/value";
export function checkStreamExpect(observation: LlmCheckObservation, expect: LlmExpectConfig): ExpectResult {
export function checkStreamExpect(
observation: LlmCheckObservation,
expect: ResolvedLlmExpectConfig,
): ExpectationResult {
if (!observation.stream || !expect.stream) return { failure: null, matched: true };
const expectedCompleted = expect.stream.completed ?? true;
const expectedCompleted = expect.stream.completed;
if (observation.stream.completed !== expectedCompleted) {
return {
failure: mismatchFailure(
@@ -24,7 +28,7 @@ export function checkStreamExpect(observation: LlmCheckObservation, expect: LlmE
}
if (expect.stream.firstTokenMs && observation.stream.firstTokenMs !== null) {
return checkValueMatcher(observation.stream.firstTokenMs, expect.stream.firstTokenMs, {
return checkValueExpectation(observation.stream.firstTokenMs, expect.stream.firstTokenMs, {
message: "stream.firstTokenMs mismatch",
path: "stream.firstTokenMs",
phase: "stream",
@@ -45,20 +49,23 @@ export function checkStreamExpect(observation: LlmCheckObservation, expect: LlmE
return { failure: null, matched: true };
}
export function runExpects(observation: LlmCheckObservation, expect: LlmExpectConfig | undefined): ExpectResult {
export function runExpects(
observation: LlmCheckObservation,
expect: ResolvedLlmExpectConfig | undefined,
): ExpectationResult {
if (!expect) {
const defaultStatus = checkStatus(observation.http?.status ?? 0, [200]);
const defaultStatus = checkStatusCode(observation.http?.status ?? 0, [200]);
if (!defaultStatus.matched) return defaultStatus;
return { failure: null, matched: true };
}
const http = observation.http;
const statusResult = checkStatus(http?.status ?? 0, expect.status ?? [200]);
const statusResult = checkStatusCode(http?.status ?? 0, expect.status);
if (!statusResult.matched) return statusResult;
if (http && expect.headers) {
const headersResult = checkHeaders(http.headers, expect.headers);
const headersResult = checkHeaderExpectations(http.headers, expect.headers);
if (!headersResult.matched) return headersResult;
}
@@ -67,11 +74,14 @@ export function runExpects(observation: LlmCheckObservation, expect: LlmExpectCo
if (!streamResult.matched) return streamResult;
}
const outputResult = checkContentRules(observation.outputText, expect.output, { path: "output", phase: "output" });
const outputResult = checkContentExpectations(observation.outputText, expect.output, {
path: "output",
phase: "output",
});
if (!outputResult.matched) return outputResult;
if (expect.finishReason !== undefined) {
const result = checkValueMatcher(observation.finishReason, expect.finishReason, {
const result = checkValueExpectation(observation.finishReason, expect.finishReason, {
message: "finishReason mismatch",
path: "finishReason",
phase: "finishReason",
@@ -80,7 +90,7 @@ export function runExpects(observation: LlmCheckObservation, expect: LlmExpectCo
}
if (expect.rawFinishReason !== undefined) {
const result = checkValueMatcher(observation.rawFinishReason, expect.rawFinishReason, {
const result = checkValueExpectation(observation.rawFinishReason, expect.rawFinishReason, {
message: "rawFinishReason mismatch",
path: "rawFinishReason",
phase: "rawFinishReason",
@@ -98,10 +108,10 @@ export function runExpects(observation: LlmCheckObservation, expect: LlmExpectCo
function checkUsageExpect(
usage: { inputTokens: number; outputTokens: number; totalTokens: number },
expectUsage: LlmUsageExpect,
): ExpectResult {
expectUsage: ResolvedLlmUsageExpect,
): ExpectationResult {
if (expectUsage.inputTokens !== undefined) {
const result = checkValueMatcher(usage.inputTokens, expectUsage.inputTokens, {
const result = checkValueExpectation(usage.inputTokens, expectUsage.inputTokens, {
message: "usage.inputTokens mismatch",
path: "usage.inputTokens",
phase: "usage",
@@ -109,7 +119,7 @@ function checkUsageExpect(
if (!result.matched) return result;
}
if (expectUsage.outputTokens !== undefined) {
const result = checkValueMatcher(usage.outputTokens, expectUsage.outputTokens, {
const result = checkValueExpectation(usage.outputTokens, expectUsage.outputTokens, {
message: "usage.outputTokens mismatch",
path: "usage.outputTokens",
phase: "usage",
@@ -117,7 +127,7 @@ function checkUsageExpect(
if (!result.matched) return result;
}
if (expectUsage.totalTokens !== undefined) {
const result = checkValueMatcher(usage.totalTokens, expectUsage.totalTokens, {
const result = checkValueExpectation(usage.totalTokens, expectUsage.totalTokens, {
message: "usage.totalTokens mismatch",
path: "usage.totalTokens",
phase: "usage",

View File

@@ -3,9 +3,9 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import {
createContentRulesSchema,
createKeyValueExpectSchema,
createValueMatcherSchema,
createRawContentExpectationsSchema,
createRawKeyedExpectationsSchema,
createRawValueExpectationSchema,
statusCodePatternSchema,
stringMapSchema,
} from "../../schema/fragments";
@@ -55,17 +55,17 @@ export const llmCheckerSchemas: CheckerSchemas = {
),
expect: Type.Object(
{
durationMs: Type.Optional(createValueMatcherSchema()),
finishReason: Type.Optional(createValueMatcherSchema()),
headers: Type.Optional(createKeyValueExpectSchema()),
output: Type.Optional(createContentRulesSchema()),
rawFinishReason: Type.Optional(createValueMatcherSchema()),
durationMs: Type.Optional(createRawValueExpectationSchema()),
finishReason: Type.Optional(createRawValueExpectationSchema()),
headers: Type.Optional(createRawKeyedExpectationsSchema()),
output: Type.Optional(createRawContentExpectationsSchema()),
rawFinishReason: Type.Optional(createRawValueExpectationSchema()),
status: Type.Optional(Type.Array(statusCodePatternSchema)),
stream: Type.Optional(
Type.Object(
{
completed: Type.Optional(Type.Boolean()),
firstTokenMs: Type.Optional(createValueMatcherSchema()),
firstTokenMs: Type.Optional(createRawValueExpectationSchema()),
},
{ additionalProperties: false },
),
@@ -73,9 +73,9 @@ export const llmCheckerSchemas: CheckerSchemas = {
usage: Type.Optional(
Type.Object(
{
inputTokens: Type.Optional(createValueMatcherSchema()),
outputTokens: Type.Optional(createValueMatcherSchema()),
totalTokens: Type.Optional(createValueMatcherSchema()),
inputTokens: Type.Optional(createRawValueExpectationSchema()),
outputTokens: Type.Optional(createRawValueExpectationSchema()),
totalTokens: Type.Optional(createRawValueExpectationSchema()),
},
{ additionalProperties: false },
),

View File

@@ -1,6 +1,13 @@
import type { JSONObject } from "@ai-sdk/provider";
import type { ContentRules, KeyValueExpect, ValueMatcherInput } from "../../expect/types";
import type {
ContentExpectations,
KeyedExpectations,
RawContentExpectations,
RawKeyedExpectations,
RawValueExpectation,
ValueExpectation,
} from "../../expect/types";
import type { ResolvedTargetBase } from "../../types";
export interface LlmCheckObservation {
@@ -24,16 +31,6 @@ export interface LlmDefaultsConfig {
providerOptions?: Record<string, JSONObject>;
}
export interface LlmExpectConfig {
durationMs?: ValueMatcherInput;
finishReason?: ValueMatcherInput;
headers?: KeyValueExpect;
output?: ContentRules;
rawFinishReason?: ValueMatcherInput;
status?: Array<number | string>;
stream?: LlmStreamExpect;
usage?: LlmUsageExpect;
}
export interface LlmHttpMetadata {
headers: Record<string, string>;
status: number;
@@ -80,11 +77,6 @@ export interface LlmOptions {
export type LlmProvider = "anthropic" | "openai" | "openai-responses";
export interface LlmStreamExpect {
completed?: boolean;
firstTokenMs?: ValueMatcherInput;
}
export interface LlmStreamObservation {
completed: boolean;
firstTokenMs: null | number;
@@ -104,18 +96,34 @@ export interface LlmTargetConfig {
url: string;
}
export interface LlmUsageExpect {
inputTokens?: ValueMatcherInput;
outputTokens?: ValueMatcherInput;
totalTokens?: ValueMatcherInput;
}
export interface LlmUsageObservation {
inputTokens: number;
outputTokens: number;
totalTokens: number;
}
export interface RawLlmExpectConfig {
durationMs?: RawValueExpectation;
finishReason?: RawValueExpectation;
headers?: RawKeyedExpectations;
output?: RawContentExpectations;
rawFinishReason?: RawValueExpectation;
status?: Array<number | string>;
stream?: RawLlmStreamExpect;
usage?: RawLlmUsageExpect;
}
export interface RawLlmStreamExpect {
completed?: boolean;
firstTokenMs?: RawValueExpectation;
}
export interface RawLlmUsageExpect {
inputTokens?: RawValueExpectation;
outputTokens?: RawValueExpectation;
totalTokens?: RawValueExpectation;
}
export interface ResolvedLlmConfig {
authToken?: string;
headers: Record<string, string>;
@@ -130,12 +138,35 @@ export interface ResolvedLlmConfig {
url: string;
}
export interface ResolvedLlmExpectConfig {
durationMs?: ValueExpectation;
finishReason?: ValueExpectation;
headers?: KeyedExpectations;
output?: ContentExpectations;
rawFinishReason?: ValueExpectation;
status: Array<number | string>;
stream?: ResolvedLlmStreamExpect;
usage?: ResolvedLlmUsageExpect;
}
export interface ResolvedLlmStreamExpect {
completed: boolean;
firstTokenMs?: ValueExpectation;
}
export interface ResolvedLlmTarget extends ResolvedTargetBase {
expect?: LlmExpectConfig;
expect?: ResolvedLlmExpectConfig;
group: string;
intervalMs: number;
llm: ResolvedLlmConfig;
name: null | string;
rawExpect?: RawLlmExpectConfig;
timeoutMs: number;
type: "llm";
}
export interface ResolvedLlmUsageExpect {
inputTokens?: ValueExpectation;
outputTokens?: ValueExpectation;
totalTokens?: ValueExpectation;
}

View File

@@ -3,13 +3,12 @@ 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,
validateKeyValueExpect,
validateValueMatcher,
} from "../../expect/validate-matcher";
validateRawContentExpectations,
validateRawKeyedExpectations,
validateRawValueExpectation,
} from "../../expect/validate";
import { issue, joinPath } from "../../schema/issues";
const ALLOWED_MODES = new Set(["http", "stream"]);
@@ -73,23 +72,27 @@ 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));
}
if (expect["headers"] !== undefined) {
issues.push(...validateKeyValueExpect(expect["headers"], joinPath(expectPath, "headers"), targetName));
issues.push(
...validateRawKeyedExpectations(expect["headers"], joinPath(expectPath, "headers"), targetName, {
caseInsensitive: true,
}),
);
}
if (expect["output"] !== undefined) {
issues.push(...validateContentRules(expect["output"], joinPath(expectPath, "output"), targetName));
issues.push(...validateRawContentExpectations(expect["output"], joinPath(expectPath, "output"), targetName));
}
if (expect["finishReason"] !== undefined) {
issues.push(...validateValueMatcher(expect["finishReason"], joinPath(expectPath, "finishReason"), targetName));
issues.push(
...validateRawValueExpectation(expect["finishReason"], joinPath(expectPath, "finishReason"), targetName),
);
}
if (expect["rawFinishReason"] !== undefined) {
issues.push(
...validateValueMatcher(expect["rawFinishReason"], joinPath(expectPath, "rawFinishReason"), targetName),
...validateRawValueExpectation(expect["rawFinishReason"], joinPath(expectPath, "rawFinishReason"), targetName),
);
}
if (expect["usage"] !== undefined) {
@@ -105,7 +108,7 @@ function validateLlmExpect(
}
}
if (expect["durationMs"] !== undefined) {
issues.push(...validateValueMatcher(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
issues.push(...validateRawValueExpectation(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
}
const allowedKeys = new Set([
@@ -289,13 +292,11 @@ 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));
}
if (stream["firstTokenMs"] !== undefined) {
issues.push(...validateValueMatcher(stream["firstTokenMs"], joinPath(path, "firstTokenMs"), targetName));
issues.push(...validateRawValueExpectation(stream["firstTokenMs"], joinPath(path, "firstTokenMs"), targetName));
}
const allowedKeys = new Set(["completed", "firstTokenMs"]);
@@ -321,11 +322,9 @@ 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));
issues.push(...validateRawValueExpectation(usage[key], joinPath(path, key), targetName));
}
}

View File

@@ -2,10 +2,11 @@ import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { ResolvedTcpTarget, TcpExpectConfig, TcpTargetConfig } from "./types";
import type { RawTcpExpectConfig, ResolvedTcpExpectConfig, ResolvedTcpTarget, TcpTargetConfig } from "./types";
import { resolveContentExpectations } from "../../expect/content";
import { errorFailure } from "../../expect/failure";
import { checkValueMatcher } from "../../expect/matcher";
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value";
import { parseSize } from "../../utils";
import { checkBanner, checkConnected } from "./expect";
import { tcpCheckerSchemas } from "./schema";
@@ -159,7 +160,7 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
}
const durationMs = Math.round(performance.now() - start);
const durationResult = checkValueMatcher(durationMs, expect?.durationMs, {
const durationResult = checkValueExpectation(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
@@ -212,13 +213,23 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
const maxBannerBytes = parseSize(t.tcp.maxBannerBytes ?? tcpDefaults?.maxBannerBytes ?? DEFAULT_MAX_BANNER_BYTES);
const bannerReadTimeout = t.tcp.bannerReadTimeout ?? tcpDefaults?.bannerReadTimeout ?? DEFAULT_BANNER_READ_TIMEOUT;
const rawExpect = target.expect as RawTcpExpectConfig | undefined;
const resolvedExpect: ResolvedTcpExpectConfig = rawExpect
? {
banner: resolveContentExpectations(rawExpect.banner),
connected: rawExpect.connected ?? true,
durationMs: resolveValueExpectation(rawExpect.durationMs),
}
: { connected: true };
return {
description: null,
expect: target.expect as TcpExpectConfig | undefined,
expect: resolvedExpect,
group: target.group ?? "default",
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? null,
rawExpect,
tcp: {
bannerReadTimeout,
host: t.tcp.host,

View File

@@ -1,13 +1,13 @@
import type { ContentRules, ExpectResult } from "../../expect/types";
import type { ContentExpectations, ExpectationResult } from "../../expect/types";
import { checkContentRules } from "../../expect/content";
import { checkContentExpectations } from "../../expect/content";
import { mismatchFailure } from "../../expect/failure";
export function checkBanner(banner: string, rules: ContentRules): ExpectResult {
return checkContentRules(banner, rules, { path: "banner", phase: "banner" });
export function checkBanner(banner: string, expectations: ContentExpectations): ExpectationResult {
return checkContentExpectations(banner, expectations, { path: "banner", phase: "banner" });
}
export function checkConnected(connected: boolean, expected: boolean): ExpectResult {
export function checkConnected(connected: boolean, expected: boolean): ExpectationResult {
if (connected === expected) return { failure: null, matched: true };
if (!connected && expected) {
return {

View File

@@ -2,7 +2,11 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import { createContentRulesSchema, createValueMatcherSchema, sizeSchema } from "../../schema/fragments";
import {
createRawContentExpectationsSchema,
createRawValueExpectationSchema,
sizeSchema,
} from "../../schema/fragments";
export const tcpCheckerSchemas: CheckerSchemas = {
config: Type.Object(
@@ -24,9 +28,9 @@ export const tcpCheckerSchemas: CheckerSchemas = {
),
expect: Type.Object(
{
banner: Type.Optional(createContentRulesSchema()),
banner: Type.Optional(createRawContentExpectationsSchema()),
connected: Type.Optional(Type.Boolean()),
durationMs: Type.Optional(createValueMatcherSchema()),
durationMs: Type.Optional(createRawValueExpectationSchema()),
},
{ additionalProperties: false },
),

View File

@@ -1,6 +1,17 @@
import type { ContentRules, ValueMatcherInput } from "../../expect/types";
import type {
ContentExpectations,
RawContentExpectations,
RawValueExpectation,
ValueExpectation,
} from "../../expect/types";
import type { ResolvedTargetBase } from "../../types";
export interface RawTcpExpectConfig {
banner?: RawContentExpectations;
connected?: boolean;
durationMs?: RawValueExpectation;
}
export interface ResolvedTcpConfig {
bannerReadTimeout: number;
host: string;
@@ -9,11 +20,18 @@ export interface ResolvedTcpConfig {
readBanner: boolean;
}
export interface ResolvedTcpExpectConfig {
banner?: ContentExpectations;
connected: boolean;
durationMs?: ValueExpectation;
}
export interface ResolvedTcpTarget extends ResolvedTargetBase {
expect?: TcpExpectConfig;
expect?: ResolvedTcpExpectConfig;
group: string;
intervalMs: number;
name: null | string;
rawExpect?: RawTcpExpectConfig;
tcp: ResolvedTcpConfig;
timeoutMs: number;
type: "tcp";
@@ -24,12 +42,6 @@ export interface TcpDefaultsConfig {
maxBannerBytes?: number | string;
}
export interface TcpExpectConfig {
banner?: ContentRules;
connected?: boolean;
durationMs?: ValueMatcherInput;
}
export interface TcpTargetConfig {
bannerReadTimeout?: number;
host: string;

View File

@@ -1,10 +1,9 @@
import { isNumber, isPlainObject, isString } from "es-toolkit";
import { isNumber, 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 { isPlainRecord, validateRawContentExpectations, validateRawValueExpectation } from "../../expect/validate";
import { issue, joinPath } from "../../schema/issues";
export function validateTcpConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
@@ -14,7 +13,7 @@ export function validateTcpConfig(input: CheckerValidationInput): ConfigValidati
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isPlainObject(target)) continue;
if (!isPlainRecord(target)) continue;
if (target["type"] !== "tcp") continue;
issues.push(...validateTcpTarget(target, `targets[${i}]`));
}
@@ -34,7 +33,7 @@ function isNonNegativeFiniteNumber(value: unknown): boolean {
function validateTcpDefaults(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const defaults = input.defaults["tcp"];
if (defaults === undefined || defaults === null || !isPlainObject(defaults)) return issues;
if (defaults === undefined || defaults === null || !isPlainRecord(defaults)) return issues;
const targetName = "defaults.tcp";
@@ -72,18 +71,16 @@ function validateTcpExpect(
): ConfigValidationIssue[] {
const targetName = getTargetName(target);
const expect = target["expect"];
if (expect === undefined || expect === null || !isPlainObject(expect)) return [];
if (expect === undefined || expect === null || !isPlainRecord(expect)) return [];
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));
}
if (expect["durationMs"] !== undefined) {
issues.push(...validateValueMatcher(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
issues.push(...validateRawValueExpectation(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
}
if (expect["banner"] !== undefined) {
@@ -92,7 +89,7 @@ function validateTcpExpect(
issue("invalid-value", joinPath(expectPath, "banner"), "banner 断言需要启用 tcp.readBanner", targetName),
);
} else {
issues.push(...validateContentRules(expect["banner"], joinPath(expectPath, "banner"), targetName));
issues.push(...validateRawContentExpectations(expect["banner"], joinPath(expectPath, "banner"), targetName));
}
}
@@ -111,7 +108,7 @@ function validateTcpTarget(target: Record<string, unknown>, path: string): Confi
const targetName = getTargetName(target);
const tcp = target["tcp"];
if (!isPlainObject(tcp)) {
if (!isPlainRecord(tcp)) {
issues.push(issue("required", joinPath(path, "tcp"), "缺少 tcp 配置分组", targetName));
issues.push(...validateTcpExpect(target, path, false));
return issues;

View File

@@ -2,10 +2,17 @@ import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { ResolvedUdpTarget, UdpDefaultsConfig, UdpExpectConfig, UdpTargetConfig } from "./types";
import type {
RawUdpExpectConfig,
ResolvedUdpExpectConfig,
ResolvedUdpTarget,
UdpDefaultsConfig,
UdpTargetConfig,
} from "./types";
import { resolveContentExpectations } from "../../expect/content";
import { errorFailure } from "../../expect/failure";
import { checkValueMatcher } from "../../expect/matcher";
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value";
import { parseSize } from "../../utils";
import { decodePayload, encodeResponse } from "./encoding";
import { checkResponded, checkResponseSize, checkResponseText, checkSourceHost, checkSourcePort } from "./expect";
@@ -111,7 +118,7 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
};
}
const durationResult = checkValueMatcher(durationMs, expect?.durationMs, {
const durationResult = checkValueExpectation(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
@@ -255,7 +262,7 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
}
}
const durationResult = checkValueMatcher(durationMs, expect?.durationMs, {
const durationResult = checkValueExpectation(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
@@ -305,13 +312,26 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
t.udp.maxResponseBytes ?? udpDefaults?.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES,
);
const rawExpect = target.expect as RawUdpExpectConfig | undefined;
const resolvedExpect: ResolvedUdpExpectConfig = rawExpect
? {
durationMs: resolveValueExpectation(rawExpect.durationMs),
responded: rawExpect.responded ?? true,
response: resolveContentExpectations(rawExpect.response),
responseSize: resolveValueExpectation(rawExpect.responseSize),
sourceHost: resolveValueExpectation(rawExpect.sourceHost),
sourcePort: resolveValueExpectation(rawExpect.sourcePort),
}
: { responded: true };
return {
description: null,
expect: target.expect as UdpExpectConfig | undefined,
expect: resolvedExpect,
group: target.group ?? "default",
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? null,
rawExpect,
timeoutMs: context.defaultTimeoutMs,
type: "udp",
udp: {

View File

@@ -1,10 +1,10 @@
import type { ContentRules, ExpectResult, ValueMatcherInput } from "../../expect/types";
import type { ContentExpectations, ExpectationResult, ValueExpectation } from "../../expect/types";
import { checkContentRules } from "../../expect/content";
import { checkContentExpectations } from "../../expect/content";
import { mismatchFailure } from "../../expect/failure";
import { checkValueMatcher } from "../../expect/matcher";
import { checkValueExpectation } from "../../expect/value";
export function checkResponded(responded: boolean, expected: boolean): ExpectResult {
export function checkResponded(responded: boolean, expected: boolean): ExpectationResult {
if (responded === expected) return { failure: null, matched: true };
if (!responded && expected) {
return {
@@ -18,28 +18,28 @@ export function checkResponded(responded: boolean, expected: boolean): ExpectRes
};
}
export function checkResponseSize(size: number, matcher: ValueMatcherInput): ExpectResult {
return checkValueMatcher(size, matcher, {
export function checkResponseSize(size: number, matcher: ValueExpectation): ExpectationResult {
return checkValueExpectation(size, matcher, {
message: "响应大小不满足条件",
path: "responseSize",
phase: "responseSize",
});
}
export function checkResponseText(text: string, rules: ContentRules): ExpectResult {
return checkContentRules(text, rules, { path: "response", phase: "response" });
export function checkResponseText(text: string, expectations: ContentExpectations): ExpectationResult {
return checkContentExpectations(text, expectations, { path: "response", phase: "response" });
}
export function checkSourceHost(actual: string, matcher: ValueMatcherInput): ExpectResult {
return checkValueMatcher(actual, matcher, {
export function checkSourceHost(actual: string, matcher: ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "响应来源地址不满足条件",
path: "sourceHost",
phase: "sourceHost",
});
}
export function checkSourcePort(actual: number, matcher: ValueMatcherInput): ExpectResult {
return checkValueMatcher(actual, matcher, {
export function checkSourcePort(actual: number, matcher: ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "响应来源端口不满足条件",
path: "sourcePort",
phase: "sourcePort",

View File

@@ -2,7 +2,11 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import { createContentRulesSchema, createValueMatcherSchema, sizeSchema } from "../../schema/fragments";
import {
createRawContentExpectationsSchema,
createRawValueExpectationSchema,
sizeSchema,
} from "../../schema/fragments";
export const udpCheckerSchemas: CheckerSchemas = {
config: Type.Object(
@@ -26,12 +30,12 @@ export const udpCheckerSchemas: CheckerSchemas = {
),
expect: Type.Object(
{
durationMs: Type.Optional(createValueMatcherSchema()),
durationMs: Type.Optional(createRawValueExpectationSchema()),
responded: Type.Optional(Type.Boolean()),
response: Type.Optional(createContentRulesSchema()),
responseSize: Type.Optional(createValueMatcherSchema()),
sourceHost: Type.Optional(createValueMatcherSchema()),
sourcePort: Type.Optional(createValueMatcherSchema()),
response: Type.Optional(createRawContentExpectationsSchema()),
responseSize: Type.Optional(createRawValueExpectationSchema()),
sourceHost: Type.Optional(createRawValueExpectationSchema()),
sourcePort: Type.Optional(createRawValueExpectationSchema()),
},
{ additionalProperties: false },
),

View File

@@ -1,6 +1,20 @@
import type { ContentRules, ValueMatcherInput } from "../../expect/types";
import type {
ContentExpectations,
RawContentExpectations,
RawValueExpectation,
ValueExpectation,
} from "../../expect/types";
import type { ResolvedTargetBase } from "../../types";
export interface RawUdpExpectConfig {
durationMs?: RawValueExpectation;
responded?: boolean;
response?: RawContentExpectations;
responseSize?: RawValueExpectation;
sourceHost?: RawValueExpectation;
sourcePort?: RawValueExpectation;
}
export interface ResolvedUdpConfig {
encoding: UdpEncoding;
host: string;
@@ -10,11 +24,21 @@ export interface ResolvedUdpConfig {
responseEncoding: UdpEncoding;
}
export interface ResolvedUdpExpectConfig {
durationMs?: ValueExpectation;
responded: boolean;
response?: ContentExpectations;
responseSize?: ValueExpectation;
sourceHost?: ValueExpectation;
sourcePort?: ValueExpectation;
}
export interface ResolvedUdpTarget extends ResolvedTargetBase {
expect?: UdpExpectConfig;
expect?: ResolvedUdpExpectConfig;
group: string;
intervalMs: number;
name: null | string;
rawExpect?: RawUdpExpectConfig;
timeoutMs: number;
type: "udp";
udp: ResolvedUdpConfig;
@@ -28,15 +52,6 @@ export interface UdpDefaultsConfig {
export type UdpEncoding = "base64" | "hex" | "text";
export interface UdpExpectConfig {
durationMs?: ValueMatcherInput;
responded?: boolean;
response?: ContentRules;
responseSize?: ValueMatcherInput;
sourceHost?: ValueMatcherInput;
sourcePort?: ValueMatcherInput;
}
export interface UdpTargetConfig {
encoding?: UdpEncoding;
host: string;

View File

@@ -1,10 +1,9 @@
import { isNumber, isPlainObject, isString } from "es-toolkit";
import { isNumber, 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 { isPlainRecord, validateRawContentExpectations, validateRawValueExpectation } from "../../expect/validate";
import { issue, joinPath } from "../../schema/issues";
const VALID_ENCODINGS = new Set(["base64", "hex", "text"]);
@@ -16,7 +15,7 @@ export function validateUdpConfig(input: CheckerValidationInput): ConfigValidati
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isPlainObject(target)) continue;
if (!isPlainRecord(target)) continue;
if (target["type"] !== "udp") continue;
issues.push(...validateUdpTarget(target, `targets[${i}]`));
}
@@ -48,7 +47,7 @@ function validateSize(value: unknown, path: string, targetName: string | undefin
function validateUdpDefaults(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const defaults = input.defaults["udp"];
if (defaults === undefined || defaults === null || !isPlainObject(defaults)) return issues;
if (defaults === undefined || defaults === null || !isPlainRecord(defaults)) return issues;
const targetName = "defaults.udp";
@@ -71,35 +70,35 @@ function validateUdpDefaults(input: CheckerValidationInput): ConfigValidationIss
function validateUdpExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const targetName = getTargetName(target);
const expect = target["expect"];
if (expect === undefined || expect === null || !isPlainObject(expect)) return [];
if (expect === undefined || expect === null || !isPlainRecord(expect)) return [];
const issues: ConfigValidationIssue[] = [];
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));
}
if (expect["durationMs"] !== undefined) {
issues.push(...validateValueMatcher(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
issues.push(...validateRawValueExpectation(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
}
if (expect["response"] !== undefined) {
issues.push(...validateContentRules(expect["response"], joinPath(expectPath, "response"), targetName));
issues.push(...validateRawContentExpectations(expect["response"], joinPath(expectPath, "response"), targetName));
}
if (expect["responseSize"] !== undefined) {
issues.push(...validateValueMatcher(expect["responseSize"], joinPath(expectPath, "responseSize"), targetName));
issues.push(
...validateRawValueExpectation(expect["responseSize"], joinPath(expectPath, "responseSize"), targetName),
);
}
if (expect["sourceHost"] !== undefined) {
issues.push(...validateValueMatcher(expect["sourceHost"], joinPath(expectPath, "sourceHost"), targetName));
issues.push(...validateRawValueExpectation(expect["sourceHost"], joinPath(expectPath, "sourceHost"), targetName));
}
if (expect["sourcePort"] !== undefined) {
issues.push(...validateValueMatcher(expect["sourcePort"], joinPath(expectPath, "sourcePort"), targetName));
issues.push(...validateRawValueExpectation(expect["sourcePort"], joinPath(expectPath, "sourcePort"), targetName));
}
const respondedFalse = responded === false;
@@ -141,7 +140,7 @@ function validateUdpTarget(target: Record<string, unknown>, path: string): Confi
const targetName = getTargetName(target);
const udp = target["udp"];
if (!isPlainObject(udp)) {
if (!isPlainRecord(udp)) {
issues.push(issue("required", joinPath(path, "udp"), "缺少 udp 配置分组", targetName));
issues.push(...validateUdpExpect(target, path));
return issues;

View File

@@ -5,9 +5,10 @@ import { Type } from "@sinclair/typebox";
import type { CheckerDefinition } from "../runner/types";
import {
createContentRulesSchema,
createKeyValueExpectSchema,
createValueMatcherSchema,
createRawContentExpectationsSchema,
createRawKeyedExpectationsSchema,
createRawValueExpectationSchema,
createValueMatcherObjectSchema,
durationSchema,
variableValueSchema,
} from "./fragments";
@@ -18,9 +19,10 @@ export function createExternalProbeConfigSchema(checkers: CheckerDefinition[]):
$id: "https://dial.local/probe-config.schema.json",
$schema: "http://json-schema.org/draft-07/schema#",
definitions: {
ContentRules: cloneSchema(createContentRulesSchema()),
KeyValueExpect: cloneSchema(createKeyValueExpectSchema()),
ValueMatcher: cloneSchema(createValueMatcherSchema()),
ContentExpectations: cloneSchema(createRawContentExpectationsSchema()),
KeyedExpectations: cloneSchema(createRawKeyedExpectationsSchema()),
ValueExpectation: cloneSchema(createRawValueExpectationSchema()),
ValueMatcher: cloneSchema(createValueMatcherObjectSchema()),
},
};
}

View File

@@ -6,8 +6,6 @@ import type { JsonValue } from "./types";
export const HTTP_METHODS = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] as const;
export const MatcherKeys = ["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "regex"] as const;
export const durationSchema = Type.String();
export const httpMethodSchema = Type.Union(
@@ -43,7 +41,7 @@ export const stringMapSchema = Type.Unsafe<Record<string, string>>({
type: "object",
});
export function createContentRulesSchema(): TSchema {
export function createRawContentExpectationsSchema(): TSchema {
return Type.Array(
Type.Object(
{
@@ -66,21 +64,23 @@ export function createContentRulesSchema(): TSchema {
);
}
export function createKeyValueExpectSchema(): TSchema {
export function createRawKeyedExpectationsSchema(): TSchema {
return Type.Unsafe<Record<string, unknown>>({
additionalProperties: {
anyOf: [jsonValueSchema, createValueMatcherSchema()],
},
additionalProperties: createRawValueExpectationSchema(),
type: "object",
});
}
export function createValueMatcherSchema(): TSchema {
export function createRawValueExpectationSchema(): TSchema {
return Type.Unsafe({
anyOf: [primitiveValueSchema, Type.Object(matcherProperties(), { additionalProperties: false, minProperties: 1 })],
});
}
export function createValueMatcherObjectSchema(): TSchema {
return Type.Object(matcherProperties(), { additionalProperties: false, minProperties: 1 });
}
export function matcherProperties(): Record<string, TSchema> {
return {
contains: Type.Optional(Type.String()),

View File

@@ -352,7 +352,7 @@ export class ProbeStore {
const serialized = checkerRegistry.get(t.type).serialize(t);
const target = serialized.target;
const config = serialized.config;
const expect = t.expect ? JSON.stringify(t.expect) : null;
const expect = t.rawExpect ? JSON.stringify(t.rawExpect) : null;
if (existingIds.has(t.id)) {
updateStmt.run(t.name, t.description, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group, t.id);

View File

@@ -45,6 +45,7 @@ export interface ResolvedTargetBase {
id: string;
intervalMs: number;
name: null | string;
rawExpect?: unknown;
timeoutMs: number;
type: string;
}

View File

@@ -84,6 +84,33 @@ describe("config contract", () => {
expect(validate(target({ equals: { status: "ok" } }))).toBe(true);
});
test("导出 schema 拒绝 KeyedExpectations 的数组和对象简写", () => {
const ajv = new Ajv({
allErrors: true,
coerceTypes: false,
removeAdditional: false,
strict: true,
useDefaults: false,
});
const validate = ajv.compile(createProbeConfigJsonSchema(createDefaultCheckerRegistry()));
const target = (headerValue: unknown) => ({
targets: [
{
expect: { headers: { "x-test": headerValue } },
http: { url: "https://example.com" },
id: "api",
type: "http",
},
],
});
expect(validate(target("ok"))).toBe(true);
expect(validate(target({ contains: "ok" }))).toBe(true);
expect(validate(target(["ok"]))).toBe(false);
expect(validate(target({ nested: "ok" }))).toBe(false);
expect(validate(target({ equals: { nested: "ok" } }))).toBe(true);
});
test("Ajv 错误转换为中文结构化 issue", () => {
const result = validateProbeConfigContract(
{

View File

@@ -3,14 +3,13 @@ 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 { checkValueExpectation } from "../../../src/server/checker/expect/value";
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";
@@ -290,7 +289,7 @@ targets:
expect(config.targets[0]!.name).toBeNull();
});
test("ValueMatcher primitive 简写在加载时归一化后可运行期匹配", async () => {
test("ValueMatcher primitive 简写在 resolve 后可运行期匹配", async () => {
const configPath = join(tempDir, "matcher-shorthand.yaml");
await writeFile(
configPath,
@@ -309,7 +308,7 @@ targets:
expect(target.expect?.durationMs).toEqual({ equals: 123 });
expect(
checkValueMatcher(123, target.expect?.durationMs as ValueMatcher, {
checkValueExpectation(123, target.expect?.durationMs, {
path: "durationMs",
phase: "duration",
}).matched,
@@ -860,11 +859,19 @@ targets:
const config = await loadConfig(configPath);
const t = config.targets[0]!;
if (t.type === "http") {
expect(t.expect).toEqual({
expect(t.rawExpect).toEqual({
body: [{ contains: "ok" }, { json: { equals: "ok", path: "$.status" } }],
durationMs: { lte: 3000 },
status: [200, 201],
});
expect(t.expect).toEqual({
body: [
{ kind: "value", matcher: { contains: "ok" } },
{ kind: "json", matcher: { equals: "ok" }, path: "$.status" },
],
durationMs: { lte: 3000 },
status: [200, 201],
});
}
});
@@ -893,12 +900,21 @@ targets:
const config = await loadConfig(configPath);
const t = config.targets[0]!;
if (t.type === "cmd") {
expect(t.expect).toEqual({
expect(t.rawExpect).toEqual({
durationMs: { lte: 5000 },
exitCode: [0, 2],
stderr: [{ empty: true }],
stdout: [{ contains: "ok" }, { regex: "done" }],
});
expect(t.expect).toEqual({
durationMs: { lte: 5000 },
exitCode: [0, 2],
stderr: [{ kind: "value", matcher: { empty: true } }],
stdout: [
{ kind: "value", matcher: { contains: "ok" } },
{ kind: "value", matcher: { regex: "done" } },
],
});
}
});
@@ -1917,7 +1933,7 @@ targets:
const config = await loadConfig(configPath);
const t = config.targets[0]! as ResolvedTcpTarget;
expect(t.tcp.readBanner).toBe(true);
expect(t.expect?.banner).toEqual([{ contains: "ESMTP" }]);
expect(t.expect?.banner).toEqual([{ kind: "value", matcher: { contains: "ESMTP" } }]);
});
test("tcp expect.banner 未开启 readBanner 抛出错误", async () => {

View File

@@ -1,31 +0,0 @@
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

@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test";
import type { ResolvedCommandTarget } from "../../../../../src/server/checker/runner/cmd/types";
import type { CheckerContext } from "../../../../../src/server/checker/runner/types";
import type { CheckerContext, ResolveContext } from "../../../../../src/server/checker/runner/types";
import { CommandChecker } from "../../../../../src/server/checker/runner/cmd/execute";
@@ -17,6 +17,10 @@ function makeCtx(timeoutMs = 5000): CheckerContext {
return { signal: controller.signal };
}
function makeResolveContext(): ResolveContext {
return { configDir: process.cwd(), defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 };
}
function makeTarget(
cmd: Partial<ResolvedCommandTarget["cmd"]>,
overrides?: Partial<ResolvedCommandTarget>,
@@ -94,7 +98,7 @@ describe("CommandChecker", () => {
const result = await checker.execute(
makeTarget(
{ args: ["-e", "console.log('hello')"], exec: "bun" },
{ expect: { stdout: [{ contains: "hello" }] } },
{ expect: { exitCode: [0], stdout: [{ kind: "value", matcher: { contains: "hello" } }] } },
),
makeCtx(),
);
@@ -105,7 +109,7 @@ describe("CommandChecker", () => {
const result = await checker.execute(
makeTarget(
{ args: ["-e", "console.log('hello')"], exec: "bun" },
{ expect: { stdout: [{ contains: "nonexistent" }] } },
{ expect: { exitCode: [0], stdout: [{ kind: "value", matcher: { contains: "nonexistent" } }] } },
),
makeCtx(),
);
@@ -117,7 +121,7 @@ describe("CommandChecker", () => {
const result = await checker.execute(
makeTarget(
{ args: ["-e", "process.stderr.write('error\\n')"], exec: "bun" },
{ expect: { stderr: [{ contains: "error" }] } },
{ expect: { exitCode: [0], stderr: [{ kind: "value", matcher: { contains: "error" } }] } },
),
makeCtx(),
);
@@ -142,7 +146,10 @@ describe("CommandChecker", () => {
test("不使用 shell通配符不被展开", async () => {
const result = await checker.execute(
makeTarget({ args: ["-e", "console.log('*')"], exec: "bun" }, { expect: { stdout: [{ contains: "*" }] } }),
makeTarget(
{ args: ["-e", "console.log('*')"], exec: "bun" },
{ expect: { exitCode: [0], stdout: [{ kind: "value", matcher: { contains: "*" } }] } },
),
makeCtx(),
);
expect(result.matched).toBe(true);
@@ -156,7 +163,7 @@ describe("CommandChecker", () => {
env: { ...processEnv, DIAL_TEST_ENV: "resolved-env" },
exec: "bun",
},
{ expect: { stdout: [{ contains: "resolved-env" }] } },
{ expect: { exitCode: [0], stdout: [{ kind: "value", matcher: { contains: "resolved-env" } }] } },
),
makeCtx(),
);
@@ -172,4 +179,11 @@ describe("CommandChecker", () => {
expect(config.exec).toBe("bun");
expect(config.args).toEqual(["-e", "console.log('hello')"]);
});
test("resolve 未配置 expect 时物化默认 exitCode", () => {
const result = checker.resolve({ cmd: { exec: "true" }, id: "test", type: "cmd" }, makeResolveContext());
expect(result.rawExpect).toBeUndefined();
expect(result.expect).toEqual({ exitCode: [0] });
});
});

View File

@@ -1,8 +1,15 @@
import { describe, expect, test } from "bun:test";
import type { ResolvedDbTarget } from "../../../../../src/server/checker/runner/db/types";
import type {
RawDbExpectConfig,
ResolvedDbExpectConfig,
ResolvedDbTarget,
} from "../../../../../src/server/checker/runner/db/types";
import type { CheckerContext } from "../../../../../src/server/checker/runner/types";
import { resolveContentExpectations } from "../../../../../src/server/checker/expect/content";
import { resolveKeyedExpectations } from "../../../../../src/server/checker/expect/keyed";
import { resolveValueExpectation } from "../../../../../src/server/checker/expect/value";
import { DbChecker } from "../../../../../src/server/checker/runner/db/execute";
const checker = new DbChecker();
@@ -13,20 +20,31 @@ function makeCtx(timeoutMs = 5000): CheckerContext {
return { signal: controller.signal };
}
function makeTarget(db: Partial<ResolvedDbTarget["db"]>, overrides?: Partial<ResolvedDbTarget>): ResolvedDbTarget {
function makeTarget(db: Partial<ResolvedDbTarget["db"]>, overrides?: { expect?: RawDbExpectConfig }): ResolvedDbTarget {
const raw = overrides?.expect;
const resolvedExpect: ResolvedDbExpectConfig | undefined = raw
? {
durationMs: resolveValueExpectation(raw.durationMs),
result: resolveContentExpectations(raw.result),
rowCount: resolveValueExpectation(raw.rowCount),
rows: raw.rows?.map((row) => resolveKeyedExpectations(row) ?? []),
}
: undefined;
return {
db: {
url: "sqlite://:memory:",
...db,
},
description: null,
expect: resolvedExpect,
group: "default",
id: "test-db",
intervalMs: 60000,
name: "test-db",
rawExpect: raw,
timeoutMs: 5000,
type: "db",
...overrides,
};
}

View File

@@ -1,7 +1,14 @@
import { describe, expect, test } from "bun:test";
import type { KeyedExpectations, RawValueExpectation } from "../../../../../src/server/checker/expect/types";
import { resolveValueExpectation } from "../../../../../src/server/checker/expect/value";
import { checkRowCount, checkRows } from "../../../../../src/server/checker/runner/db/expect";
function row(record: Record<string, RawValueExpectation>): KeyedExpectations {
return Object.entries(record).map(([key, value]) => ({ key, matcher: resolveValueExpectation(value) }));
}
describe("checkRowCount", () => {
test("空数组通过 rowCount gte 0", () => {
const result = checkRowCount(0, { gte: 0 });
@@ -39,7 +46,7 @@ describe("checkRowCount", () => {
describe("checkRows", () => {
test("非数组返回失败", () => {
const result = checkRows(null, [{ col: 1 }]);
const result = checkRows(null, [row({ col: 1 })]);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("row");
expect(result.failure!.path).toBe("rows");
@@ -51,17 +58,17 @@ describe("checkRows", () => {
});
test("单行单列匹配(字面量)", () => {
const result = checkRows([{ col: "value" }], [{ col: "value" }]);
const result = checkRows([{ col: "value" }], [row({ col: "value" })]);
expect(result.matched).toBe(true);
});
test("单行单列匹配operator", () => {
const result = checkRows([{ col: 100 }], [{ col: { gte: 50 } }]);
const result = checkRows([{ col: 100 }], [row({ col: { gte: 50 } })]);
expect(result.matched).toBe(true);
});
test("单行单列不匹配", () => {
const result = checkRows([{ col: 10 }], [{ col: { gte: 50 } }]);
const result = checkRows([{ col: 10 }], [row({ col: { gte: 50 } })]);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("row");
expect(result.failure!.path).toBe("rows[0].col");
@@ -73,62 +80,61 @@ describe("checkRows", () => {
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
],
[{ id: { gte: 1 } }, { name: "Bob" }],
[row({ id: { gte: 1 } }), row({ name: "Bob" })],
);
expect(result.matched).toBe(true);
});
test("多行中有一行不匹配", () => {
const result = checkRows([{ col: 1 }, { col: 2 }], [{ col: { gte: 2 } }, { col: { gte: 3 } }]);
const result = checkRows([{ col: 1 }, { col: 2 }], [row({ col: { gte: 2 } }), row({ col: { gte: 3 } })]);
expect(result.matched).toBe(false);
// 第一行 { col: 1 } 不满足 { gte: 2 },所以失败在第一行
expect(result.failure!.path).toBe("rows[0].col");
});
test("结果行数不足", () => {
const result = checkRows([{ col: 1 }], [{ col: 1 }, { col: 2 }]);
const result = checkRows([{ col: 1 }], [row({ col: 1 }), row({ col: 2 })]);
expect(result.matched).toBe(false);
expect(result.failure!.message).toContain("行数不足");
});
test("只检查声明的列", () => {
const result = checkRows([{ col: 1, other: "ignored" }], [{ col: { gte: 0 } }]);
const result = checkRows([{ col: 1, other: "ignored" }], [row({ col: { gte: 0 } })]);
expect(result.matched).toBe(true);
});
test("行不是对象返回失败", () => {
const result = checkRows(["not-an-object"] as unknown[], [{ col: 1 }]);
const result = checkRows(["not-an-object"] as unknown[], [row({ col: 1 })]);
expect(result.matched).toBe(false);
expect(result.failure!.path).toBe("rows[0]");
});
test("列不存在视为 undefined", () => {
const result = checkRows([{}], [{ col: { exists: false } }]);
const result = checkRows([{}], [row({ col: { exists: false } })]);
expect(result.matched).toBe(true);
});
test("列存在且值为 null", () => {
const result = checkRows([{ col: null }], [{ col: { empty: true } }]);
const result = checkRows([{ col: null }], [row({ col: { empty: true } })]);
expect(result.matched).toBe(true);
});
test("contains 匹配字符串", () => {
const result = checkRows([{ text: "hello world" }], [{ text: { contains: "hello" } }]);
const result = checkRows([{ text: "hello world" }], [row({ text: { contains: "hello" } })]);
expect(result.matched).toBe(true);
});
test("regex 正则匹配", () => {
const result = checkRows([{ code: "ABC-123" }], [{ code: { regex: "^ABC-" } }]);
const result = checkRows([{ code: "ABC-123" }], [row({ code: { regex: "^ABC-" } })]);
expect(result.matched).toBe(true);
});
test("多个断言同时满足", () => {
const result = checkRows([{ val: 50 }], [{ val: { gte: 10, lte: 100 } }]);
const result = checkRows([{ val: 50 }], [row({ val: { gte: 10, lte: 100 } })]);
expect(result.matched).toBe(true);
});
test("多个断言中有一个不满足", () => {
const result = checkRows([{ val: 50 }], [{ val: { gte: 10, lte: 30 } }]);
const result = checkRows([{ val: 50 }], [row({ val: { gte: 10, lte: 30 } })]);
expect(result.matched).toBe(false);
});
});

View File

@@ -1,6 +1,14 @@
import { describe, expect, test } from "bun:test";
import { checkHeaders, checkStatus } from "../../../../../src/server/checker/runner/http/expect";
import type { RawKeyedExpectations } from "../../../../../src/server/checker/expect/types";
import { checkHeaderExpectations } from "../../../../../src/server/checker/expect/headers";
import { resolveKeyedExpectations } from "../../../../../src/server/checker/expect/keyed";
import { checkStatusCode } from "../../../../../src/server/checker/expect/status";
function checkHeaders(headers: Record<string, unknown>, raw?: RawKeyedExpectations) {
return checkHeaderExpectations(headers, resolveKeyedExpectations(raw));
}
describe("checkHeaders", () => {
test("未配置 headers expect 时匹配成功", () => {
@@ -46,15 +54,15 @@ describe("checkHeaders", () => {
});
});
describe("checkStatus 范围匹配", () => {
test("无 expect 配置时默认 status [200] 可由调用方使用 checkStatus 表达", () => {
const result = checkStatus(200, [200]);
describe("checkStatusCode 范围匹配", () => {
test("无 expect 配置时默认 status [200] 可由调用方使用 checkStatusCode 表达", () => {
const result = checkStatusCode(200, [200]);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
test("status 不匹配返回 phase=status 的失败", () => {
const result = checkStatus(503, [200]);
const result = checkStatusCode(503, [200]);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("status");
expect(result.failure!.expected).toEqual([200]);
@@ -62,47 +70,47 @@ describe("checkStatus 范围匹配", () => {
});
test("2xx 范围匹配 200", () => {
expect(checkStatus(200, ["2xx"]).matched).toBe(true);
expect(checkStatusCode(200, ["2xx"]).matched).toBe(true);
});
test("2xx 范围匹配 204", () => {
expect(checkStatus(204, ["2xx"]).matched).toBe(true);
expect(checkStatusCode(204, ["2xx"]).matched).toBe(true);
});
test("2xx 范围不匹配 301", () => {
expect(checkStatus(301, ["2xx"]).matched).toBe(false);
expect(checkStatusCode(301, ["2xx"]).matched).toBe(false);
});
test("5xx 范围匹配 503", () => {
expect(checkStatus(503, ["5xx"]).matched).toBe(true);
expect(checkStatusCode(503, ["5xx"]).matched).toBe(true);
});
test("混合精确值与范围模式命中精确值", () => {
expect(checkStatus(301, ["2xx", 301]).matched).toBe(true);
expect(checkStatusCode(301, ["2xx", 301]).matched).toBe(true);
});
test("混合精确值与范围模式命中范围", () => {
expect(checkStatus(204, ["2xx", 301]).matched).toBe(true);
expect(checkStatusCode(204, ["2xx", 301]).matched).toBe(true);
});
test("混合模式都不匹配", () => {
expect(checkStatus(404, ["2xx", 301]).matched).toBe(false);
expect(checkStatusCode(404, ["2xx", 301]).matched).toBe(false);
});
test("纯精确值仍正常工作", () => {
expect(checkStatus(200, [200, 201]).matched).toBe(true);
expect(checkStatus(404, [200, 201]).matched).toBe(false);
expect(checkStatusCode(200, [200, 201]).matched).toBe(true);
expect(checkStatusCode(404, [200, 201]).matched).toBe(false);
});
test("1xx 范围匹配 101", () => {
expect(checkStatus(101, ["1xx"]).matched).toBe(true);
expect(checkStatusCode(101, ["1xx"]).matched).toBe(true);
});
test("3xx 范围匹配 301", () => {
expect(checkStatus(301, ["3xx"]).matched).toBe(true);
expect(checkStatusCode(301, ["3xx"]).matched).toBe(true);
});
test("4xx 范围匹配 404", () => {
expect(checkStatus(404, ["4xx"]).matched).toBe(true);
expect(checkStatusCode(404, ["4xx"]).matched).toBe(true);
});
});

View File

@@ -1,13 +1,22 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { ResolvedHttpTarget } from "../../../../../src/server/checker/runner/http/types";
import type {
RawHttpExpectConfig,
ResolvedHttpExpectConfig,
ResolvedHttpTarget,
} from "../../../../../src/server/checker/runner/http/types";
import type { CheckerContext, ResolveContext } from "../../../../../src/server/checker/runner/types";
import { resolveContentExpectations } from "../../../../../src/server/checker/expect/content";
import { resolveKeyedExpectations } from "../../../../../src/server/checker/expect/keyed";
import { checkStatusCode } from "../../../../../src/server/checker/expect/status";
import { resolveValueExpectation } from "../../../../../src/server/checker/expect/value";
import { HttpChecker } from "../../../../../src/server/checker/runner/http/execute";
import { checkStatus } from "../../../../../src/server/checker/runner/http/expect";
import { formatConfigIssues } from "../../../../../src/server/checker/schema/issues";
const checker = new HttpChecker();
const SLOW_BODY_DELAY_MS = 1000;
const FAST_RESPONSE_LIMIT_MS = 500;
function validateHttpTarget(target: unknown): string {
return formatConfigIssues(checker.validate({ defaults: {}, targets: [target as never] }));
@@ -145,7 +154,7 @@ describe("HttpChecker", () => {
function makeTarget(overrides: {
body?: string;
expect?: Record<string, unknown>;
expect?: RawHttpExpectConfig;
headers?: Record<string, string>;
ignoreSSL?: boolean;
maxBodyBytes?: number;
@@ -154,9 +163,19 @@ describe("HttpChecker", () => {
timeoutMs?: number;
url?: string;
}): ResolvedHttpTarget {
const raw = overrides.expect;
const resolvedExpect: ResolvedHttpExpectConfig | undefined = raw
? {
body: resolveContentExpectations(raw.body),
durationMs: resolveValueExpectation(raw.durationMs),
headers: resolveKeyedExpectations(raw.headers),
status: raw.status ?? [200],
}
: undefined;
return {
description: null,
expect: overrides.expect,
expect: resolvedExpect,
group: "default",
http: {
body: overrides.body,
@@ -170,6 +189,7 @@ describe("HttpChecker", () => {
id: "test-http",
intervalMs: 60000,
name: "test-http",
rawExpect: raw,
timeoutMs: overrides.timeoutMs ?? 5000,
type: "http",
};
@@ -181,6 +201,29 @@ describe("HttpChecker", () => {
return { signal: controller.signal };
}
function startSlowBodyServer(init: ResponseInit = {}) {
return Bun.serve({
fetch() {
let sentFirstChunk = false;
return new Response(
new ReadableStream<Uint8Array>({
async pull(controller) {
if (!sentFirstChunk) {
sentFirstChunk = true;
controller.enqueue(new TextEncoder().encode("slow body"));
return;
}
await new Promise((resolve) => setTimeout(resolve, SLOW_BODY_DELAY_MS));
controller.close();
},
}),
init,
);
},
port: 0,
});
}
test("成功请求 200", async () => {
const result = await checker.execute(makeTarget({ url: `${baseUrl}/ok` }), makeCtx());
expect(result.matched).toBe(true);
@@ -192,7 +235,7 @@ describe("HttpChecker", () => {
test("404 不匹配默认 status [200]", async () => {
const result = await checker.execute(makeTarget({ url: `${baseUrl}/notfound` }), makeCtx());
expect(result.matched).toBe(false);
expect(result.observation).toMatchObject({ bodyPreview: "not found", statusCode: 404 });
expect(result.observation).toMatchObject({ bodyPreview: null, statusCode: 404 });
expect(result.failure!.phase).toBe("status");
});
@@ -218,7 +261,7 @@ describe("HttpChecker", () => {
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.observation).toMatchObject({ bodyPreview: "hello world", statusCode: 200 });
expect(result.observation).toMatchObject({ bodyPreview: null, statusCode: 200 });
expect(result.failure!.phase).toBe("headers");
});
@@ -279,12 +322,43 @@ describe("HttpChecker", () => {
});
test("快速失败status 失败时不读取 body", async () => {
const slowBodyServer = startSlowBodyServer({ status: 404 });
try {
const start = performance.now();
const result = await checker.execute(
makeTarget({ expect: { body: [{ contains: "something" }], status: [200] }, url: `${baseUrl}/notfound` }),
makeTarget({
expect: { body: [{ contains: "something" }], status: [200] },
url: `http://localhost:${slowBodyServer.port}/`,
}),
makeCtx(),
);
expect(performance.now() - start).toBeLessThan(FAST_RESPONSE_LIMIT_MS);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("status");
expect(result.observation?.["bodyPreview"]).toBeNull();
} finally {
void slowBodyServer.stop();
}
});
test("快速失败headers 失败时不读取 body", async () => {
const slowBodyServer = startSlowBodyServer({ headers: { "x-custom": "actual" }, status: 200 });
try {
const start = performance.now();
const result = await checker.execute(
makeTarget({
expect: { body: [{ contains: "something" }], headers: { "x-custom": "expected" }, status: [200] },
url: `http://localhost:${slowBodyServer.port}/`,
}),
makeCtx(),
);
expect(performance.now() - start).toBeLessThan(FAST_RESPONSE_LIMIT_MS);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("headers");
expect(result.observation?.["bodyPreview"]).toBeNull();
} finally {
void slowBodyServer.stop();
}
});
test("status 通过但 body 失败", async () => {
@@ -511,12 +585,40 @@ describe("HttpChecker", () => {
}
});
test("无 body rules 时不读取 body", async () => {
test("无 body expectations 时不读取 body", async () => {
const slowBodyServer = startSlowBodyServer({ status: 200 });
try {
const start = performance.now();
const result = await checker.execute(
makeTarget({ expect: { status: [200] }, maxBodyBytes: 1, url: `${baseUrl}/large` }),
makeTarget({ expect: { status: [200] }, maxBodyBytes: 1, url: `http://localhost:${slowBodyServer.port}/` }),
makeCtx(),
);
expect(performance.now() - start).toBeLessThan(FAST_RESPONSE_LIMIT_MS);
expect(result.matched).toBe(true);
expect(result.observation?.["bodyPreview"]).toBeNull();
} finally {
void slowBodyServer.stop();
}
});
test("进入 body 前 durationMs 上界已失败时不读取 body", async () => {
const slowBodyServer = startSlowBodyServer({ status: 200 });
try {
const start = performance.now();
const result = await checker.execute(
makeTarget({
expect: { body: [{ contains: "slow" }], durationMs: { lte: 0 }, status: [200] },
url: `http://localhost:${slowBodyServer.port}/`,
}),
makeCtx(),
);
expect(performance.now() - start).toBeLessThan(FAST_RESPONSE_LIMIT_MS);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("duration");
expect(result.observation?.["bodyPreview"]).toBeNull();
} finally {
void slowBodyServer.stop();
}
});
test("body 失败优先于 duration 检查", async () => {
@@ -598,7 +700,7 @@ describe("HttpChecker", () => {
expect(result.observation).toMatchObject({ statusCode: 200 });
});
test("混合 body rules 集成检查", async () => {
test("混合 body expectations 集成检查", async () => {
const result = await checker.execute(
makeTarget({
expect: {
@@ -649,7 +751,7 @@ describe("HttpChecker", () => {
});
test("1xx 范围模式匹配 101", () => {
const r = checkStatus(101, ["1xx"]);
const r = checkStatusCode(101, ["1xx"]);
expect(r.matched).toBe(true);
});
@@ -868,6 +970,16 @@ describe("HttpChecker.resolve", () => {
expect(result.http.ignoreSSL).toBe(false);
});
test("未配置 expect 时在 Resolved 模型物化默认 status", () => {
const result = checker.resolve(
{ http: { url: "https://example.com" }, id: "test", name: "test", type: "http" },
makeResolveContext(),
);
expect(result.rawExpect).toBeUndefined();
expect(result.expect).toEqual({ status: [200] });
});
test("maxRedirects 默认值为 0", () => {
const result = checker.resolve(
{ http: { url: "https://example.com" }, id: "test", name: "test", type: "http" },

View File

@@ -84,7 +84,10 @@ rtt min/avg/max/mdev = 1.234/2.345/3.456/0.567 ms`);
test("packetLoss 断言失败", async () => {
mockSpawn(`3 packets transmitted, 2 received, 33% packet loss, time 2003ms
rtt min/avg/max/mdev = 1.234/156.000/340.000/0.567 ms`);
const result = await checker.execute(makeTarget({ expect: { packetLossPercent: { lte: 10 } } }), makeCtx());
const result = await checker.execute(
makeTarget({ expect: { alive: true, packetLossPercent: { lte: 10 } } }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("packetLoss");
expect(result.observation).toMatchObject({ alive: true, maxLatencyMs: 340 });
@@ -125,6 +128,8 @@ describe("IcmpChecker resolve", () => {
);
expect(target.icmp).toEqual({ count: 3, host: "10.0.0.1", packetSize: 56 });
expect(target.group).toBe("default");
expect(target.rawExpect).toBeUndefined();
expect(target.expect).toEqual({ alive: true });
});
test("serialize 返回摘要和配置", () => {

View File

@@ -1,8 +1,15 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { ResolvedLlmTarget } from "../../../../../src/server/checker/runner/llm/types";
import type {
RawLlmExpectConfig,
ResolvedLlmExpectConfig,
ResolvedLlmTarget,
} from "../../../../../src/server/checker/runner/llm/types";
import type { CheckerContext } from "../../../../../src/server/checker/runner/types";
import { resolveContentExpectations } from "../../../../../src/server/checker/expect/content";
import { resolveKeyedExpectations } from "../../../../../src/server/checker/expect/keyed";
import { resolveValueExpectation } from "../../../../../src/server/checker/expect/value";
import { LlmChecker } from "../../../../../src/server/checker/runner/llm/execute";
const MOCK_PORT = 18456;
@@ -14,13 +21,34 @@ function makeCtx(timeoutMs = 10000): CheckerContext {
return { signal: controller.signal };
}
function makeTarget(
overrides?: Partial<ResolvedLlmTarget["llm"]>,
expectOverrides?: Partial<ResolvedLlmTarget["expect"]>,
): ResolvedLlmTarget {
function makeTarget(overrides?: Partial<ResolvedLlmTarget["llm"]>, rawExpect?: RawLlmExpectConfig): ResolvedLlmTarget {
const resolvedExpect: ResolvedLlmExpectConfig | undefined = rawExpect
? {
durationMs: resolveValueExpectation(rawExpect.durationMs),
finishReason: resolveValueExpectation(rawExpect.finishReason),
headers: resolveKeyedExpectations(rawExpect.headers),
output: resolveContentExpectations(rawExpect.output),
rawFinishReason: resolveValueExpectation(rawExpect.rawFinishReason),
status: rawExpect.status ?? [200],
stream: rawExpect.stream
? {
completed: rawExpect.stream.completed ?? true,
firstTokenMs: resolveValueExpectation(rawExpect.stream.firstTokenMs),
}
: undefined,
usage: rawExpect.usage
? {
inputTokens: resolveValueExpectation(rawExpect.usage.inputTokens),
outputTokens: resolveValueExpectation(rawExpect.usage.outputTokens),
totalTokens: resolveValueExpectation(rawExpect.usage.totalTokens),
}
: undefined,
}
: undefined;
return {
description: null,
expect: expectOverrides,
expect: resolvedExpect,
group: "default",
id: "test-llm",
intervalMs: 30000,
@@ -38,6 +66,7 @@ function makeTarget(
...overrides,
},
name: null,
rawExpect,
timeoutMs: 10000,
type: "llm",
};

View File

@@ -1,12 +1,32 @@
import { describe, expect, test } from "bun:test";
import type { LlmCheckObservation } from "../../../../../src/server/checker/runner/llm/types";
import type {
RawContentExpectations,
RawKeyedExpectations,
RawValueExpectation,
} from "../../../../../src/server/checker/expect/types";
import type { LlmCheckObservation, ResolvedLlmExpectConfig } from "../../../../../src/server/checker/runner/llm/types";
import { checkContentRules } from "../../../../../src/server/checker/expect/content";
import { checkContentExpectations, resolveContentExpectations } from "../../../../../src/server/checker/expect/content";
import { resolveKeyedExpectations } from "../../../../../src/server/checker/expect/keyed";
import { resolveValueExpectation } from "../../../../../src/server/checker/expect/value";
import { runExpects } from "../../../../../src/server/checker/runner/llm/expect";
function checkOutputRules(outputText: null | string, rules: Parameters<typeof checkContentRules>[1]) {
return checkContentRules(outputText, rules, { path: "output", phase: "output" });
interface RawLlmExpectInput {
finishReason?: RawValueExpectation;
headers?: RawKeyedExpectations;
output?: RawContentExpectations;
rawFinishReason?: RawValueExpectation;
status?: Array<number | string>;
stream?: { completed?: boolean; firstTokenMs?: RawValueExpectation };
usage?: { inputTokens?: RawValueExpectation; outputTokens?: RawValueExpectation; totalTokens?: RawValueExpectation };
}
function checkOutputRules(outputText: null | string, rawRules: RawContentExpectations | undefined) {
return checkContentExpectations(outputText, resolveContentExpectations(rawRules), {
path: "output",
phase: "output",
});
}
function makeObservation(overrides?: Partial<LlmCheckObservation>): LlmCheckObservation {
@@ -25,7 +45,31 @@ function makeObservation(overrides?: Partial<LlmCheckObservation>): LlmCheckObse
};
}
describe("LLM output rules", () => {
function resolveLlmExpect(raw: RawLlmExpectInput | undefined): ResolvedLlmExpectConfig | undefined {
if (raw === undefined) return undefined;
return {
finishReason: resolveValueExpectation(raw.finishReason),
headers: resolveKeyedExpectations(raw.headers),
output: resolveContentExpectations(raw.output),
rawFinishReason: resolveValueExpectation(raw.rawFinishReason),
status: raw.status ?? [200],
stream: raw.stream
? {
completed: raw.stream.completed ?? true,
firstTokenMs: resolveValueExpectation(raw.stream.firstTokenMs),
}
: undefined,
usage: raw.usage
? {
inputTokens: resolveValueExpectation(raw.usage.inputTokens),
outputTokens: resolveValueExpectation(raw.usage.outputTokens),
totalTokens: resolveValueExpectation(raw.usage.totalTokens),
}
: undefined,
};
}
describe("LLM output expectations", () => {
test("equals 严格匹配", () => {
expect(checkOutputRules("OK", [{ equals: "OK" }]).matched).toBe(true);
expect(checkOutputRules("OK\n", [{ equals: "OK" }]).matched).toBe(false);
@@ -66,7 +110,7 @@ describe("LLM output rules", () => {
expect(result.failure?.phase).toBe("output");
});
test("undefined rules 返回通过", () => {
test("undefined expectations 返回通过", () => {
expect(checkOutputRules("anything", undefined).matched).toBe(true);
expect(checkOutputRules(null, undefined).matched).toBe(true);
});
@@ -75,11 +119,14 @@ describe("LLM output rules", () => {
describe("LLM runExpects", () => {
test("全部 expect 通过", () => {
const observation = makeObservation();
const result = runExpects(observation, {
const result = runExpects(
observation,
resolveLlmExpect({
finishReason: { equals: "stop" },
output: [{ contains: "OK" }],
status: [200],
});
}),
);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
@@ -92,35 +139,35 @@ describe("LLM runExpects", () => {
test("status 不匹配失败", () => {
const observation = makeObservation();
const result = runExpects(observation, { status: [404] });
const result = runExpects(observation, resolveLlmExpect({ status: [404] }));
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("status");
});
test("finishReason 不匹配失败", () => {
const observation = makeObservation();
const result = runExpects(observation, { finishReason: { equals: "length" } });
const result = runExpects(observation, resolveLlmExpect({ finishReason: { equals: "length" } }));
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("finishReason");
});
test("rawFinishReason 不匹配失败", () => {
const observation = makeObservation();
const result = runExpects(observation, { rawFinishReason: { equals: "end_turn" } });
const result = runExpects(observation, resolveLlmExpect({ rawFinishReason: { equals: "end_turn" } }));
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("rawFinishReason");
});
test("usage 不匹配失败", () => {
const observation = makeObservation();
const result = runExpects(observation, { usage: { totalTokens: { gte: 100 } } });
const result = runExpects(observation, resolveLlmExpect({ usage: { totalTokens: { gte: 100 } } }));
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("usage");
});
test("usage 匹配通过", () => {
const observation = makeObservation();
const result = runExpects(observation, { usage: { totalTokens: { lte: 20 } } });
const result = runExpects(observation, resolveLlmExpect({ usage: { totalTokens: { lte: 20 } } }));
expect(result.matched).toBe(true);
});
@@ -129,9 +176,12 @@ describe("LLM runExpects", () => {
mode: "stream",
stream: { completed: true, firstTokenMs: 500 },
});
const result = runExpects(observation, {
const result = runExpects(
observation,
resolveLlmExpect({
stream: { completed: true },
});
}),
);
expect(result.matched).toBe(true);
});
@@ -140,9 +190,12 @@ describe("LLM runExpects", () => {
mode: "stream",
stream: { completed: true, firstTokenMs: 500 },
});
const result = runExpects(observation, {
const result = runExpects(
observation,
resolveLlmExpect({
stream: { firstTokenMs: { lte: 1000 } },
});
}),
);
expect(result.matched).toBe(true);
});
@@ -151,9 +204,12 @@ describe("LLM runExpects", () => {
mode: "stream",
stream: { completed: true, firstTokenMs: null },
});
const result = runExpects(observation, {
const result = runExpects(
observation,
resolveLlmExpect({
stream: { firstTokenMs: { lte: 1000 } },
});
}),
);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("stream");
});
@@ -162,9 +218,12 @@ describe("LLM runExpects", () => {
const observation = makeObservation({
http: { headers: { "content-type": "application/json" }, status: 200, statusText: "OK" },
});
const result = runExpects(observation, {
const result = runExpects(
observation,
resolveLlmExpect({
headers: { "content-type": "application/json" },
});
}),
);
expect(result.matched).toBe(true);
});
@@ -172,19 +231,25 @@ describe("LLM runExpects", () => {
const observation = makeObservation({
http: { headers: { "content-type": "text/plain" }, status: 200, statusText: "OK" },
});
const result = runExpects(observation, {
const result = runExpects(
observation,
resolveLlmExpect({
headers: { "content-type": "application/json" },
});
}),
);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("headers");
});
test("首个 expect 失败立即返回", () => {
const observation = makeObservation();
const result = runExpects(observation, {
const result = runExpects(
observation,
resolveLlmExpect({
output: [{ contains: "OK" }],
status: [404],
});
}),
);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("status");
});
@@ -196,7 +261,7 @@ describe("LLM runExpects", () => {
outputText: null,
usage: null,
});
const result = runExpects(observation, { status: [401] });
const result = runExpects(observation, resolveLlmExpect({ status: [401] }));
expect(result.matched).toBe(true);
});
});

View File

@@ -214,7 +214,7 @@ describe("LlmChecker validate", () => {
defaults: {},
targets: [makeRawTarget({ expect: { output: [{ contains: "y", json: { path: "$.status" } }] } })],
});
expect(issues.some((i) => i.code === "invalid-content-rule")).toBe(true);
expect(issues.some((i) => i.code === "invalid-content-expectation")).toBe(true);
});
test("expect.output regex ReDoS 报错", () => {
@@ -284,6 +284,44 @@ describe("LlmChecker resolve", () => {
expect(resolved.group).toBe("default");
expect(resolved.intervalMs).toBe(30000);
expect(resolved.timeoutMs).toBe(10000);
expect(resolved.rawExpect).toBeUndefined();
expect(resolved.expect).toEqual({ status: [200] });
});
test("stream mode 未配置 expect.stream 时不物化 completed", () => {
const raw = makeRawTarget({
expect: { output: [{ contains: "OK" }] },
llm: {
mode: "stream",
model: "gpt-4o-mini",
prompt: "Say OK",
provider: "openai",
url: "https://api.openai.com/v1",
},
});
const resolved = asLlm(checker.resolve(raw, makeResolveContext()));
expect(resolved.rawExpect).toEqual({ output: [{ contains: "OK" }] });
expect(resolved.expect?.stream).toBeUndefined();
});
test("配置 expect.stream 但省略 completed 时默认 true", () => {
const raw = makeRawTarget({
expect: { stream: { firstTokenMs: 100 } },
llm: {
mode: "stream",
model: "gpt-4o-mini",
prompt: "Say OK",
provider: "openai",
url: "https://api.openai.com/v1",
},
});
const resolved = asLlm(checker.resolve(raw, makeResolveContext()));
expect(resolved.rawExpect).toEqual({ stream: { firstTokenMs: 100 } });
expect(resolved.expect?.stream).toEqual({ completed: true, firstTokenMs: { equals: 100 } });
});
test("defaults.llm 与 target.llm 浅合并", () => {

View File

@@ -1,12 +1,15 @@
import { describe, expect, test } from "bun:test";
import { checkContentRules } from "../../../../../src/server/checker/expect/content";
import type { RawContentExpectations } from "../../../../../src/server/checker/expect/types";
function checkBodyExpect(body: string, rules?: Parameters<typeof checkContentRules>[1]) {
return checkContentRules(body, rules, { path: "body", phase: "body" });
import { checkContentExpectations, resolveContentExpectations } from "../../../../../src/server/checker/expect/content";
function checkBodyExpect(body: string, rawExpectations?: RawContentExpectations) {
const resolved = resolveContentExpectations(rawExpectations);
return checkContentExpectations(body, resolved, { path: "body", phase: "body" });
}
describe("checkBodyExpect (BodyRule[])", () => {
describe("checkBodyExpect (ContentExpectations)", () => {
test("无规则返回匹配成功", () => {
const r = checkBodyExpect("anything");
expect(r.matched).toBe(true);
@@ -19,6 +22,22 @@ describe("checkBodyExpect (BodyRule[])", () => {
expect(r.failure).toBeNull();
});
test("resolve 输出显式 kind union 并物化 extractor 默认 exists", () => {
expect(
resolveContentExpectations([
{ contains: "ok" },
{ json: { path: "$.status" } },
{ css: { attr: "content", selector: "meta[name=status]" } },
{ xpath: { path: "/root/status" } },
]),
).toEqual([
{ kind: "value", matcher: { contains: "ok" } },
{ kind: "json", matcher: { exists: true }, path: "$.status" },
{ attr: "content", kind: "css", matcher: { exists: true }, selector: "meta[name=status]" },
{ kind: "xpath", matcher: { exists: true }, path: "/root/status" },
]);
});
test("contains 规则匹配成功", () => {
const r = checkBodyExpect("hello world", [{ contains: "hello" }]);
expect(r.matched).toBe(true);

View File

@@ -0,0 +1,65 @@
import { describe, expect, test } from "bun:test";
import type { CheckerValidationInput } from "../../../../../src/server/checker/runner/types";
import { validateDbConfig } from "../../../../../src/server/checker/runner/db/validate";
import { validateHttpConfig } from "../../../../../src/server/checker/runner/http/validate";
import { validateLlmConfig } from "../../../../../src/server/checker/runner/llm/validate";
function input(target: Record<string, unknown>): CheckerValidationInput {
return { defaults: {}, targets: [target as CheckerValidationInput["targets"][number]] };
}
describe("HTTP/LLM headers reject case-insensitive duplicate keys", () => {
test("HTTP headers 大小写不同的重复 key 报错", () => {
const target = {
expect: { headers: { "Content-Type": "application/json", "content-type": "text/plain" } },
http: { url: "https://example.com" },
id: "dup",
type: "http",
};
const issues = validateHttpConfig(input(target));
expect(issues.some((i) => i.code === "duplicate-key" && i.path.includes("headers"))).toBe(true);
});
test("LLM headers 大小写不同的重复 key 报错", () => {
const target = {
expect: { headers: { "X-Trace": "a", "x-trace": "b" } },
id: "dup",
llm: {
mode: "stream",
model: "test-model",
prompt: "hello",
provider: "openai",
url: "https://example.com/v1/chat/completions",
},
type: "llm",
};
const issues = validateLlmConfig(input(target));
expect(issues.some((i) => i.code === "duplicate-key" && i.path.includes("headers"))).toBe(true);
});
test("HTTP headers 不同 key 不触发 duplicate-key", () => {
const target = {
expect: { headers: { Accept: "application/json", "Content-Type": "application/json" } },
http: { url: "https://example.com" },
id: "ok",
type: "http",
};
expect(validateHttpConfig(input(target)).some((i) => i.code === "duplicate-key")).toBe(false);
});
test("DB rows 保留大小写敏感不触发 duplicate-key", () => {
const target = {
db: { query: "SELECT 1", url: "sqlite://:memory:" },
expect: { rows: [{ Name: "a", name: "b" }] },
id: "dup-rows",
type: "db",
};
expect(validateDbConfig(input(target)).some((i) => i.code === "duplicate-key")).toBe(false);
});
});

View File

@@ -1,9 +1,9 @@
import { describe, expect, test } from "bun:test";
import { checkValueMatcher } from "../../../../../src/server/checker/expect/matcher";
import { checkValueExpectation } from "../../../../../src/server/checker/expect/value";
function checkDuration(durationMs: number, maxDurationMs?: number) {
return checkValueMatcher(durationMs, maxDurationMs === undefined ? undefined : { lte: maxDurationMs }, {
return checkValueExpectation(durationMs, maxDurationMs === undefined ? undefined : { lte: maxDurationMs }, {
path: "durationMs",
phase: "duration",
});

View File

@@ -0,0 +1,34 @@
import { describe, expect, test } from "bun:test";
import { checkKeyedExpectations, resolveKeyedExpectations } from "../../../../../src/server/checker/expect/keyed";
describe("KeyedExpectations", () => {
test("resolve 将 Raw Record 转为保持顺序的有序数组", () => {
expect(resolveKeyedExpectations({ Count: { gte: 1 }, Name: "alice" })).toEqual([
{ key: "Count", matcher: { gte: 1 } },
{ key: "Name", matcher: { equals: "alice" } },
]);
});
test("failure.path 包含基础路径和原始 key", () => {
const result = checkKeyedExpectations(
{ "content-type": "text/plain" },
resolveKeyedExpectations({ "Content-Type": { contains: "json" } }),
{ normalizeKey: (key) => key.toLowerCase(), path: "headers", phase: "headers" },
);
expect(result.matched).toBe(false);
expect(result.failure?.path).toBe("headers.Content-Type");
});
test("DB rows 默认保持大小写敏感匹配", () => {
const expectations = resolveKeyedExpectations({ Name: "alice", name: "bob" });
expect(
checkKeyedExpectations({ Name: "alice", name: "bob" }, expectations, { path: "rows[0]", phase: "rows" }).matched,
).toBe(true);
expect(
checkKeyedExpectations({ Name: "bob", name: "alice" }, expectations, { path: "rows[0]", phase: "rows" }).matched,
).toBe(false);
});
});

View File

@@ -1,6 +1,12 @@
import { describe, expect, test } from "bun:test";
import { applyMatcher, checkExpectValue, evaluateJsonPath } from "../../../../../src/server/checker/expect/matcher";
import {
applyValueMatcher,
checkValueExpectation,
displayValueExpectation,
evaluateJsonPath,
resolveValueExpectation,
} from "../../../../../src/server/checker/expect/value";
describe("evaluateJsonPath", () => {
const obj = {
@@ -55,97 +61,115 @@ describe("evaluateJsonPath", () => {
});
});
describe("applyMatcher", () => {
describe("applyValueMatcher", () => {
test("equals 操作符", () => {
expect(applyMatcher("ok", { equals: "ok" })).toBe(true);
expect(applyMatcher("ok", { equals: "error" })).toBe(false);
expect(applyMatcher(42, { equals: 42 })).toBe(true);
expect(applyMatcher(42, { equals: 41 })).toBe(false);
expect(applyMatcher(null, { equals: null })).toBe(true);
expect(applyMatcher(true, { equals: true })).toBe(true);
expect(applyValueMatcher("ok", { equals: "ok" })).toBe(true);
expect(applyValueMatcher("ok", { equals: "error" })).toBe(false);
expect(applyValueMatcher(42, { equals: 42 })).toBe(true);
expect(applyValueMatcher(42, { equals: 41 })).toBe(false);
expect(applyValueMatcher(null, { equals: null })).toBe(true);
expect(applyValueMatcher(true, { equals: true })).toBe(true);
});
test("equals 支持 JSON 对象和数组", () => {
expect(applyMatcher({ status: "ok" }, { equals: { status: "ok" } })).toBe(true);
expect(applyMatcher({ status: "ok" }, { equals: { status: "fail" } })).toBe(false);
expect(applyMatcher(["a", "b"], { equals: ["a", "b"] })).toBe(true);
expect(applyMatcher(["a", "b"], { equals: ["b", "a"] })).toBe(false);
expect(applyValueMatcher({ status: "ok" }, { equals: { status: "ok" } })).toBe(true);
expect(applyValueMatcher({ status: "ok" }, { equals: { status: "fail" } })).toBe(false);
expect(applyValueMatcher(["a", "b"], { equals: ["a", "b"] })).toBe(true);
expect(applyValueMatcher(["a", "b"], { equals: ["b", "a"] })).toBe(false);
});
test("contains 操作符", () => {
expect(applyMatcher("hello world", { contains: "hello" })).toBe(true);
expect(applyMatcher("hello world", { contains: "missing" })).toBe(false);
expect(applyMatcher(12345, { contains: "23" })).toBe(true);
expect(applyValueMatcher("hello world", { contains: "hello" })).toBe(true);
expect(applyValueMatcher("hello world", { contains: "missing" })).toBe(false);
expect(applyValueMatcher(12345, { contains: "23" })).toBe(true);
});
test("regex matcher", () => {
expect(applyMatcher("v2.1.0", { regex: "\\d+\\.\\d+\\.\\d+" })).toBe(true);
expect(applyMatcher("v2.1", { regex: "\\d+\\.\\d+\\.\\d+" })).toBe(false);
expect(applyMatcher("abc123", { regex: "^\\w+\\d+$" })).toBe(true);
expect(applyValueMatcher("v2.1.0", { regex: "\\d+\\.\\d+\\.\\d+" })).toBe(true);
expect(applyValueMatcher("v2.1", { regex: "\\d+\\.\\d+\\.\\d+" })).toBe(false);
expect(applyValueMatcher("abc123", { regex: "^\\w+\\d+$" })).toBe(true);
});
test("empty 操作符", () => {
expect(applyMatcher("", { empty: true })).toBe(true);
expect(applyMatcher(null, { empty: true })).toBe(true);
expect(applyMatcher(undefined, { empty: true })).toBe(true);
expect(applyMatcher([], { empty: true })).toBe(true);
expect(applyMatcher({}, { empty: true })).toBe(true);
expect(applyMatcher("ok", { empty: true })).toBe(false);
expect(applyMatcher(0, { empty: true })).toBe(false);
expect(applyMatcher(false, { empty: true })).toBe(false);
expect(applyMatcher([1, 2], { empty: false })).toBe(true);
expect(applyMatcher([], { empty: false })).toBe(false);
expect(applyValueMatcher("", { empty: true })).toBe(true);
expect(applyValueMatcher(null, { empty: true })).toBe(true);
expect(applyValueMatcher(undefined, { empty: true })).toBe(true);
expect(applyValueMatcher([], { empty: true })).toBe(true);
expect(applyValueMatcher({}, { empty: true })).toBe(true);
expect(applyValueMatcher("ok", { empty: true })).toBe(false);
expect(applyValueMatcher(0, { empty: true })).toBe(false);
expect(applyValueMatcher(false, { empty: true })).toBe(false);
expect(applyValueMatcher([1, 2], { empty: false })).toBe(true);
expect(applyValueMatcher([], { empty: false })).toBe(false);
});
test("exists 操作符", () => {
expect(applyMatcher("ok", { exists: true })).toBe(true);
expect(applyMatcher(null, { exists: true })).toBe(true);
expect(applyMatcher(undefined, { exists: true })).toBe(false);
expect(applyMatcher(undefined, { exists: false })).toBe(true);
expect(applyMatcher("ok", { exists: false })).toBe(false);
expect(applyValueMatcher("ok", { exists: true })).toBe(true);
expect(applyValueMatcher(null, { exists: true })).toBe(true);
expect(applyValueMatcher(undefined, { exists: true })).toBe(false);
expect(applyValueMatcher(undefined, { exists: false })).toBe(true);
expect(applyValueMatcher("ok", { exists: false })).toBe(false);
});
test("gte 操作符", () => {
expect(applyMatcher(10, { gte: 5 })).toBe(true);
expect(applyMatcher(5, { gte: 5 })).toBe(true);
expect(applyMatcher(3, { gte: 5 })).toBe(false);
expect(applyMatcher("10", { gte: 5 })).toBe(true);
expect(applyValueMatcher(10, { gte: 5 })).toBe(true);
expect(applyValueMatcher(5, { gte: 5 })).toBe(true);
expect(applyValueMatcher(3, { gte: 5 })).toBe(false);
expect(applyValueMatcher("10", { gte: 5 })).toBe(true);
});
test("lte 操作符", () => {
expect(applyMatcher(3, { lte: 5 })).toBe(true);
expect(applyMatcher(5, { lte: 5 })).toBe(true);
expect(applyMatcher(10, { lte: 5 })).toBe(false);
expect(applyValueMatcher(3, { lte: 5 })).toBe(true);
expect(applyValueMatcher(5, { lte: 5 })).toBe(true);
expect(applyValueMatcher(10, { lte: 5 })).toBe(false);
});
test("gt 操作符", () => {
expect(applyMatcher(10, { gt: 5 })).toBe(true);
expect(applyMatcher(5, { gt: 5 })).toBe(false);
expect(applyValueMatcher(10, { gt: 5 })).toBe(true);
expect(applyValueMatcher(5, { gt: 5 })).toBe(false);
});
test("lt 操作符", () => {
expect(applyMatcher(3, { lt: 5 })).toBe(true);
expect(applyMatcher(5, { lt: 5 })).toBe(false);
expect(applyValueMatcher(3, { lt: 5 })).toBe(true);
expect(applyValueMatcher(5, { lt: 5 })).toBe(false);
});
test("多操作符 AND 组合", () => {
expect(applyMatcher(7, { gte: 5, lte: 10 })).toBe(true);
expect(applyMatcher(3, { gte: 5, lte: 10 })).toBe(false);
expect(applyMatcher(15, { gte: 5, lte: 10 })).toBe(false);
expect(applyValueMatcher(7, { gte: 5, lte: 10 })).toBe(true);
expect(applyValueMatcher(3, { gte: 5, lte: 10 })).toBe(false);
expect(applyValueMatcher(15, { gte: 5, lte: 10 })).toBe(false);
});
});
describe("checkExpectValue", () => {
test("原始值直接比较", () => {
expect(checkExpectValue("ok", "ok")).toBe(true);
expect(checkExpectValue("ok", "error")).toBe(false);
expect(checkExpectValue(42, 42)).toBe(true);
expect(checkExpectValue(null, null)).toBe(true);
describe("resolveValueExpectation", () => {
test("原始值解析为 equals matcher", () => {
expect(resolveValueExpectation("ok")).toEqual({ equals: "ok" });
expect(resolveValueExpectation(42)).toEqual({ equals: 42 });
expect(resolveValueExpectation(null)).toEqual({ equals: null });
expect(resolveValueExpectation(true)).toEqual({ equals: true });
});
test("对象作为操作符", () => {
expect(checkExpectValue(42, { gte: 10 })).toBe(true);
expect(checkExpectValue(42, { gte: 100 })).toBe(false);
expect(checkExpectValue("hello", { contains: "ell" })).toBe(true);
test("对象 matcher 原样保留", () => {
const matcher = { gte: 10 };
expect(resolveValueExpectation(matcher)).toBe(matcher);
expect(resolveValueExpectation({ contains: "ell" })).toEqual({ contains: "ell" });
});
test("undefined 返回 undefined", () => {
expect(resolveValueExpectation(undefined)).toBeUndefined();
});
test("failure expected 使用用户可读的 equals 值", () => {
const result = checkValueExpectation("actual", resolveValueExpectation("expected"), {
path: "finishReason",
phase: "finishReason",
});
expect(result.matched).toBe(false);
expect(result.failure?.expected).toBe("expected");
});
test("displayValueExpectation 保留多 matcher 对象", () => {
expect(displayValueExpectation({ contains: "ok", regex: "^ok$" })).toEqual({ contains: "ok", regex: "^ok$" });
});
});

View File

@@ -1,9 +1,12 @@
import { describe, expect, test } from "bun:test";
import { checkContentRules } from "../../../../../src/server/checker/expect/content";
import type { RawContentExpectations } from "../../../../../src/server/checker/expect/types";
function checkTextRules(text: string, rules: Parameters<typeof checkContentRules>[1], phase: string) {
return checkContentRules(text, rules, { path: phase, phase });
import { checkContentExpectations, resolveContentExpectations } from "../../../../../src/server/checker/expect/content";
function checkTextRules(text: string, rawRules: RawContentExpectations, phase: string) {
const resolved = resolveContentExpectations(rawRules);
return checkContentExpectations(text, resolved, { path: phase, phase });
}
describe("checkTextRules", () => {

View File

@@ -15,7 +15,7 @@ function input(target: Record<string, unknown>): CheckerValidationInput {
}
describe("ValueMatcher primitive shorthand in checker validators", () => {
test("normalizes shorthand for all checker ValueMatcher fields", () => {
test("accepts shorthand for all checker ValueMatcher fields", () => {
const targets = [
{
expect: { durationMs: 100 },
@@ -82,9 +82,10 @@ describe("ValueMatcher primitive shorthand in checker validators", () => {
for (const target of targets) {
const { validate, ...config } = target;
const original = structuredClone(config);
expect(validate(input(config))).toHaveLength(0);
expect((config.expect as Record<string, unknown>)["durationMs"]).toEqual({ equals: 100 });
expect(config).toEqual(original);
}
});

View File

@@ -1,8 +1,14 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { ResolvedTcpTarget } from "../../../../../src/server/checker/runner/tcp/types";
import type {
RawTcpExpectConfig,
ResolvedTcpExpectConfig,
ResolvedTcpTarget,
} from "../../../../../src/server/checker/runner/tcp/types";
import type { CheckerContext } from "../../../../../src/server/checker/runner/types";
import { resolveContentExpectations } from "../../../../../src/server/checker/expect/content";
import { resolveValueExpectation } from "../../../../../src/server/checker/expect/value";
import { TcpChecker } from "../../../../../src/server/checker/runner/tcp/execute";
const checker = new TcpChecker();
@@ -20,13 +26,27 @@ function makeCtx(timeoutMs = 5000): CheckerContext {
return { signal: controller.signal };
}
function makeTarget(tcp: Partial<ResolvedTcpTarget["tcp"]>, overrides?: Partial<ResolvedTcpTarget>): ResolvedTcpTarget {
function makeTarget(
tcp: Partial<ResolvedTcpTarget["tcp"]>,
overrides?: { expect?: RawTcpExpectConfig },
): ResolvedTcpTarget {
const raw = overrides?.expect;
const resolvedExpect: ResolvedTcpExpectConfig | undefined = raw
? {
banner: resolveContentExpectations(raw.banner),
connected: raw.connected ?? true,
durationMs: resolveValueExpectation(raw.durationMs),
}
: undefined;
return {
description: null,
expect: resolvedExpect,
group: "default",
id: "test-tcp",
intervalMs: 60000,
name: "test-tcp",
rawExpect: raw,
tcp: {
bannerReadTimeout: 2000,
host: "127.0.0.1",
@@ -37,7 +57,6 @@ function makeTarget(tcp: Partial<ResolvedTcpTarget["tcp"]>, overrides?: Partial<
},
timeoutMs: 5000,
type: "tcp",
...overrides,
};
}
@@ -295,6 +314,8 @@ describe("TcpChecker resolve", () => {
expect(target.name).toBeNull();
expect(target.intervalMs).toBe(30000);
expect(target.timeoutMs).toBe(10000);
expect(target.rawExpect).toBeUndefined();
expect(target.expect).toEqual({ connected: true });
});
test("bannerReadTimeout 和 maxBannerBytes 支持 per-target 覆盖", () => {
@@ -356,7 +377,12 @@ describe("TcpChecker resolve", () => {
},
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
);
expect(target.expect).toEqual({ banner: [{ contains: "ESMTP" }], connected: false, durationMs: { lte: 5000 } });
expect(target.expect).toEqual({
banner: [{ kind: "value", matcher: { contains: "ESMTP" } }],
connected: false,
durationMs: { lte: 5000 },
});
expect(target.rawExpect).toEqual({ banner: [{ contains: "ESMTP" }], connected: false, durationMs: { lte: 5000 } });
});
test("name 和 group 解析", () => {

View File

@@ -1,7 +1,13 @@
import { describe, expect, test } from "bun:test";
import type { ContentExpectations } from "../../../../../src/server/checker/expect/types";
import { checkBanner, checkConnected } from "../../../../../src/server/checker/runner/tcp/expect";
function value(matcher: ContentExpectations[number]["matcher"]): ContentExpectations[number] {
return { kind: "value", matcher };
}
describe("checkConnected", () => {
test("connected=true 期望 true 匹配", () => {
const result = checkConnected(true, true);
@@ -32,34 +38,34 @@ describe("checkConnected", () => {
describe("checkBanner", () => {
test("contains 匹配", () => {
const result = checkBanner("220 smtp.example.com ESMTP", [{ contains: "ESMTP" }]);
const result = checkBanner("220 smtp.example.com ESMTP", [value({ contains: "ESMTP" })]);
expect(result.matched).toBe(true);
});
test("contains 不匹配", () => {
const result = checkBanner("220 smtp.example.com ESMTP", [{ contains: "POSTFIX" }]);
const result = checkBanner("220 smtp.example.com ESMTP", [value({ contains: "POSTFIX" })]);
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("banner");
});
test("regex 正则匹配", () => {
const result = checkBanner("220 smtp.example.com ESMTP", [{ regex: "^220" }]);
const result = checkBanner("220 smtp.example.com ESMTP", [value({ regex: "^220" })]);
expect(result.matched).toBe(true);
});
test("空 banner 与 contains 空字符串", () => {
const result = checkBanner("", [{ contains: "" }]);
const result = checkBanner("", [value({ contains: "" })]);
expect(result.matched).toBe(true);
});
test("多 operator 同时匹配", () => {
const result = checkBanner("220 ESMTP", [{ contains: "ESMTP", regex: "^220" }]);
const result = checkBanner("220 ESMTP", [value({ contains: "ESMTP", regex: "^220" })]);
expect(result.matched).toBe(true);
});
test("多 operator 部分不匹配", () => {
const result = checkBanner("220 ESMTP", [{ contains: "ESMTP", regex: "^250" }]);
const result = checkBanner("220 ESMTP", [value({ contains: "ESMTP", regex: "^250" })]);
expect(result.matched).toBe(false);
});
});

View File

@@ -1,7 +1,13 @@
import { describe, expect, it } from "bun:test";
import type { ResolvedUdpTarget, UdpExpectConfig } from "../../../../../src/server/checker/runner/udp/types";
import type {
RawUdpExpectConfig,
ResolvedUdpExpectConfig,
ResolvedUdpTarget,
} from "../../../../../src/server/checker/runner/udp/types";
import { resolveContentExpectations } from "../../../../../src/server/checker/expect/content";
import { resolveValueExpectation } from "../../../../../src/server/checker/expect/value";
import { UdpChecker } from "../../../../../src/server/checker/runner/udp/execute";
async function createEchoServer(): Promise<{ close: () => void; port: number }> {
@@ -27,14 +33,26 @@ function makeSignal(timeoutMs: number): { cleanup: () => void; signal: AbortSign
return { cleanup: () => clearTimeout(timer), signal: controller.signal };
}
function makeTarget(overrides: Partial<ResolvedUdpTarget["udp"]> = {}, expect?: UdpExpectConfig): ResolvedUdpTarget {
function makeTarget(overrides: Partial<ResolvedUdpTarget["udp"]> = {}, raw?: RawUdpExpectConfig): ResolvedUdpTarget {
const resolvedExpect: ResolvedUdpExpectConfig | undefined = raw
? {
durationMs: resolveValueExpectation(raw.durationMs),
responded: raw.responded ?? true,
response: resolveContentExpectations(raw.response),
responseSize: resolveValueExpectation(raw.responseSize),
sourceHost: resolveValueExpectation(raw.sourceHost),
sourcePort: resolveValueExpectation(raw.sourcePort),
}
: undefined;
return {
description: null,
expect,
expect: resolvedExpect,
group: "default",
id: "test-udp",
intervalMs: 30000,
name: null,
rawExpect: raw,
timeoutMs: 10000,
type: "udp",
udp: {
@@ -322,6 +340,8 @@ describe("UdpChecker resolve", () => {
expect(target.udp.encoding).toBe("text");
expect(target.udp.responseEncoding).toBe("text");
expect(target.udp.maxResponseBytes).toBe(4096);
expect(target.rawExpect).toBeUndefined();
expect(target.expect).toEqual({ responded: true });
});
it("should use defaults.udp for missing fields", () => {

View File

@@ -1,5 +1,7 @@
import { describe, expect, it } from "bun:test";
import type { ContentExpectations } from "../../../../../src/server/checker/expect/types";
import {
checkResponded,
checkResponseSize,
@@ -8,6 +10,10 @@ import {
checkSourcePort,
} from "../../../../../src/server/checker/runner/udp/expect";
function value(matcher: ContentExpectations[number]["matcher"]): ContentExpectations[number] {
return { kind: "value", matcher };
}
describe("checkResponded", () => {
it("responded=true 期望 true → 匹配", () => {
const result = checkResponded(true, true);
@@ -63,26 +69,26 @@ describe("checkResponseSize", () => {
describe("checkResponseText", () => {
it("单条 contains 匹配", () => {
const result = checkResponseText("PONG", [{ contains: "PONG" }]);
const result = checkResponseText("PONG", [value({ contains: "PONG" })]);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
it("单条 contains 不匹配phase=response", () => {
const result = checkResponseText("PING", [{ contains: "PONG" }]);
const result = checkResponseText("PING", [value({ contains: "PONG" })]);
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("response");
});
it("多条规则全部匹配", () => {
const result = checkResponseText("hello world", [{ contains: "hello" }, { regex: "^hello" }]);
const result = checkResponseText("hello world", [value({ contains: "hello" }), value({ regex: "^hello" })]);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
it("多条规则第二条失败 → 不匹配", () => {
const result = checkResponseText("hello world", [{ contains: "hello" }, { regex: "^world" }]);
const result = checkResponseText("hello world", [value({ contains: "hello" }), value({ regex: "^world" })]);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("response");
expect(result.failure!.path).toBe("response[1]");

View File

@@ -26,7 +26,7 @@ beforeAll(() => {
const httpTarget: ResolvedHttpTarget = {
description: null,
expect: { durationMs: { lte: 3000 }, status: [200] },
expect: { body: [{ kind: "value", matcher: { contains: "ok" } }], durationMs: { equals: 3000 }, status: [200] },
group: "default",
http: {
headers: { Accept: "application/json" },
@@ -39,6 +39,7 @@ const httpTarget: ResolvedHttpTarget = {
id: "test-http",
intervalMs: 30000,
name: "test-http",
rawExpect: { body: [{ contains: "ok" }], durationMs: 3000 },
timeoutMs: 10000,
type: "http",
};
@@ -106,7 +107,7 @@ describe("ProbeStore", () => {
expect(config.maxRedirects).toBe(0);
expect(t.interval_ms).toBe(30000);
expect(t.timeout_ms).toBe(10000);
expect(JSON.parse(t.expect!)).toEqual({ durationMs: { lte: 3000 }, status: [200] });
expect(JSON.parse(t.expect!)).toEqual({ body: [{ contains: "ok" }], durationMs: 3000 });
});
test("cmd target 字段正确", () => {

View File

@@ -103,7 +103,7 @@ describe("createTargetTableColumns", () => {
test("延迟列超过 9999ms 时显示上限文案", () => {
const latencyColumn = getColumn(createTargetTableColumns(["http"]), "latestCheck.durationMs");
const renderCell = latencyColumn.cell as (params: PrimaryTableCellParams<TargetStatus>) => {
props: { children: string; className: string };
props: { children: string[]; className: string };
};
const element = renderCell({
col: latencyColumn,
@@ -133,7 +133,7 @@ describe("createTargetTableColumns", () => {
test("延迟列正常值包含 ms 单位", () => {
const latencyColumn = getColumn(createTargetTableColumns(["http"]), "latestCheck.durationMs");
const renderCell = latencyColumn.cell as (params: PrimaryTableCellParams<TargetStatus>) => {
props: { children: string; className: string };
props: { children: string[]; className: string };
};
const element = renderCell({
col: latencyColumn,