1
0

feat: 重构配置生命周期为 Authoring/Normalized/Resolved 三层

将变量替换和 expect 简写展开统一放入 Normalized 阶段,
运行时 AJV 使用 Normalized schema,导出 schema 面向 Authoring Config。

主要变更:
- 新增 normalizer.ts 实现 normalizeAuthoringConfig()
- 拆分 Authoring/Normalized 双 schema,checker 接口支持 authoring/normalized 片段
- config-loader 流程:normalize → Normalized AJV → semantic → resolve
- validator 兼容层自动分派 raw/normalized expect 形态
- 删除 rawExpect,store.expect 列写入 null
- Authoring schema 对 integer/boolean/enum 字段接受变量引用
- 修复 DB/HTTP validate 入口守卫和 LLM options integer 变量引用
- 优化 compact() 避免 undefined 覆盖隐患
- 移除 content.ts 恒为 true 的前置条件
- 同步 5 个主规范并归档 change
This commit is contained in:
2026-05-22 14:00:47 +08:00
parent 6e53c8130d
commit cf847ccd7a
56 changed files with 1717 additions and 656 deletions

View File

@@ -139,7 +139,7 @@ probe-config.schema.json 用户配置 JSON Schema 导出物(用于 IDE 自动
启动流程: 启动流程:
dev.ts / main.ts → readRuntimeConfig(cli args, 仅提取 configPath) dev.ts / main.ts → readRuntimeConfig(cli args, 仅提取 configPath)
→ bootstrap({ configPath, mode }) → bootstrap({ configPath, mode })
→ loadConfig(yamlYAML 解析 → 变量替换 → 契约校验 → 语义校验 → resolve) → loadConfig(yamlYAML 解析 → Authoring normalize变量替换 + expect 简写展开)→ Normalized 契约校验 → 语义校验 → resolve)
→ ResolvedConfig{ host(server.listen), port(server.listen), dataDir(server.storage), maxConcurrentChecks(probes.execution), retentionMs(server.storage), targets, logging(server.logging) } → ResolvedConfig{ host(server.listen), port(server.listen), dataDir(server.storage), maxConcurrentChecks(probes.execution), retentionMs(server.storage), targets, logging(server.logging) }
→ createRuntimeLogger(logging) → Logger配置加载失败时使用 ConsoleFallbackLogger → createRuntimeLogger(logging) → Logger配置加载失败时使用 ConsoleFallbackLogger
→ ProbeStore(db) → store.syncTargets(targets) → ProbeStore(db) → store.syncTargets(targets)
@@ -246,9 +246,13 @@ export function handleMetrics(idStr: string, url: URL, store: ProbeStore, mode:
### 1.6 配置契约与校验 ### 1.6 配置契约与校验
配置加载流程固定为:`unknown -> 变量替换 -> RawProbeConfig -> ValidatedProbeConfig -> ResolvedConfig` 配置加载流程固定为:`unknown -> AuthoringProbeConfig -> NormalizedProbeConfig -> ValidatedProbeConfig -> ResolvedConfig`
变量替换阶段由 `variables.ts` 负责,在 YAML 解析之后、AJV 契约校验之前执行。顶层 `variables` 支持 string/number/boolean 字面量且自身不参与替换;`server``probes``targets` 字符串字段支持 `${key}``${key|default}``${key|}``$${key}`,解析优先级为 `variables -> process.env -> 默认值 -> unresolved-variable 报错`;替换范围不包含对象 key且仅跳过 `targets[].id``targets[].type` 字段 Authoring Config 是用户 YAML 可书写形态,允许 `${key}``${key|default}``${key|}``$${key}` 变量引用和 expect primitive/keyed/content 简写。`normalizeAuthoringConfig()` 在 YAML 解析之后、AJV 契约校验之前执行,只做去糖:调用 `variables.ts` 完成变量替换,展开 expect 简写,并移除顶层 `variables` 段。Normalized Config 不包含变量引用、不包含 Raw expect primitive 简写,也不补默认值、不解析 duration/size/path/env、不合并 `cmd.env`
根目录 `probe-config.schema.json` 由 Authoring schema 导出,服务 VSCode 和外部用户校验;运行时 `validateProbeConfigContract()` 使用 Normalized schema。checker 必须提供 `schemas.authoring.config``schemas.authoring.expect``schemas.normalized.config``schemas.normalized.expect` 四个 TypeBox 片段Authoring 片段描述用户可写 DSLNormalized 片段描述 normalizer 输出。
变量替换解析优先级为 `variables -> process.env -> 默认值 -> unresolved-variable 报错`;替换范围不包含对象 key且仅跳过 `targets[].id``targets[].type` 字段。字段值完整等于单个变量引用时保留 number/boolean/string 类型推断,部分拼接时统一转为字符串。
`config-loader.ts` 只负责 YAML 解析、契约调度、公共语义校验和最终运行期解析checker 专属规则必须下沉到对应 checker 的 `schema.ts``validate.ts` `config-loader.ts` 只负责 YAML 解析、契约调度、公共语义校验和最终运行期解析checker 专属规则必须下沉到对应 checker 的 `schema.ts``validate.ts`
@@ -375,29 +379,28 @@ TcpChecker implements Checker
**`resolve()` 规范** **`resolve()` 规范**
- 只做内置默认值填充、路径解析、单位转换,**不执行校验** - 只做内置默认值填充、路径解析、单位转换,**不执行校验**
- 若 checker 支持 expect必须保留 `rawExpect` 为变量替换后的 Raw 配置快照,并生成只供 `execute()` 使用的 Resolved `expect` - `resolve()` 接收的 target 已通过 Normalized schema 和语义校验expect 已是 normalized 形态primitive 简写已展开为 `{equals}`、keyed 已转为 `{key, matcher}[]`、content 已转为带 `kind` 的执行结构)
- `resolve()` 对 expect 只做默认值填充(如未配置 `status` 时物化 `[200]`)和 spread 透传,不再调用 Raw 简写展开函数
- 返回 `satisfies ResolvedXxxTarget` 确保类型正确 - 返回 `satisfies ResolvedXxxTarget` 确保类型正确
**expect 五层管线**每种断言模型从定义到执行经过五个阶段,每层使用对应的共享函数 **expect 管线**配置从定义到执行经过 Authoring → Normalized → Resolved → Execute 四层,简写展开在 normalizer 阶段完成
| 断言模型 | 类型层Raw | Schema | Validate 层 | Resolve 层 | Execute 层 | | 断言模型 | Authoring 输入 | Normalizer| Schema 层Authoring / Normalized | Validate 层(接收 Normalized | Execute 层 |
| --------------------- | ----------------------------------- | ----------------------------------- | ---------------------------------- | ------------------------------ | ---------------------------- | | --------------------- | ----------------------------------- | ------------------------------ | -------------------------------------------------------------------- | ---------------------------------- | ---------------------------- |
| `ValueExpectation` | `number \| ValueMatcher` | `createValueMatcherSchema()` | `validateRawValueExpectation()` | `resolveValueExpectation()` | `checkValueExpectation()` | | `ValueExpectation` | `number \| ValueMatcher` | `resolveValueExpectation()` | `createAuthoringValueExpectationSchema()` / `createNormalized*()` | `validateRawValueExpectation()` | `checkValueExpectation()` |
| `ContentExpectations` | `(ValueMatcher \| ExtractorRule)[]` | `createContentExpectationsSchema()` | `validateRawContentExpectations()` | `resolveContentExpectations()` | `checkContentExpectations()` | | `ContentExpectations` | `(ValueMatcher \| ExtractorRule)[]` | `resolveContentExpectations()` | `createAuthoringContentExpectationsSchema()` / `createNormalized*()` | `validateRawContentExpectations()` | `checkContentExpectations()` |
| `KeyedExpectations` | `Record<string, ValueExpectation>` | `createKeyedExpectationsSchema()` | `validateRawKeyedExpectations()` | `resolveKeyedExpectations()` | `checkKeyedExpectations()` | | `KeyedExpectations` | `Record<string, ValueExpectation>` | `resolveKeyedExpectations()` | `createAuthoringKeyedExpectationsSchema()` / `createNormalized*()` | `validateRawKeyedExpectations()` | `checkKeyedExpectations()` |
选择哪种模型参考 [1.10 expect 字段选择规范](#110-expect-断言系统)的决策树。 选择哪种模型参考 [1.10 expect 字段选择规范](#110-expect-断言系统)的决策树。
**resolve 中的标准模式** **resolve 中的标准模式**
```typescript ```typescript
// resolve() 内:逐字段调用对应的 resolve 函数,未配置的字段保持 undefined // resolve() 内:expect 已是 normalized 形态spread 后补默认值
const rawExpect = raw.expect ?? {}; const expect = target.expect as ResolvedXxxExpectConfig | undefined;
expect: { const resolvedExpect: ResolvedXxxExpectConfig = expect
durationMs: rawExpect.durationMs != null ? resolveValueExpectation(rawExpect.durationMs) : undefined, ? { ...expect, status: expect.status ?? [200] }
body: rawExpect.body != null ? resolveContentExpectations(rawExpect.body) : undefined, : { status: [200] };
headers: rawExpect.headers != null ? resolveKeyedExpectations(rawExpect.headers) : undefined,
}
``` ```
**execute 中的标准模式** **execute 中的标准模式**
@@ -627,7 +630,7 @@ expect(logger.entries[0]!.msg).toContain("UP → DOWN");
两层模型:**观测值收集** → **规则校验**。共享断言基础设施位于 `checker/expect/`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` **Authoring → Normalized → Resolved**:用户 YAML 写的是 Authoring 形态primitive 简写、`{ json: { path, equals } }` 提取器对象、`Record<string, value>` 键值表),`normalizeAuthoringConfig()` 在 AJV 前将其转换为 Normalized 形态`{ equals: primitive }``{ kind, matcher, ... }` content 联合、`{ key, matcher }[]` 有序数组)。语义校验器接收 Normalized 形态并校验。`resolve()` 只补默认值和做运行期转换。Store `targets.expect` 列当前写入 NULL不持久化 expect 快照;checker.execute 消费 Resolved `expect`
**共享模型** **共享模型**
@@ -637,11 +640,11 @@ expect(logger.entries[0]!.msg).toContain("UP → DOWN");
| `ContentExpectations` | 返回内容或半结构化内容断言,必须是数组 | `body``stdout``stderr``banner``response``output``result` | | `ContentExpectations` | 返回内容或半结构化内容断言,必须是数组 | `body``stdout``stderr``banner``response``output``result` |
| `KeyedExpectations` | 动态键值断言,字面量等价于 `{ equals: value }` | `headers`、DB `rows[]` 中的列值 | | `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))`。ValueExpectation Raw 输入可使用 string、number、boolean 或 null 简写,resolve 阶段归一化为 `{ 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 Authoring 输入可使用 string、number、boolean 或 null 简写,normalizer 阶段归一化为 `{ equals: value }`;数组和对象简写不支持,必须显式写成 `{ equals: ... }`
`ContentExpectations` 数组按顺序快速失败。数组项可以是直接 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.ts` 负责,校验空 matcher、未知字段、字段类型、`exists:false` 组合、ContentExpectations 互斥性、JSONPath 子集、XPath 可编译性、regex 可编译性、ReDoS 风险以及 HTTP/LLM headers 大小写归一化后重复 key。语义校验不修改 Raw 输入。旧字段 `match``maxDurationMs`、ICMP 的 `max*` 阈值字段不再支持。 启动期语义校验统一由 `expect/validate.ts` 负责,校验器通过兼容层同时支持 Raw 和 Normalized expect 形态(`validateRawKeyedExpectations` 遇到数组自动分派到 `validateNormalizedKeyedExpectations``validateRawContentExpectation` 遇到 `kind` 字段自动分派到 `validateNormalizedContentExpectation`)。校验内容包括空 matcher、未知字段、字段类型、`exists:false` 组合、ContentExpectations 互斥性、JSONPath 子集、XPath 可编译性、regex 可编译性、ReDoS 风险以及 HTTP/LLM headers 大小写归一化后重复 key。语义校验不修改输入。旧字段 `match``maxDurationMs`、ICMP 的 `max*` 阈值字段不再支持。
**快速失败顺序** **快速失败顺序**

View File

@@ -258,6 +258,8 @@ targets: # 拨测目标列表(必填)
解析优先级为 `variables -> process.env -> 默认值`,三者均不存在时配置校验失败。字段值完整等于单个变量引用时会保留 number/boolean/string 类型,环境变量和默认值会做类型推断,但空字符串保持为字符串;部分拼接时统一转为字符串。变量替换作用于 `server``probes``targets`,不作用于 `variables` 段自身,且不会替换 `targets[].id``targets[].type` 字段;对象 key 不参与替换。 解析优先级为 `variables -> process.env -> 默认值`,三者均不存在时配置校验失败。字段值完整等于单个变量引用时会保留 number/boolean/string 类型,环境变量和默认值会做类型推断,但空字符串保持为字符串;部分拼接时统一转为字符串。变量替换作用于 `server``probes``targets`,不作用于 `variables` 段自身,且不会替换 `targets[].id``targets[].type` 字段;对象 key 不参与替换。
配置加载内部区分三层形态:用户 YAML 属于 Authoring Config允许变量引用和 expect 简写;`normalizeAuthoringConfig()` 会在启动时完成变量替换、expect primitive/keyed/content 简写展开并移除 `variables` 段,生成 Normalized Configchecker 的 `resolve()` 只在 ResolvedConfig 阶段补默认值并解析 duration、size、路径和运行期环境。根目录 `probe-config.schema.json` 面向 Authoring Config因此 VSCode 校验会接受 `server.listen.port: "${server_port|3000}"``http.maxRedirects: "${MAX|5}"``expect.durationMs: 5000` 这类写法。
### targets — 拨测目标列表(必填) ### targets — 拨测目标列表(必填)
每个 target 的通用字段: 每个 target 的通用字段:

View File

@@ -5,7 +5,7 @@
## Requirements ## Requirements
### Requirement: Checker 配置契约片段 ### Requirement: Checker 配置契约片段
系统 SHALL 支持 checker 提供自身 TypeBox 配置契约片段,用于描述该 checker 的 target 领域分组和 expect 分组。公共配置加载模块 SHALL 通过 registry 获取已注册 checker 的契约片段,并组合为启动期 Ajv 契约校验流程和外部 `probe-config.schema.json` 导出流程。 系统 SHALL 支持 checker 提供自身 TypeBox 配置契约片段,用于描述该 checker 的 target 领域分组和 expect 分组。checker 契约 SHALL 区分 Authoring schema 与 Normalized schema。Authoring schema SHALL 描述用户 YAML 可书写形式,包括变量引用和 expect 简写Normalized schema SHALL 描述 `normalizeAuthoringConfig()` 输出形式,不接受变量引用、不接受 expect primitive 简写。公共配置加载模块 SHALL 通过 registry 获取已注册 checker 的契约片段,并按用途组合为运行时 Ajv 契约校验流程和外部 `probe-config.schema.json` 导出流程。
#### Scenario: HTTP checker 提供契约片段 #### Scenario: HTTP checker 提供契约片段
- **WHEN** HTTP checker 被注册 - **WHEN** HTTP checker 被注册
@@ -21,7 +21,11 @@
#### Scenario: 外部 schema 通过 registry 生成 #### Scenario: 外部 schema 通过 registry 生成
- **WHEN** 系统生成 `probe-config.schema.json` - **WHEN** 系统生成 `probe-config.schema.json`
- **THEN** 生成流程 SHALL 从 registry 获取已注册 checker 的契约片段,并将其组合进完整配置 schema - **THEN** 生成流程 SHALL 从 registry 获取已注册 checker 的 Authoring 契约片段,并将其组合进完整配置 schema
#### Scenario: 运行时 schema 通过 registry 生成
- **WHEN** config-loader 执行运行时 AJV 契约校验
- **THEN** 校验流程 SHALL 从 registry 获取已注册 checker 的 Normalized 契约片段,并将其组合进完整配置 schema
#### Scenario: 契约组装不依赖全局 singleton #### Scenario: 契约组装不依赖全局 singleton
- **WHEN** 测试或 schema 生成流程需要组装配置契约 - **WHEN** 测试或 schema 生成流程需要组装配置契约
@@ -50,11 +54,11 @@
- **THEN** checker SHALL 返回 `ConfigValidationIssue`,而不是直接抛出最终用户错误字符串 - **THEN** checker SHALL 返回 `ConfigValidationIssue`,而不是直接抛出最终用户错误字符串
### Requirement: Checker 接口定义 ### Requirement: Checker 接口定义
系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义面向扩展的泛型 `CheckerDefinition<TResolved extends ResolvedTargetBase = ResolvedTargetBase>`,包含 `type``configKey`、TypeBox 配置契约、启动期语义校验、`resolve``execute``serialize``buildDetail` 成员。泛型参数 SHALL 约束 `execute``serialize` 方法的 target 参数类型,使 checker 实现内部获得编译期类型安全。默认泛型参数 `= ResolvedTargetBase` 保证中间层registry、engine、config-loader无需指定泛型。 系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义面向扩展的泛型 `CheckerDefinition<TResolved extends ResolvedTargetBase = ResolvedTargetBase>`,包含 `type``configKey`Authoring/Normalized TypeBox 配置契约、启动期语义校验、`resolve``execute``serialize``buildDetail` 成员。泛型参数 SHALL 约束 `execute``serialize` 方法的 target 参数类型,使 checker 实现内部获得编译期类型安全。默认泛型参数 `= ResolvedTargetBase` 保证中间层registry、engine、config-loader无需指定泛型。
#### Scenario: Checker 接口包含必要方法 #### Scenario: Checker 接口包含必要方法
- **WHEN** 开发者实现一个新的 Checker - **WHEN** 开发者实现一个新的 Checker
- **THEN** 该实现 MUST 提供 `type`(字符串标识)、`configKey`配置分组名、TypeBox 配置契约、启动期语义校验、`resolve(target, context)`(解析配置并填充默认值)、`execute(target, ctx)`(执行探测返回 CheckResult`serialize(target)`(返回 target 展示文本和 config JSON`buildDetail(observation)`(从 observation 构造人可读摘要) - **THEN** 该实现 MUST 提供 `type`(字符串标识)、`configKey`(配置分组名)、Authoring/Normalized TypeBox 配置契约、启动期语义校验、`resolve(target, context)`(解析配置并填充默认值)、`execute(target, ctx)`(执行探测返回 CheckResult`serialize(target)`(返回 target 展示文本和 config JSON`buildDetail(observation)`(从 observation 构造人可读摘要)
#### Scenario: CheckerContext 注入 signal #### Scenario: CheckerContext 注入 signal
- **WHEN** 引擎调用 `checker.execute(target, ctx)` - **WHEN** 引擎调用 `checker.execute(target, ctx)`
@@ -62,7 +66,11 @@
#### Scenario: resolve 不承担通用契约校验 #### Scenario: resolve 不承担通用契约校验
- **WHEN** config-loader 调用 checker.resolve() - **WHEN** config-loader 调用 checker.resolve()
- **THEN** checker.resolve() SHALL 假定配置已经通过 TypeBox/Ajv 契约校验和启动期语义校验,只负责默认值填充、路径解析和领域配置转换 - **THEN** checker.resolve() SHALL 假定配置已经通过 Normalized TypeBox/Ajv 契约校验和启动期语义校验,只负责默认值填充、路径解析和领域配置转换
#### Scenario: resolve 接收 Normalized target
- **WHEN** config-loader 调用 checker.resolve()
- **THEN** 传入的 target SHALL 已通过 Normalized schema 和语义校验且不包含变量引用、Authoring expect primitive 简写或 Raw content/keyed DSL
#### Scenario: type 与 configKey 默认一致 #### Scenario: type 与 configKey 默认一致
- **WHEN** checker 定义 `type: "tcp"` - **WHEN** checker 定义 `type: "tcp"`
@@ -125,6 +133,14 @@
- **WHEN** config-loader 校验配置文件 - **WHEN** config-loader 校验配置文件
- **THEN** config-loader SHALL 从 `checkerRegistry` 获取已注册 checker 的契约片段,并用于校验 defaults 与 targets 中对应 checker 的配置形状 - **THEN** config-loader SHALL 从 `checkerRegistry` 获取已注册 checker 的契约片段,并用于校验 defaults 与 targets 中对应 checker 的配置形状
#### Scenario: Authoring 契约通过 registry 组合
- **WHEN** 系统导出用户配置 JSON Schema
- **THEN** 配置 builder SHALL 从 `checkerRegistry` 获取已注册 checker 的 Authoring 契约片段
#### Scenario: Normalized 契约通过 registry 组合
- **WHEN** config-loader 校验 normalized 配置对象
- **THEN** config-loader SHALL 从 `checkerRegistry` 获取已注册 checker 的 Normalized 契约片段
#### Scenario: 配置解析委托 checker #### Scenario: 配置解析委托 checker
- **WHEN** config-loader 解析一个 type 为 "cmd" 的 target - **WHEN** config-loader 解析一个 type 为 "cmd" 的 target
- **THEN** config-loader SHALL 调用 `checkerRegistry.get("cmd")` 获取对应 checker并委托该 checker 执行语义校验和 resolve - **THEN** config-loader SHALL 调用 `checkerRegistry.get("cmd")` 获取对应 checker并委托该 checker 执行语义校验和 resolve
@@ -146,22 +162,22 @@
- **THEN** HttpChecker 的语义校验 SHALL 抛出校验错误,提示 URL 格式不合法 - **THEN** HttpChecker 的语义校验 SHALL 抛出校验错误,提示 URL 格式不合法
### Requirement: 存储序列化通过 registry 获取展示格式 ### Requirement: 存储序列化通过 registry 获取展示格式
系统 SHALL 在 `ProbeStore.syncTargets()` 中通过 `checkerRegistry.get(t.type).serialize(t)` 获取每个 target 的展示摘要(`target` 列)和配置 JSON`config` 列),替代 `buildTargetDisplay()` / `buildTargetConfig()` 中的类型分支。系统 SHALL 将 `targets.expect` 持久化为 resolved target 上的 Raw expect 快照,而不是运行期 Resolved expect 执行计划 系统 SHALL 在 `ProbeStore.syncTargets()` 中通过 `checkerRegistry.get(t.type).serialize(t)` 获取每个 target 的展示摘要(`target` 列)和配置 JSON`config` 列),替代 `buildTargetDisplay()` / `buildTargetConfig()` 中的类型分支。系统 SHALL 将 `targets.expect` 持久化为 null不依赖 Raw expect Resolved expect。
#### Scenario: 序列化委托 checker #### Scenario: 序列化委托 checker
- **WHEN** store 同步 targets 表 - **WHEN** store 同步 targets 表
- **THEN** store SHALL 对每个 target 调用对应 checker 的 `serialize()` 方法获取 `{ target, config }` - **THEN** store SHALL 对每个 target 调用对应 checker 的 `serialize()` 方法获取 `{ target, config }`
#### Scenario: expect 持久化使用 rawExpect #### Scenario: expect 持久化不依赖 rawExpect
- **WHEN** store 同步带 expect 的 target 到 targets 表 - **WHEN** store 同步带 expect 的 target 到 targets 表
- **THEN** store SHALL 将 `rawExpect` 序列化写入 `targets.expect`MUST NOT 将包含 `kind` Resolved content expectation 写入该列 - **THEN** store SHALL 将 `targets.expect` 写入 NULLMUST NOT 依赖 `rawExpect` Raw expect 快照
### Requirement: Checker resolve 生成 Raw 与 Resolved expect ### Requirement: Checker resolve 只接收已去糖配置
每个 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 字段。 每个 checker 的 `resolve()` SHALL 接收已通过 Normalized schema 和语义校验的配置不再包含变量引用、Authoring expect primitive 简写或 Raw content/keyed DSL`config-loader` SHALL 继续通过 registry 委托 checker resolveMUST NOT 在中间层理解 checker 专属 expect 字段。
#### Scenario: resolve 输出双 expect 模型 #### Scenario: resolve 不再展开 Raw expect
- **WHEN** config-loader 解析一个带 `expect.durationMs: 1000` 的 target - **WHEN** config-loader 解析一个带 `expect.durationMs: {equals: 1000}` 的 target
- **THEN** 对应 checker 的 resolved target SHALL 包含 Raw expect 中的 `durationMs: 1000`,并在 Resolved expect 中包含 `{equals: 1000}` 形式的运行期 matcher - **THEN** 对应 checker 的 resolved target SHALL 直接使用 Normalized expect 中 `{equals: 1000}`resolve 只负责默认值和运行期配置转换
#### Scenario: 中间层不感知 checker expect 字段 #### Scenario: 中间层不感知 checker expect 字段
- **WHEN** 新增 checker 定义自己的 Raw/Resolved expect 字段 - **WHEN** 新增 checker 定义自己的 Raw/Resolved expect 字段

View File

@@ -133,7 +133,7 @@
- **THEN** 系统 SHALL 将该字段替换为 string 类型的空字符串 `""`MUST NOT 推断为 number 0 - **THEN** 系统 SHALL 将该字段替换为 string 类型的空字符串 `""`MUST NOT 推断为 number 0
### Requirement: 替换范围限制 ### Requirement: 替换范围限制
变量替换 SHALL 作用于 `server``probes``targets` 段中的字符串值。`variables` 段自身 MUST NOT 参与变量替换。系统 SHALL 递归遍历支持范围内对象树中所有字符串 value 进行替换,包括嵌套对象和数组元素中的字符串。系统 MUST NOT 替换对象 key。`targets[].id``targets[].type` 字段 MUST NOT 参与变量替换target 内部其他路径上名为 `id``type` 的字段 SHALL 正常参与变量替换。顶层 `defaults` 不再是合法配置段,因此不属于变量替换范围。 变量替换 SHALL 作用于 Authoring Config 的 `server``probes``targets` 段中的字符串值。`variables` 段自身 MUST NOT 参与变量替换。系统 SHALL 递归遍历支持范围内对象树中所有字符串 value 进行替换,包括嵌套对象和数组元素中的字符串。系统 MUST NOT 替换对象 key。`targets[].id``targets[].type` 字段 MUST NOT 参与变量替换target 内部其他路径上名为 `id``type` 的字段 SHALL 正常参与变量替换。顶层 `defaults` 不再是合法配置段,因此不属于变量替换范围。变量替换完成后Normalized Config MUST NOT 保留顶层 `variables` 段。
#### Scenario: target 嵌套对象中的变量替换 #### Scenario: target 嵌套对象中的变量替换
- **WHEN** target 配置 `http.headers.Authorization: "${token}"` 且 variables 中定义 `token: "Bearer abc"` - **WHEN** target 配置 `http.headers.Authorization: "${token}"` 且 variables 中定义 `token: "Bearer abc"`
@@ -179,6 +179,10 @@
- **WHEN** 配置文件声明顶层 `defaults` - **WHEN** 配置文件声明顶层 `defaults`
- **THEN** 系统 SHALL 在契约校验阶段拒绝该未知字段,而不是尝试变量替换 - **THEN** 系统 SHALL 在契约校验阶段拒绝该未知字段,而不是尝试变量替换
#### Scenario: Normalized Config 移除 variables 段
- **WHEN** Authoring Config 包含顶层 `variables` 段且变量替换成功
- **THEN** Normalized Config SHALL 不包含顶层 `variables` 字段
### Requirement: 变量替换错误报告 ### Requirement: 变量替换错误报告
变量替换阶段的错误 SHALL 作为 `ConfigValidationIssue` 输出code 为 `unresolved-variable`。错误信息 SHALL 包含字段路径和变量名。对于 `targets[i]` 内的错误,错误信息还 SHALL 包含 target 索引、target id 和 target 展示名上下文。 变量替换阶段的错误 SHALL 作为 `ConfigValidationIssue` 输出code 为 `unresolved-variable`。错误信息 SHALL 包含字段路径和变量名。对于 `targets[i]` 内的错误,错误信息还 SHALL 包含 target 索引、target id 和 target 展示名上下文。
@@ -199,7 +203,7 @@
- **THEN** 系统 SHALL 收集所有缺失变量错误后统一输出,而非遇到第一个就退出 - **THEN** 系统 SHALL 收集所有缺失变量错误后统一输出,而非遇到第一个就退出
### Requirement: 变量替换执行时机 ### Requirement: 变量替换执行时机
变量替换 SHALL 在 YAML 解析之后、schema 契约校验AJV之前执行。替换完成后的配置对象 SHALL 传入后续校验流程。 变量替换 SHALL 在 YAML 解析之后、Normalized schema 契约校验AJV之前执行。变量替换 SHALL 是 `normalizeAuthoringConfig()` 的一部分,替换完成后的配置对象 SHALL 继续执行 expect 简写展开并形成 Normalized Config。Normalized Config SHALL 传入后续契约校验和语义校验流程。
#### Scenario: target 替换后通过 schema 校验 #### Scenario: target 替换后通过 schema 校验
- **WHEN** target 配置 `http.maxRedirects: "${MAX_REDIRECTS}"` 且环境变量 `MAX_REDIRECTS=5` - **WHEN** target 配置 `http.maxRedirects: "${MAX_REDIRECTS}"` 且环境变量 `MAX_REDIRECTS=5`
@@ -215,4 +219,8 @@
#### Scenario: probes 替换后通过 schema 校验 #### Scenario: probes 替换后通过 schema 校验
- **WHEN** probes 配置 `execution.maxConcurrentChecks: "${MAX_CHECKS}"` 且 variables 中定义 `MAX_CHECKS: 20` - **WHEN** probes 配置 `execution.maxConcurrentChecks: "${MAX_CHECKS}"` 且 variables 中定义 `MAX_CHECKS: 20`
- **THEN** 系统 SHALL 先将该字段替换为 number 20再进入 AJV 校验(期望 integer校验通过 - **THEN** 系统 SHALL 先将 probes.execution.maxConcurrentChecks 替换为 number 20再进入 Normalized schema 校验(期望 integer校验通过
#### Scenario: 变量替换后继续展开 expect 简写
- **WHEN** Authoring Config 配置 `expect.durationMs: "${MAX_MS}"` 且 variables 中定义 `MAX_MS: 1000`
- **THEN** Normalized Config SHALL 包含 `expect.durationMs: { equals: 1000 }`

View File

@@ -7,7 +7,7 @@
### Requirement: ValueMatcher 统一匹配器 ### Requirement: ValueMatcher 统一匹配器
系统 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 均通过。 系统 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 均通过。
所有类型为 `RawValueExpectation` 的 expect 字段 SHALL 同时接受 primitive 原始值string / number / boolean / null作为简写形式。原始值简写 SHALL 等价于 `{ equals: value }`。系统 SHALL 在 resolve 阶段将 primitive 原始值归一化为 `{ equals: value }` 对象形式运行期逻辑 SHALL 仅处理 `ValueMatcher` 对象形式。数组和对象 MUST NOT 作为原始值简写;需要对数组或对象执行 equals 匹配时,配置 MUST 显式写成 `{ equals: value }` 所有类型为 Authoring `RawValueExpectation` 的 expect 字段 SHALL 同时接受 primitive 原始值string / number / boolean / null作为简写形式。原始值简写 SHALL 等价于 `{ equals: value }`。系统 SHALL 在 Normalized 阶段将 primitive 原始值归一化为 `{ equals: value }` 对象形式。Normalized Config、checker 语义校验、checker.resolve() 和运行期逻辑 SHALL 仅处理 `ValueMatcher` 对象形式。数组和对象 MUST NOT 作为原始值简写;需要对数组或对象执行 equals 匹配时,配置 MUST 显式写成 `{ equals: value }`
#### Scenario: equals 匹配对象 #### Scenario: equals 匹配对象
- **WHEN** 实际值为 `{status: "ok", count: 1}` 且 matcher 为 `{equals: {status: "ok", count: 1}}` - **WHEN** 实际值为 `{status: "ok", count: 1}` 且 matcher 为 `{equals: {status: "ok", count: 1}}`
@@ -25,28 +25,28 @@
- **WHEN** 实际值为 `"healthy"` 且 matcher 为 `{contains: "health", regex: "^ready$"}` - **WHEN** 实际值为 `"healthy"` 且 matcher 为 `{contains: "health", regex: "^ready$"}`
- **THEN** 系统 SHALL 在任一 matcher 不满足时判定该 matcher 对象不通过 - **THEN** 系统 SHALL 在任一 matcher 不满足时判定该 matcher 对象不通过
#### Scenario: 字符串原始值简写等价 equals #### Scenario: 字符串原始值简写在 Normalized 阶段等价 equals
- **WHEN** expect 字段配置为 `finishReason: "stop"` 且实际值为 `"stop"` - **WHEN** Authoring Config 中 expect 字段配置为 `finishReason: "stop"`
- **THEN** 系统 SHALL 在 resolve 阶段`"stop"` 归一化为 `{equals: "stop"}`判定通过 - **THEN** Normalized Config SHALL `"stop"` 归一化为 `{equals: "stop"}`,运行期实际值为 `"stop"`判定通过
#### Scenario: 数字原始值简写等价 equals #### Scenario: 数字原始值简写在 Normalized 阶段等价 equals
- **WHEN** expect 字段配置为 `rowCount: 1` 且实际值为 `1` - **WHEN** Authoring Config 中 expect 字段配置为 `rowCount: 1`
- **THEN** 系统 SHALL 在 resolve 阶段`1` 归一化为 `{equals: 1}`判定通过 - **THEN** Normalized Config SHALL `1` 归一化为 `{equals: 1}`,运行期实际值为 `1`判定通过
#### Scenario: 布尔原始值简写等价 equals #### Scenario: 布尔原始值简写在 Normalized 阶段等价 equals
- **WHEN** expect 字段配置为 RawValueExpectation 类型且值为 `true`,实际值为 `true` - **WHEN** Authoring Config 中 RawValueExpectation 类型字段值为 `true`
- **THEN** 系统 SHALL 在 resolve 阶段`true` 归一化为 `{equals: true}`判定通过 - **THEN** Normalized Config SHALL `true` 归一化为 `{equals: true}`,运行期实际值为 `true`判定通过
#### Scenario: null 原始值简写等价 equals #### Scenario: null 原始值简写在 Normalized 阶段等价 equals
- **WHEN** expect 字段配置为 RawValueExpectation 类型且值为 `null`,实际值为 `null` - **WHEN** Authoring Config 中 RawValueExpectation 类型字段值为 `null`
- **THEN** 系统 SHALL 在 resolve 阶段`null` 归一化为 `{equals: null}`判定通过 - **THEN** Normalized Config SHALL `null` 归一化为 `{equals: null}`,运行期实际值为 `null`判定通过
#### Scenario: 原始值简写不匹配 #### Scenario: 原始值简写不匹配
- **WHEN** expect 字段配置为 `finishReason: "stop"` 且实际值为 `"error"` - **WHEN** expect 字段配置为 `finishReason: "stop"` 且实际值为 `"error"`
- **THEN** 系统 SHALL 判定不通过并生成 mismatch failure - **THEN** 系统 SHALL 判定不通过并生成 mismatch failure
### Requirement: ValueMatcher 启动期校验 ### Requirement: ValueMatcher 启动期校验
系统 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 导致启动期配置错误。 系统 SHALL 在启动期对所有 normalized `ValueMatcher` 字段执行严格的类型和语义校验。Authoring primitive 简写 MUST 在语义校验前由 Normalized 阶段转换为 `{equals: value}`,因此语义 validator SHALL 校验 `ValueMatcher` 对象而不是 Raw primitive 输入。当输入为 `ValueMatcher` 对象时,`contains` MUST 为 string。`equals` MAY 为任意 JSON value。`exists``empty` MUST 为 boolean。`gt``gte``lt``lte` MUST 为有限数字(`Number.isFinite`)。`regex` MUST 为可编译的 string pattern并通过 ReDoS 风险校验。`ValueMatcher` 对象 MUST 至少包含一个合法 matcher 字段,空对象 `{}` SHALL 导致启动期配置错误。`ValueMatcher` 对象 MUST NOT 包含未知字段,任何不属于 `equals``contains``regex``exists``empty``gt``gte``lt``lte` 的字段 SHALL 导致启动期配置错误。
#### Scenario: 空 matcher 对象被拒绝 #### Scenario: 空 matcher 对象被拒绝
- **WHEN** YAML 配置中任一 matcher 对象为空 `{}` - **WHEN** YAML 配置中任一 matcher 对象为空 `{}`
@@ -64,17 +64,17 @@
- **WHEN** YAML 配置中任一 matcher 的 `exists``empty` 值不是布尔类型 - **WHEN** YAML 配置中任一 matcher 的 `exists``empty` 值不是布尔类型
- **THEN** 系统 SHALL 在启动期配置校验失败,提示该字段必须为布尔值 - **THEN** 系统 SHALL 在启动期配置校验失败,提示该字段必须为布尔值
#### Scenario: 字符串原始值校验通过 #### Scenario: 字符串原始值在 Normalized schema 中被拒绝
- **WHEN** YAML 配置中 RawValueExpectation 字段值为字符串 `"stop"` - **WHEN** Normalized schema 校验 RawValueExpectation 字段值为字符串 `"stop"`
- **THEN** 系统 SHALL 接受该配置,但 SHALL NOT 在语义校验阶段修改输入对象 - **THEN** schema 校验 SHALL 失败,因为 Normalized Config 只接受 `ValueMatcher` 对象
#### Scenario: 数字原始值校验通过 #### Scenario: 数字原始值在 Normalized schema 中被拒绝
- **WHEN** YAML 配置中 RawValueExpectation 字段值为数字 `5000` - **WHEN** Normalized schema 校验 RawValueExpectation 字段值为数字 `5000`
- **THEN** 系统 SHALL 接受该配置,但 SHALL NOT 在语义校验阶段修改输入对象 - **THEN** schema 校验 SHALL 失败,因为 Normalized Config 只接受 `ValueMatcher` 对象
#### Scenario: null 原始值校验通过 #### Scenario: null 原始值在 Normalized schema 中被拒绝
- **WHEN** YAML 配置中 RawValueExpectation 字段值为 `null` - **WHEN** Normalized schema 校验 RawValueExpectation 字段值为 `null`
- **THEN** 系统 SHALL 接受该配置,但 SHALL NOT 在语义校验阶段修改输入对象 - **THEN** schema 校验 SHALL 失败,因为 Normalized Config 只接受 `ValueMatcher` 对象
#### Scenario: 数组原始值被拒绝 #### Scenario: 数组原始值被拒绝
- **WHEN** YAML 配置中 RawValueExpectation 字段值为数组 `[1, 2]` - **WHEN** YAML 配置中 RawValueExpectation 字段值为数组 `[1, 2]`
@@ -142,7 +142,7 @@
- **THEN** 系统 SHALL 在启动期配置校验失败,提示正则存在 ReDoS 风险 - **THEN** 系统 SHALL 在启动期配置校验失败,提示正则存在 ReDoS 风险
### Requirement: ContentExpectations 内容断言数组 ### 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 支持内容字段的非数组对象快捷写法。 系统 SHALL 提供共享 `ContentExpectations` 表达返回内容断言。Authoring `ContentExpectations` MUST 为有序数组,数组项 SHALL 为直接 `ValueMatcher`,或 `json``css``xpath` 三类 extractor expectation 之一。系统 SHALL 在 Normalized 阶段将 Authoring content DSL 解析为带 `kind` 字段的 Normalized `ContentExpectation` 执行计划,并按数组顺序执行全部 expectation任一 expectation 失败时 SHALL 立即停止并返回该 expectation 的 failure。系统 MUST NOT 支持内容字段的非数组对象快捷写法。
#### Scenario: 直接 matcher 内容 expectation #### Scenario: 直接 matcher 内容 expectation
- **WHEN** 内容字段配置 `[{contains: "ready"}, {regex: "listening on \\d+"}]` 且原始内容同时满足两条 expectation - **WHEN** 内容字段配置 `[{contains: "ready"}, {regex: "listening on \\d+"}]` 且原始内容同时满足两条 expectation
@@ -156,9 +156,9 @@
- **WHEN** YAML 中内容字段配置为 `{contains: "ok"}` 而不是数组 - **WHEN** YAML 中内容字段配置为 `{contains: "ok"}` 而不是数组
- **THEN** 系统 SHALL 在启动期配置校验失败,提示该内容字段必须为 expectation 数组 - **THEN** 系统 SHALL 在启动期配置校验失败,提示该内容字段必须为 expectation 数组
#### Scenario: Resolved content expectation 使用 kind #### Scenario: Normalized content expectation 使用 kind
- **WHEN** Raw 内容字段包含直接 matcher、json extractor、css extractor 和 xpath extractor - **WHEN** Authoring 内容字段包含直接 matcher、json extractor、css extractor 和 xpath extractor
- **THEN** resolve 阶段 SHALL 分别生成 `kind="value"``kind="json"``kind="css"``kind="xpath"` Resolved `ContentExpectation` - **THEN** Normalized 阶段 SHALL 分别生成 `kind="value"``kind="json"``kind="css"``kind="xpath"``ContentExpectation`
### Requirement: 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 报错。 一条 Raw `ContentExpectation` MUST 为直接 `ValueMatcher` 或恰好一个 extractor`json``css``xpath` 之一)。系统 MUST NOT 允许同一条 Raw expectation 同时包含多个 extractor。直接 `ValueMatcher` expectation MUST NOT 包含 `json``css``xpath` 字段。系统 SHALL 在启动期对违反互斥性的 Raw expectation 报错。
@@ -190,7 +190,7 @@
- **THEN** 系统 SHALL 直接在结构化值上使用深度相等比较 - **THEN** 系统 SHALL 直接在结构化值上使用深度相等比较
### Requirement: ContentExpectations 提取器 ### 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 }` 系统 SHALL 支持在 Authoring `ContentExpectations` 中使用 `json``css``xpath` extractor。`json.path` MUST 使用现有 JSONPath 子集。`css.selector` MUST 为非空字符串,并 MAY 配置 `attr` 提取属性值。`xpath.path` MUST 为非空字符串,并 SHALL 在启动期进行可编译校验。Extractor 内部 MAY 包含任意 `ValueMatcher` 字段。Extractor expectation 未配置任何 matcher 时,Normalized 阶段 SHALL 将其 matcher 物化为 `{ exists: true }`
#### Scenario: json extractor 数字比较 #### Scenario: json extractor 数字比较
- **WHEN** 原始内容为 JSON 字符串 `{"count": 2}` 且 expectation 为 `{json: {path: "$.count", gte: 1}}` - **WHEN** 原始内容为 JSON 字符串 `{"count": 2}` 且 expectation 为 `{json: {path: "$.count", gte: 1}}`
@@ -198,22 +198,22 @@
#### Scenario: json extractor 存在性默认语义 #### Scenario: json extractor 存在性默认语义
- **WHEN** 原始内容为 JSON 字符串 `{"user": {"id": null}}` 且 expectation 为 `{json: {path: "$.user.id"}}` - **WHEN** 原始内容为 JSON 字符串 `{"user": {"id": null}}` 且 expectation 为 `{json: {path: "$.user.id"}}`
- **THEN** resolve 阶段 SHALL 将该 expectation 的 matcher 物化为 `{exists: true}` 并在运行期判定通过 - **THEN** Normalized 阶段 SHALL 将该 expectation 的 matcher 物化为 `{exists: true}` 并在运行期判定通过
#### Scenario: css attr 存在性默认语义 #### Scenario: css attr 存在性默认语义
- **WHEN** 原始内容包含 `<meta name="status" content="ok">` 且 expectation 为 `{css: {selector: "meta[name=status]", attr: "content"}}` - **WHEN** 原始内容包含 `<meta name="status" content="ok">` 且 expectation 为 `{css: {selector: "meta[name=status]", attr: "content"}}`
- **THEN** resolve 阶段 SHALL 将该 expectation 的 matcher 物化为 `{exists: true}` 并在属性存在时判定通过 - **THEN** Normalized 阶段 SHALL 将该 expectation 的 matcher 物化为 `{exists: true}` 并在属性存在时判定通过
#### Scenario: xpath 无匹配节点失败 #### Scenario: xpath 无匹配节点失败
- **WHEN** XML 内容中不存在 XPath 指向的节点,且 expectation 为 `{xpath: {path: "/root/status"}}` - **WHEN** XML 内容中不存在 XPath 指向的节点,且 expectation 为 `{xpath: {path: "/root/status"}}`
- **THEN** 系统 SHALL 判定该 expectation 不通过并生成 phase 对应内容字段的 mismatch failure - **THEN** 系统 SHALL 判定该 expectation 不通过并生成 phase 对应内容字段的 mismatch failure
### Requirement: KeyedExpectations 键控断言数组 ### 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 归一化,按查询结果列名大小写敏感匹配。 系统 SHALL 提供共享 `KeyedExpectations` 表达键值型观测值断言。Authoring `KeyedExpectations` SHALL 为动态键对象,每个键对应的值 MUST 为 Authoring `RawValueExpectation`;数组和对象简写 MUST 被拒绝,数组或对象 equals 匹配 MUST 显式写为 `{equals: <value>}`Normalized `KeyedExpectations` SHALL 为有序数组,每个元素包含原始 key 和已归一化的 `ValueExpectation` matcher。调用方 MAY 指定 key 规范化策略HTTP 与 LLM headers MUST 使用大小写不敏感的 key 匹配,并 MUST 在启动期校验或 Normalized 阶段拒绝归一化后重复的 header key。DB rows 不做 key 归一化,按查询结果列名大小写敏感匹配。
#### Scenario: headers 字面量快捷写法 #### Scenario: headers 字面量快捷写法
- **WHEN** 响应 headers 中 `content-type``application/json`,且配置为 `headers: {Content-Type: "application/json"}` - **WHEN** 响应 headers 中 `content-type``application/json`,且配置为 `headers: {Content-Type: "application/json"}`
- **THEN** resolve 阶段 SHALL 将该项解析为 keyed expectation `{key: "Content-Type", matcher: {equals: "application/json"}}`,运行期按大小写不敏感 key 匹配并判定通过 - **THEN** Normalized 阶段 SHALL 将该项解析为 keyed expectation `{key: "Content-Type", matcher: {equals: "application/json"}}`,运行期按大小写不敏感 key 匹配并判定通过
#### Scenario: headers matcher 写法 #### Scenario: headers matcher 写法
- **WHEN** 响应 headers 中 `content-type``application/json; charset=utf-8`,且配置为 `headers: {Content-Type: {contains: "application/json"}}` - **WHEN** 响应 headers 中 `content-type``application/json; charset=utf-8`,且配置为 `headers: {Content-Type: {contains: "application/json"}}`

View File

@@ -98,15 +98,11 @@
- **THEN** 系统 SHALL 以错误退出并提示文件不存在 - **THEN** 系统 SHALL 以错误退出并提示文件不存在
### Requirement: 配置校验 ### Requirement: 配置校验
系统 SHALL 在启动时对 YAML 配置进行完整校验,校验失败时以非零状态退出并输出清晰的错误信息。系统 SHALL 使用 TypeBox 定义配置契约和 raw config TypeScript 类型,由 Ajv 校验 TypeBox 生成的 JSON Schema,再执行启动期语义 validator。配置加载流程 SHALL 明确区分 `RawProbeConfig``ValidatedProbeConfig``ResolvedConfig` 三段生命周期,并在 YAML 解析之后、AJV 校验之前执行变量替换阶段。JSON Schema 契约 SHALL 覆盖业务无关的结构规则,包括字段类型、必填字段、枚举、数组与对象形状、数值范围和未知字段。语义 validator SHALL 覆盖契约不适合表达的业务规则,包括 target id 唯一性、id 命名规则校验、checker type 注册状态、时长和大小解析、HTTP URL、正则可编译、JSONPath 子集和 XPath 可编译 系统 SHALL 在启动时对 YAML 配置进行完整校验,校验失败时以非零状态退出并输出清晰的错误信息。系统 SHALL 使用 TypeBox 定义 Authoring Config 和 Normalized Config 的配置契约,并使用 Ajv 校验 TypeBox 生成的 JSON Schema。配置加载流程 SHALL 明确区分 `AuthoringProbeConfig``NormalizedProbeConfig``ValidatedProbeConfig``ResolvedConfig` 生命周期,并在 YAML 解析之后、Normalized schema 校验之前执行 `normalizeAuthoringConfig()`。该 normalizer SHALL 只负责去糖变量替换、expect primitive/keyed/content 简写展开、移除 `variables` 段。该 normalizer MUST NOT 注入默认值、解析 duration/size/path/env或合并 `cmd.env`
契约校验和语义 validator SHALL 统一产出 `ConfigValidationIssue`,最终由配置加载流程统一渲染为中文错误信息 JSON Schema 契约 SHALL 覆盖业务无关的结构规则包括字段类型、必填字段、枚举、数组与对象形状、数值范围和未知字段。Authoring schema SHALL 用于导出的 `probe-config.schema.json`,描述用户 YAML 可书写形式,包括变量引用和 expect 简写。Normalized schema SHALL 用于运行时 AJV 校验,描述 normalizer 输出结果,不接受变量引用、不接受 expect primitive 简写、不包含 `variables` 段。语义 validator SHALL 覆盖契约不适合表达的业务规则,包括 target id 唯一性、id 命名规则校验、checker type 注册状态、时长和大小解析、HTTP URL、正则可编译、JSONPath 子集和 XPath 可编译
系统 SHALL 导出完整 `probe-config.schema.json`,该文件 SHALL 与运行期 TypeBox fragments 生成的 JSON Schema 保持一致,用于用户配置引用和编辑器提示 契约校验、normalizer 和语义 validator SHALL 统一产出 `ConfigValidationIssue`,最终由配置加载流程统一渲染为中文错误信息。除 `headers``env`、Authoring `variables` 等明确声明为动态键值表的对象外,配置中的未知字段 SHALL 导致启动期配置错误。系统 MUST NOT 静默忽略未知字段
`headers``env``variables` 等明确声明为动态键值表的对象外,配置中的未知字段 SHALL 导致启动期配置错误。系统 MUST NOT 静默忽略未知字段。
所有 `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 缺少必填字段 #### Scenario: target 缺少必填字段
- **WHEN** YAML 中某个 target 缺少 id 或 type 字段 - **WHEN** YAML 中某个 target 缺少 id 或 type 字段
@@ -145,7 +141,7 @@
- **THEN** 系统 SHALL 以错误退出并提示格式错误 - **THEN** 系统 SHALL 以错误退出并提示格式错误
#### Scenario: maxConcurrentChecks 非法 #### Scenario: maxConcurrentChecks 非法
- **WHEN** probes.execution.maxConcurrentChecks 不是正整数 - **WHEN** Normalized Config 中 probes.execution.maxConcurrentChecks 不是正整数
- **THEN** 系统 SHALL 以错误退出并提示 probes.execution.maxConcurrentChecks 格式错误 - **THEN** 系统 SHALL 以错误退出并提示 probes.execution.maxConcurrentChecks 格式错误
#### Scenario: interval 或 timeout 解析结果非法 #### Scenario: interval 或 timeout 解析结果非法
@@ -161,7 +157,7 @@
- **THEN** 系统 SHALL 以错误退出并提示必须为非负安全整数字节数 - **THEN** 系统 SHALL 以错误退出并提示必须为非负安全整数字节数
#### Scenario: HTTP method 非法 #### Scenario: HTTP method 非法
- **WHEN** YAML 中某个 HTTP target 的 `http.method` 不是 GET、HEAD、POST、PUT、PATCH、DELETE、OPTIONS 之一 - **WHEN** Normalized Config 中某个 HTTP target 的 `http.method` 不是 GET、HEAD、POST、PUT、PATCH、DELETE、OPTIONS 之一
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 method 不合法 - **THEN** 系统 SHALL 以错误退出,提示该 target 的 method 不合法
#### Scenario: HTTP method 小写非法 #### Scenario: HTTP method 小写非法
@@ -177,7 +173,7 @@
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 maxRedirects 必须为非负整数 - **THEN** 系统 SHALL 以错误退出,提示该 target 的 maxRedirects 必须为非负整数
#### Scenario: maxRedirects 非整数非法 #### Scenario: maxRedirects 非整数非法
- **WHEN** YAML 中某个 HTTP target 的 `http.maxRedirects` 不是非负整数(如 `1.5``"5"` - **WHEN** Normalized Config 中某个 HTTP target 的 `http.maxRedirects` 不是非负整数(如 `1.5``"5"`
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 maxRedirects 必须为非负整数 - **THEN** 系统 SHALL 以错误退出,提示该 target 的 maxRedirects 必须为非负整数
#### Scenario: ignoreSSL 类型非法 #### Scenario: ignoreSSL 类型非法
@@ -205,28 +201,28 @@
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 status 数字不合法 - **THEN** 系统 SHALL 以错误退出,提示该 target 的 status 数字不合法
#### Scenario: durationMs matcher 非法 #### Scenario: durationMs matcher 非法
- **WHEN** YAML 中某个 target 的 `expect.durationMs` 不是合法 `RawValueExpectation` - **WHEN** Normalized Config 中某个 target 的 `expect.durationMs` 不是合法 `ValueMatcher` 对象
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 expect.durationMs 格式错误 - **THEN** 系统 SHALL 以错误退出,提示该 target 的 expect.durationMs 格式错误
#### Scenario: durationMs 原始值简写合法 #### Scenario: durationMs 原始值简写在 Authoring schema 合法
- **WHEN** YAML 中某个 target 配置 `expect.durationMs: 5000` - **WHEN** 使用 Authoring schema 校验配置文件中 `expect.durationMs: 5000`
- **THEN** 语义校验 SHALL 接受该 Raw primitive 简写且 MUST NOT 修改输入checker resolve 阶段 SHALL 将其解析为 `{equals: 5000}` - **THEN** JSON Schema 校验 SHALL 通过,因为 Authoring schema 接受 primitive 简写
#### Scenario: ValueMatcher 字段字符串简写合 #### Scenario: durationMs 原始值简写在 Normalized schema 非
- **WHEN** YAML 中某个 target 配置 `expect.finishReason: "stop"` - **WHEN** 使用 Normalized schema 校验配置对象中 `expect.durationMs: 5000`
- **THEN** 语义校验 SHALL 接受该 Raw primitive 简写且 MUST NOT 修改输入checker resolve 阶段 SHALL 将其解析为 `{equals: "stop"}` - **THEN** JSON Schema 校验 SHALL 失败,因为 Normalized schema 只接受 `ValueMatcher` 对象
#### Scenario: ValueMatcher 字段 null 简写合法 #### Scenario: 变量引用在 Authoring schema 合法
- **WHEN** YAML 中某个 target 配置 ValueMatcher 字段值为 `null` - **WHEN** 使用 Authoring schema 校验配置文件中 `server.listen.port: "${PORT|3000}"`
- **THEN** 语义校验 SHALL 接受该 Raw primitive 简写且 MUST NOT 修改输入checker resolve 阶段 SHALL 将其解析为 `{equals: null}` - **THEN** JSON Schema 校验 SHALL 通过,因为 Authoring schema 面向用户可书写 YAML
#### Scenario: ValueMatcher 字段数组简写非法 #### Scenario: 变量引用在 Normalized schema 非法
- **WHEN** YAML 中某个 target 配置 ValueMatcher 字段值为数组 `[1, 2]` - **WHEN** 使用 Normalized schema 校验配置对象中 `server.listen.port: "${PORT|3000}"`
- **THEN** 系统 SHALL 以错误退出,提示该字段必须为 primitive 原始值或 matcher 对象;如需数组 equals 匹配应写成 `{equals: [1, 2]}` - **THEN** JSON Schema 校验 SHALL 失败,因为 Normalized schema 只接受变量替换后的 integer
#### Scenario: ValueMatcher 字段对象简写非法 #### Scenario: Authoring schema 对 integer/boolean/enum 字段接受变量引用
- **WHEN** YAML 中某个 target 配置 ValueMatcher 字段值为对象 `{foo: "bar"}`,且 `foo` 不是合法 matcher 字段 - **WHEN** 使用 Authoring schema 校验配置文件中 `http.maxRedirects: "${MAX_REDIRECTS|5}"``http.ignoreSSL: "${IGNORE_SSL|false}"``llm.provider: "${PROVIDER|openai}"`
- **THEN** 系统 SHALL 以错误退出,提示 `foo` 是未知 matcher如需对象 equals 匹配应写成 `{equals: {foo: "bar"}}` - **THEN** JSON Schema 校验 SHALL 通过,因为 Authoring schema 对支持变量替换的 integer/boolean/enum/pattern-string 字段使用 `anyOf: [originalType, {type: "string", pattern: "^\\$\\{[^}]+\\}$"}]` 额外接受完整变量引用字符串
#### Scenario: icmp target 缺少 host #### Scenario: icmp target 缺少 host
- **WHEN** YAML 中某个 target 配置 `type: icmp` 但缺少 `icmp.host` - **WHEN** YAML 中某个 target 配置 `type: icmp` 但缺少 `icmp.host`
@@ -292,37 +288,25 @@
- **WHEN** 系统执行 JSON Schema 契约校验 - **WHEN** 系统执行 JSON Schema 契约校验
- **THEN** 系统 MUST NOT 通过契约校验器强制转换类型、注入默认值或删除未知字段 - **THEN** 系统 MUST NOT 通过契约校验器强制转换类型、注入默认值或删除未知字段
#### Scenario: 变量替换后字段超长由 Normalized schema 的 maxLength 校验拦截
- **WHEN** Authoring Config 中 target 的 `description` 通过 `${...}` 变量替换后超过 500 个字符
- **THEN** Normalized schema SHALL 在 AJV 校验阶段以错误退出,提示 description 字段长度错误
#### Scenario: 配置生命周期分离 #### Scenario: 配置生命周期分离
- **WHEN** 系统加载配置文件 - **WHEN** 系统加载配置文件
- **THEN** 系统 SHALL 按 `unknown -> 变量替换 -> RawProbeConfig -> ValidatedProbeConfig -> ResolvedConfig` 的顺序执行变量替换、契约校验、语义校验和运行期配置解析 - **THEN** 系统 SHALL 按 `unknown -> AuthoringProbeConfig -> NormalizedProbeConfig -> ValidatedProbeConfig -> ResolvedConfig` 的顺序执行 YAML 解析、配置去糖、契约校验、语义校验和运行期配置解析
#### Scenario: Normalized 不补默认值
- **WHEN** Authoring Config 中 HTTP target 未配置 `http.method``expect.status`
- **THEN** Normalized Config SHALL 仍不包含这些默认值checker.resolve() SHALL 在 ResolvedConfig 阶段物化默认 method 和 status 语义
#### Scenario: 结构化校验 issue #### Scenario: 结构化校验 issue
- **WHEN** 契约校验、语义 validator 或变量替换阶段发现非法配置 - **WHEN** 契约校验、normalizer、语义 validator 或变量替换阶段发现非法配置
- **THEN** 系统 SHALL 先生成包含 code、path、message 和可选 targetName 的结构化 `ConfigValidationIssue`,再统一渲染为中文错误信息 - **THEN** 系统 SHALL 先生成包含 code、path、message 和可选 targetName 的结构化 `ConfigValidationIssue`,再统一渲染为中文错误信息
#### Scenario: 导出配置 JSON Schema #### Scenario: 导出配置 JSON Schema
- **WHEN** 仓库生成或检查配置契约 - **WHEN** 仓库生成或检查配置契约
- **THEN** 根目录 SHALL 存在 draft-07 `probe-config.schema.json`,且其内容 SHALL 与当前公共 fragments 和已注册 checker fragments 组装出的完整 schema 一致(包含 variables 段和 target 的 id/name 字段,且不包含顶层 defaults。所有 `RawValueExpectation` 字段的 schema SHALL 声明为 `anyOf: [primitiveValue, matcherObject]` 联合类型,`RawKeyedExpectations` 的 dynamic value schema SHALL 复用 `RawValueExpectation` - **THEN** 根目录 SHALL 存在 draft-07 `probe-config.schema.json`,且其内容 SHALL 与当前 Authoring fragments 和已注册 checker Authoring fragments 组装出的完整 schema 一致(包含 variables 段和 target 的 id/name/description 字段,且不包含顶层 defaults
#### Scenario: JSON Schema RawValueExpectation 接受原始值
- **WHEN** 使用 JSON Schema 校验配置文件中 RawValueExpectation 字段值为数字 `5000`
- **THEN** JSON Schema 校验 SHALL 通过,因为 RawValueExpectation schema 声明为 `anyOf: [primitiveValue, matcherObject]`
#### Scenario: JSON Schema RawValueExpectation 接受 matcher 对象
- **WHEN** 使用 JSON Schema 校验配置文件中 RawValueExpectation 字段值为 `{lte: 5000}`
- **THEN** JSON Schema 校验 SHALL 通过
#### Scenario: JSON Schema RawValueExpectation 拒绝数组原始值
- **WHEN** 使用 JSON Schema 校验配置文件中 RawValueExpectation 字段值为数组 `[1, 2]`
- **THEN** JSON Schema 校验 SHALL 失败,因为数组不属于 primitive 原始值或 matcher 对象
#### 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` 系统 SHALL 支持使用单位字符串配置读取上限,单位包括 `B``KB``MB``GB`
#### Scenario: 解析 MB #### Scenario: 解析 MB
@@ -352,21 +336,21 @@
- **THEN** 系统 SHALL 调用 `Bun.YAML.parse()` 将内容解析为配置对象 - **THEN** 系统 SHALL 调用 `Bun.YAML.parse()` 将内容解析为配置对象
### Requirement: expect 配置增强 ### Requirement: expect 配置增强
系统 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 支持 typed target 的领域专用 expect 配置,并通过共享 `ValueMatcher``ContentExpectations``KeyedExpectations` 表达可复用断言能力。状态类字段 SHALL 保持枚举或布尔语义,包括 HTTP/LLM 的 `status`支持精确数字和范围模式、cmd 的 `exitCode`、tcp 的 `connected`、icmp 的 `alive` 和 udp 的 `responded`Authoring value 类指标字段 SHALL 使用 `RawValueExpectation` 输入,并在 Normalized 阶段归一化为运行期 `ValueExpectation`,包括通用 `durationMs`、db 的 `rowCount`、udp 的 `responseSize`/`sourceHost`/`sourcePort`、icmp 的 `packetLossPercent`/`avgLatencyMs`/`maxLatencyMs`、llm 的 usage token 与 stream 首 token 耗时。Authoring 内容类字段 MUST 使用 `RawContentExpectations` 数组表达配置顺序,并在 Normalized 阶段转换为带 `kind``ContentExpectation` 数组,包括 HTTP `body`、cmd `stdout`/`stderr`、tcp `banner`、udp `response`、llm `output` 和 db `result`Authoring 键值类字段 SHALL 使用动态对象,并在 Normalized 阶段转换为 `KeyedExpectations` 数组,包括 HTTP/LLM `headers` 和 db `rows` 中的列值断言。
配置加载流程 SHALL 保留变量替换后的 Raw expect 作为用户配置快照,同时生成 Resolved expect 作为运行期执行计划。语义校验 SHALL 读取 Raw expect 并报告问题MUST NOT 原地归一化或修改 Raw expect。Store 持久化 SHALL 写入 Raw expectchecker execute SHALL 只消费 Resolved expect。 配置加载流程 MUST NOT 保留变量替换后的 Raw expect 作为执行路径依赖。语义校验 SHALL 读取 Normalized expect 并报告问题。Store 持久化 MUST NOT 依赖 Raw expectchecker execute SHALL 只消费 Resolved expect。
#### Scenario: 解析 HTTP expect 配置 #### Scenario: 解析 HTTP expect 配置
- **WHEN** YAML 配置文件中 HTTP target 的 expect 包含 status、headers、body expectation 数组和 durationMs matcher - **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 - **THEN** Normalized Config SHALL 包含 normalized keyed headers、normalized content body 和 normalized durationMschecker.resolve() SHALL 生成包含默认 status 的 HTTP Resolved expect
#### Scenario: 解析 cmd expect 配置 #### Scenario: 解析 cmd expect 配置
- **WHEN** YAML 配置文件中 cmd target 的 expect 包含 exitCode、stdout、stderr 和 durationMs matcher - **WHEN** YAML 配置文件中 cmd target 的 expect 包含 exitCode、stdout、stderr 和 durationMs matcher
- **THEN** 系统 SHALL 保留 Raw cmd expect 快照,并生成包含默认 exitCode、resolved stdout/stderr content expectations 和 resolved durationMs 的 cmd Resolved expect - **THEN** Normalized Config SHALL 包含 normalized stdout/stderr content expectations 和 normalized durationMschecker.resolve() SHALL 生成包含默认 exitCode 的 cmd Resolved expect
#### Scenario: 解析 db expect 配置 #### Scenario: 解析 db expect 配置
- **WHEN** YAML 配置文件中 db target 的 expect 包含 durationMs、rowCount、rows 和 result - **WHEN** YAML 配置文件中 db target 的 expect 包含 durationMs、rowCount、rows 和 result
- **THEN** 系统 SHALL 保留 Raw db expect 快照,并生成包含 resolved rowCount、rows keyed expectations 和 result content expectations 的 db Resolved expect - **THEN** Normalized Config SHALL 包含 normalized rowCount、rows keyed expectations 和 result content expectations
#### Scenario: 解析 tcp expect 配置 #### Scenario: 解析 tcp expect 配置
- **WHEN** YAML 配置文件中 tcp target 的 expect 包含 connected、banner expectation 数组和 durationMs matcher - **WHEN** YAML 配置文件中 tcp target 的 expect 包含 connected、banner expectation 数组和 durationMs matcher
@@ -386,11 +370,11 @@
#### Scenario: 解析有序 ContentExpectations 数组 #### Scenario: 解析有序 ContentExpectations 数组
- **WHEN** YAML 中任一内容类 expect 配置 contains、json、regex 三个数组项 - **WHEN** YAML 中任一内容类 expect 配置 contains、json、regex 三个数组项
- **THEN** 系统 SHALL 在 Raw expect 中保留数组顺序,并在 Resolved expect 中保留执行顺序,供执行阶段按配置顺序快速失败 - **THEN** 系统 SHALL 在 Normalized expect 中保留执行顺序,供执行阶段按配置顺序快速失败
#### Scenario: 不配置 HTTP status #### Scenario: 不配置 HTTP status
- **WHEN** HTTP target 未配置 `expect.status` - **WHEN** HTTP target 未配置 `expect.status`
- **THEN** 系统 SHALL 在 Resolved HTTP expect 中物化默认 `status: [200]` 语义 - **THEN** Normalized Config SHALL 不注入 statuschecker.resolve() SHALL 在 Resolved HTTP expect 中物化默认 `status: [200]` 语义
#### Scenario: 配置 HTTP status 范围模式 #### Scenario: 配置 HTTP status 范围模式
- **WHEN** HTTP target 配置 `expect.status: ["2xx"]` - **WHEN** HTTP target 配置 `expect.status: ["2xx"]`
@@ -398,15 +382,15 @@
#### Scenario: 不配置 cmd exitCode #### Scenario: 不配置 cmd exitCode
- **WHEN** cmd target 未配置 `expect.exitCode` - **WHEN** cmd target 未配置 `expect.exitCode`
- **THEN** 系统 SHALL 在 Resolved cmd expect 中物化默认 `exitCode: [0]` 语义 - **THEN** Normalized Config SHALL 不注入 exitCodechecker.resolve() SHALL 在 Resolved cmd expect 中物化默认 `exitCode: [0]` 语义
#### Scenario: 不配置 expect #### Scenario: 不配置 expect
- **WHEN** target 未配置任何 expect 规则 - **WHEN** target 未配置任何 expect 规则
- **THEN** 系统 SHALL 正常处理,Raw expect 快照为 undefinedResolved expect 由各 checker 物化自身默认状态语义 - **THEN** 系统 SHALL 正常处理,Normalized Config 不包含 expectResolved expect 由各 checker 物化自身默认状态语义
#### Scenario: Raw expect 不被语义校验修改 #### Scenario: Raw expect 不再保留
- **WHEN** YAML 中配置 `expect.durationMs: 1000` - **WHEN** YAML 中配置 `expect.durationMs: 1000`
- **THEN** 语义校验 SHALL 接受该 Raw primitive 简写且 MUST NOT 将 Raw 输入原地修改为 `{equals: 1000}` - **THEN** Normalized Config SHALL 包含 `expect.durationMs: {equals: 1000}`ResolvedTarget MUST NOT 携带 `rawExpect`
#### Scenario: 旧 maxDurationMs 字段不再支持 #### Scenario: 旧 maxDurationMs 字段不再支持
- **WHEN** YAML 中任一 target 配置 `expect.maxDurationMs` - **WHEN** YAML 中任一 target 配置 `expect.maxDurationMs`
@@ -426,16 +410,12 @@
#### Scenario: ContentExpectations 字段必须为数组 #### Scenario: ContentExpectations 字段必须为数组
- **WHEN** YAML 中任一内容类 expect 字段配置为非数组 - **WHEN** YAML 中任一内容类 expect 字段配置为非数组
- **THEN** 系统 SHALL 在启动期配置校验失败,提示该字段必须为 expectation 数组 - **THEN** 系统 SHALL 在启动期配置校验失败,提示该内容字段必须为 expectation 数组
#### Scenario: regex 字段非法 #### Scenario: regex 字段非法
- **WHEN** YAML 中任一 `regex` 不是字符串、不是可编译正则或存在 ReDoS 风险 - **WHEN** YAML 中任一 `regex` 不是字符串、不是可编译正则或存在 ReDoS 风险
- **THEN** 系统 SHALL 在启动期配置校验失败,提示对应 regex 不合法 - **THEN** 系统 SHALL 在启动期配置校验失败,提示对应 regex 不合法
#### Scenario: Store 持久化 Raw expect
- **WHEN** 系统同步已解析 target 到 targets 表
- **THEN** `targets.expect` SHALL 存储变量替换后的 Raw expect JSON而不是包含 `kind` 或 resolved matcher 的运行期执行计划
### Requirement: 数据保留配置字段 ### Requirement: 数据保留配置字段
配置 schema 的 `server.storage` 段 SHALL 支持 `retention` 字段,类型为字符串,格式为 `<数字><单位>`(单位:`d` 天、`h` 小时、`m` 分钟),用于指定历史数据保留时长。 配置 schema 的 `server.storage` 段 SHALL 支持 `retention` 字段,类型为字符串,格式为 `<数字><单位>`(单位:`d` 天、`h` 小时、`m` 分钟),用于指定历史数据保留时长。
@@ -510,16 +490,20 @@
- **THEN** 系统 SHALL 在契约校验阶段以错误退出,提示 description 字段长度错误 - **THEN** 系统 SHALL 在契约校验阶段以错误退出,提示 description 字段长度错误
### Requirement: 配置 schema 导出包含 target 元信息约束 ### Requirement: 配置 schema 导出包含 target 元信息约束
系统 SHALL 在导出的 `probe-config.schema.json` 中包含 target `id``name``description` 的长度约束和可空类型,用于编辑器提示和外部校验。 系统 SHALL 在导出的 Authoring `probe-config.schema.json` 中包含 target `id``name``description` 的长度约束和可空类型,用于编辑器提示和外部校验。导出 schema SHALL 面向用户可书写规则文件,因此还 SHALL 接受支持变量替换字段中的完整变量引用字符串和 expect 简写。
#### Scenario: schema 导出 description #### Scenario: schema 导出 description
- **WHEN** 系统导出 `probe-config.schema.json` - **WHEN** 系统导出 `probe-config.schema.json`
- **THEN** target schema SHALL 包含可选的 `description` 字段,类型为 string 或 null字符串最大长度为 500 - **THEN** target schema SHALL 包含可选的 `description` 字段,类型为 string 或 null字符串最大长度为 500,并允许完整变量引用字符串
#### Scenario: schema 导出 id 和 name #### Scenario: schema 导出 id 和 name
- **WHEN** 系统导出 `probe-config.schema.json` - **WHEN** 系统导出 `probe-config.schema.json`
- **THEN** target schema SHALL 声明 `id` 的 minLength 为 1、maxLength 为 30并声明 `name` 为可选字段,类型为 string 或 null字符串的 minLength 为 1、maxLength 为 30 - **THEN** target schema SHALL 声明 `id` 的 minLength 为 1、maxLength 为 30并声明 `name` 为可选字段,类型为 string 或 null字符串的 minLength 为 1、maxLength 为 30
#### Scenario: schema 导出面向 Authoring Config
- **WHEN** 系统导出 `probe-config.schema.json`
- **THEN** 导出 schema SHALL 接受 `server.listen.port: "${PORT|3000}"``expect.durationMs: 5000` 这类 Authoring 写法
### Requirement: TCP 配置校验 ### Requirement: TCP 配置校验
系统 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 输入。 系统 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 输入。

View File

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

View File

@@ -14,8 +14,16 @@
"type": "object", "type": "object",
"properties": { "properties": {
"maxConcurrentChecks": { "maxConcurrentChecks": {
"minimum": 1, "anyOf": [
"type": "integer" {
"minimum": 1,
"type": "integer"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
} }
} }
} }
@@ -33,9 +41,17 @@
"type": "string" "type": "string"
}, },
"port": { "port": {
"maximum": 65535, "anyOf": [
"minimum": 0, {
"type": "integer" "maximum": 65535,
"minimum": 0,
"type": "integer"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
} }
} }
}, },
@@ -50,27 +66,35 @@
"level": { "level": {
"anyOf": [ "anyOf": [
{ {
"const": "trace", "anyOf": [
"type": "string" {
"const": "trace",
"type": "string"
},
{
"const": "debug",
"type": "string"
},
{
"const": "info",
"type": "string"
},
{
"const": "warn",
"type": "string"
},
{
"const": "error",
"type": "string"
},
{
"const": "fatal",
"type": "string"
}
]
}, },
{ {
"const": "debug", "pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
},
{
"const": "info",
"type": "string"
},
{
"const": "warn",
"type": "string"
},
{
"const": "error",
"type": "string"
},
{
"const": "fatal",
"type": "string" "type": "string"
} }
] ]
@@ -84,27 +108,35 @@
"level": { "level": {
"anyOf": [ "anyOf": [
{ {
"const": "trace", "anyOf": [
"type": "string" {
"const": "trace",
"type": "string"
},
{
"const": "debug",
"type": "string"
},
{
"const": "info",
"type": "string"
},
{
"const": "warn",
"type": "string"
},
{
"const": "error",
"type": "string"
},
{
"const": "fatal",
"type": "string"
}
]
}, },
{ {
"const": "debug", "pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
},
{
"const": "info",
"type": "string"
},
{
"const": "warn",
"type": "string"
},
{
"const": "error",
"type": "string"
},
{
"const": "fatal",
"type": "string" "type": "string"
} }
] ]
@@ -120,22 +152,38 @@
"frequency": { "frequency": {
"anyOf": [ "anyOf": [
{ {
"const": "hourly", "anyOf": [
"type": "string" {
"const": "hourly",
"type": "string"
},
{
"const": "daily",
"type": "string"
},
{
"const": "weekly",
"type": "string"
}
]
}, },
{ {
"const": "daily", "pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
},
{
"const": "weekly",
"type": "string" "type": "string"
} }
] ]
}, },
"maxFiles": { "maxFiles": {
"minimum": 1, "anyOf": [
"type": "integer" {
"minimum": 1,
"type": "integer"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
}, },
"size": { "size": {
"anyOf": [ "anyOf": [
@@ -155,27 +203,35 @@
"level": { "level": {
"anyOf": [ "anyOf": [
{ {
"const": "trace", "anyOf": [
"type": "string" {
"const": "trace",
"type": "string"
},
{
"const": "debug",
"type": "string"
},
{
"const": "info",
"type": "string"
},
{
"const": "warn",
"type": "string"
},
{
"const": "error",
"type": "string"
},
{
"const": "fatal",
"type": "string"
}
]
}, },
{ {
"const": "debug", "pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
},
{
"const": "info",
"type": "string"
},
{
"const": "warn",
"type": "string"
},
{
"const": "error",
"type": "string"
},
{
"const": "fatal",
"type": "string" "type": "string"
} }
] ]
@@ -216,8 +272,16 @@
"type": "null" "type": "null"
}, },
{ {
"maxLength": 500, "anyOf": [
"type": "string" {
"maxLength": 500,
"type": "string"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
} }
] ]
}, },
@@ -656,9 +720,17 @@
"type": "null" "type": "null"
}, },
{ {
"maxLength": 30, "anyOf": [
"minLength": 1, {
"type": "string" "maxLength": 30,
"minLength": 1,
"type": "string"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
} }
] ]
}, },
@@ -681,12 +753,28 @@
}, },
"headers": { "headers": {
"additionalProperties": { "additionalProperties": {
"type": "string" "anyOf": [
{
"type": "string"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
}, },
"type": "object" "type": "object"
}, },
"ignoreSSL": { "ignoreSSL": {
"type": "boolean" "anyOf": [
{
"type": "boolean"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
}, },
"maxBodyBytes": { "maxBodyBytes": {
"anyOf": [ "anyOf": [
@@ -700,37 +788,53 @@
] ]
}, },
"maxRedirects": { "maxRedirects": {
"minimum": 0, "anyOf": [
"type": "integer" {
"minimum": 0,
"type": "integer"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
}, },
"method": { "method": {
"anyOf": [ "anyOf": [
{ {
"const": "DELETE", "anyOf": [
"type": "string" {
"const": "DELETE",
"type": "string"
},
{
"const": "GET",
"type": "string"
},
{
"const": "HEAD",
"type": "string"
},
{
"const": "OPTIONS",
"type": "string"
},
{
"const": "PATCH",
"type": "string"
},
{
"const": "POST",
"type": "string"
},
{
"const": "PUT",
"type": "string"
}
]
}, },
{ {
"const": "GET", "pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
},
{
"const": "HEAD",
"type": "string"
},
{
"const": "OPTIONS",
"type": "string"
},
{
"const": "PATCH",
"type": "string"
},
{
"const": "POST",
"type": "string"
},
{
"const": "PUT",
"type": "string" "type": "string"
} }
] ]
@@ -758,8 +862,16 @@
"type": "null" "type": "null"
}, },
{ {
"maxLength": 500, "anyOf": [
"type": "string" {
"maxLength": 500,
"type": "string"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
} }
] ]
}, },
@@ -1353,9 +1465,17 @@
"type": "null" "type": "null"
}, },
{ {
"maxLength": 30, "anyOf": [
"minLength": 1, {
"type": "string" "maxLength": 30,
"minLength": 1,
"type": "string"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
} }
] ]
}, },
@@ -1422,8 +1542,16 @@
"type": "null" "type": "null"
}, },
{ {
"maxLength": 500, "anyOf": [
"type": "string" {
"maxLength": 500,
"type": "string"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
} }
] ]
}, },
@@ -1924,9 +2052,17 @@
"type": "null" "type": "null"
}, },
{ {
"maxLength": 30, "anyOf": [
"minLength": 1, {
"type": "string" "maxLength": 30,
"minLength": 1,
"type": "string"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
} }
] ]
}, },
@@ -1971,8 +2107,16 @@
"type": "null" "type": "null"
}, },
{ {
"maxLength": 500, "anyOf": [
"type": "string" {
"maxLength": 500,
"type": "string"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
} }
] ]
}, },
@@ -2224,7 +2368,15 @@
} }
}, },
"connected": { "connected": {
"type": "boolean" "anyOf": [
{
"type": "boolean"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
}, },
"durationMs": { "durationMs": {
"anyOf": [ "anyOf": [
@@ -2320,9 +2472,17 @@
"type": "null" "type": "null"
}, },
{ {
"maxLength": 30, "anyOf": [
"minLength": 1, {
"type": "string" "maxLength": 30,
"minLength": 1,
"type": "string"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
} }
] ]
}, },
@@ -2361,12 +2521,28 @@
] ]
}, },
"port": { "port": {
"maximum": 65535, "anyOf": [
"minimum": 1, {
"type": "integer" "maximum": 65535,
"minimum": 1,
"type": "integer"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
}, },
"readBanner": { "readBanner": {
"type": "boolean" "anyOf": [
{
"type": "boolean"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
} }
} }
} }
@@ -2387,8 +2563,16 @@
"type": "null" "type": "null"
}, },
{ {
"maxLength": 500, "anyOf": [
"type": "string" {
"maxLength": 500,
"type": "string"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
} }
] ]
}, },
@@ -2397,7 +2581,15 @@
"type": "object", "type": "object",
"properties": { "properties": {
"alive": { "alive": {
"type": "boolean" "anyOf": [
{
"type": "boolean"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
}, },
"avgLatencyMs": { "avgLatencyMs": {
"anyOf": [ "anyOf": [
@@ -2718,9 +2910,17 @@
"type": "null" "type": "null"
}, },
{ {
"maxLength": 30, "anyOf": [
"minLength": 1, {
"type": "string" "maxLength": 30,
"minLength": 1,
"type": "string"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
} }
] ]
}, },
@@ -2739,18 +2939,34 @@
], ],
"properties": { "properties": {
"count": { "count": {
"maximum": 100, "anyOf": [
"minimum": 1, {
"type": "integer" "maximum": 100,
"minimum": 1,
"type": "integer"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
}, },
"host": { "host": {
"minLength": 1, "minLength": 1,
"type": "string" "type": "string"
}, },
"packetSize": { "packetSize": {
"maximum": 65500, "anyOf": [
"minimum": 1, {
"type": "integer" "maximum": 65500,
"minimum": 1,
"type": "integer"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
} }
} }
} }
@@ -2771,8 +2987,16 @@
"type": "null" "type": "null"
}, },
{ {
"maxLength": 500, "anyOf": [
"type": "string" {
"maxLength": 500,
"type": "string"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
} }
] ]
}, },
@@ -2856,7 +3080,15 @@
] ]
}, },
"responded": { "responded": {
"type": "boolean" "anyOf": [
{
"type": "boolean"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
}, },
"response": { "response": {
"type": "array", "type": "array",
@@ -3345,9 +3577,17 @@
"type": "null" "type": "null"
}, },
{ {
"maxLength": 30, "anyOf": [
"minLength": 1, {
"type": "string" "maxLength": 30,
"minLength": 1,
"type": "string"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
} }
] ]
}, },
@@ -3369,15 +3609,23 @@
"encoding": { "encoding": {
"anyOf": [ "anyOf": [
{ {
"const": "text", "anyOf": [
"type": "string" {
"const": "text",
"type": "string"
},
{
"const": "hex",
"type": "string"
},
{
"const": "base64",
"type": "string"
}
]
}, },
{ {
"const": "hex", "pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
},
{
"const": "base64",
"type": "string" "type": "string"
} }
] ]
@@ -3401,22 +3649,38 @@
"type": "string" "type": "string"
}, },
"port": { "port": {
"maximum": 65535, "anyOf": [
"minimum": 1, {
"type": "integer" "maximum": 65535,
"minimum": 1,
"type": "integer"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
}, },
"responseEncoding": { "responseEncoding": {
"anyOf": [ "anyOf": [
{ {
"const": "text", "anyOf": [
"type": "string" {
"const": "text",
"type": "string"
},
{
"const": "hex",
"type": "string"
},
{
"const": "base64",
"type": "string"
}
]
}, },
{ {
"const": "hex", "pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
},
{
"const": "base64",
"type": "string" "type": "string"
} }
] ]
@@ -3440,8 +3704,16 @@
"type": "null" "type": "null"
}, },
{ {
"maxLength": 500, "anyOf": [
"type": "string" {
"maxLength": 500,
"type": "string"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
} }
] ]
}, },
@@ -4016,7 +4288,15 @@
"type": "object", "type": "object",
"properties": { "properties": {
"completed": { "completed": {
"type": "boolean" "anyOf": [
{
"type": "boolean"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
}, },
"firstTokenMs": { "firstTokenMs": {
"anyOf": [ "anyOf": [
@@ -4345,9 +4625,17 @@
"type": "null" "type": "null"
}, },
{ {
"maxLength": 30, "anyOf": [
"minLength": 1, {
"type": "string" "maxLength": 30,
"minLength": 1,
"type": "string"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
} }
] ]
}, },
@@ -4373,12 +4661,28 @@
}, },
"headers": { "headers": {
"additionalProperties": { "additionalProperties": {
"type": "string" "anyOf": [
{
"type": "string"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
}, },
"type": "object" "type": "object"
}, },
"ignoreSSL": { "ignoreSSL": {
"type": "boolean" "anyOf": [
{
"type": "boolean"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
}, },
"key": { "key": {
"type": "string" "type": "string"
@@ -4386,11 +4690,19 @@
"mode": { "mode": {
"anyOf": [ "anyOf": [
{ {
"const": "http", "anyOf": [
"type": "string" {
"const": "http",
"type": "string"
},
{
"const": "stream",
"type": "string"
}
]
}, },
{ {
"const": "stream", "pattern": "^\\$\\{[^}]+\\}$",
"type": "string" "type": "string"
} }
] ]
@@ -4407,8 +4719,16 @@
"type": "number" "type": "number"
}, },
"maxOutputTokens": { "maxOutputTokens": {
"minimum": 1, "anyOf": [
"type": "integer" {
"minimum": 1,
"type": "integer"
},
{
"pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
}
]
}, },
"presencePenalty": { "presencePenalty": {
"type": "number" "type": "number"
@@ -4440,15 +4760,23 @@
"provider": { "provider": {
"anyOf": [ "anyOf": [
{ {
"const": "openai", "anyOf": [
"type": "string" {
"const": "openai",
"type": "string"
},
{
"const": "openai-responses",
"type": "string"
},
{
"const": "anthropic",
"type": "string"
}
]
}, },
{ {
"const": "openai-responses", "pattern": "^\\$\\{[^}]+\\}$",
"type": "string"
},
{
"const": "anthropic",
"type": "string" "type": "string"
} }
] ]

View File

@@ -3,7 +3,7 @@
server: server:
listen: listen:
host: "127.0.0.1" host: "127.0.0.1"
port: 3000 port: "${server_port|3000}"
storage: storage:
dataDir: "/tmp/probes_data" dataDir: "/tmp/probes_data"
# logging: # logging:
@@ -20,12 +20,14 @@ server:
probes: probes:
execution: execution:
maxConcurrentChecks: 20 maxConcurrentChecks: "${max_checks|20}"
variables: variables:
env_name: "演示" env_name: "演示"
httpbin_base: "https://httpbin.org" httpbin_base: "https://httpbin.org"
api_token: "Bearer demo-token" api_token: "Bearer demo-token"
max_checks: 20
server_port: 3000
sqlite_url: "sqlite://:memory:" sqlite_url: "sqlite://:memory:"
targets: targets:

View File

@@ -13,12 +13,12 @@ import type {
ServerStorageConfig, ServerStorageConfig,
} from "./types"; } from "./types";
import { normalizeAuthoringConfig } from "./normalizer";
import { checkerRegistry } from "./runner"; import { checkerRegistry } from "./runner";
import { issue, throwConfigIssues } from "./schema/issues"; import { issue, throwConfigIssues } from "./schema/issues";
import { asValidatedConfig, type RawProbeConfig } from "./schema/types"; import { asValidatedConfig, type NormalizedProbeConfig } from "./schema/types";
import { validateProbeConfigContract } from "./schema/validate"; import { validateProbeConfigContract } from "./schema/validate";
import { parseDuration, parseSize } from "./utils"; import { parseDuration, parseSize } from "./utils";
import { resolveVariables } from "./variables";
const DEFAULT_HOST = "127.0.0.1"; const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 3000; const DEFAULT_PORT = 3000;
@@ -60,17 +60,17 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
throw new Error("配置文件内容为空或格式无效"); throw new Error("配置文件内容为空或格式无效");
} }
const variableResult = resolveVariables(parsed); const normalizeResult = normalizeAuthoringConfig(parsed);
if (variableResult.issues.length > 0) { if (normalizeResult.issues.length > 0) {
throwConfigIssues(dedupeIssues(variableResult.issues)); throwConfigIssues(dedupeIssues(normalizeResult.issues));
} }
const resolvedVariablesConfig = variableResult.config; const normalizedConfig = normalizeResult.config;
const contractResult = validateProbeConfigContract(resolvedVariablesConfig, checkerRegistry); const contractResult = validateProbeConfigContract(normalizedConfig, checkerRegistry);
if (contractResult.config === null && !canRunSemanticValidation(resolvedVariablesConfig)) { if (contractResult.config === null && !canRunSemanticValidation(normalizedConfig)) {
throwConfigIssues(contractResult.issues); throwConfigIssues(contractResult.issues);
} }
const semanticInput = (contractResult.config ?? resolvedVariablesConfig) as RawProbeConfig; const semanticInput = (contractResult.config ?? normalizedConfig) as NormalizedProbeConfig;
const validationIssues = validateConfig(semanticInput); const validationIssues = validateConfig(semanticInput);
const allIssues = [...contractResult.issues, ...validationIssues]; const allIssues = [...contractResult.issues, ...validationIssues];
@@ -208,7 +208,7 @@ function resolveTarget(
return result; return result;
} }
function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] { function validateConfig(config: NormalizedProbeConfig): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
if (!Array.isArray(config.targets) || config.targets.length === 0) { if (!Array.isArray(config.targets) || config.targets.length === 0) {
issues.push(issue("required", "targets", "配置文件必须包含至少一个 target")); issues.push(issue("required", "targets", "配置文件必须包含至少一个 target"));

View File

@@ -22,7 +22,7 @@ import type {
} from "./types"; } from "./types";
import { errorFailure, mismatchFailure } from "./failure"; import { errorFailure, mismatchFailure } from "./failure";
import { CONTENT_EXTRACTOR_KEY_SET, MATCHER_KEY_SET } from "./keys"; import { MATCHER_KEY_SET } from "./keys";
import { applyValueMatcher, displayValueExpectation, evaluateJsonPath } from "./value"; import { applyValueMatcher, displayValueExpectation, evaluateJsonPath } from "./value";
type ParsedJsonResult = { error: string; ok: false } | { ok: true; value: unknown }; type ParsedJsonResult = { error: string; ok: false } | { ok: true; value: unknown };
@@ -238,7 +238,7 @@ function resolveContentExpectation(raw: RawContentExpectation): ContentExpectati
} }
const record = raw as Record<string, unknown>; const record = raw as Record<string, unknown>;
if (CONTENT_EXTRACTOR_KEY_SET.has("json") && isPlainObject(record["json"])) { if (isPlainObject(record["json"])) {
const json = record["json"] as RawContentJsonExpectation; const json = record["json"] as RawContentJsonExpectation;
return { return {
kind: "json", kind: "json",

View File

@@ -58,6 +58,7 @@ export function validateRawKeyedExpectations(
targetName?: string, targetName?: string,
options?: { caseInsensitive?: boolean }, options?: { caseInsensitive?: boolean },
): ConfigValidationIssue[] { ): ConfigValidationIssue[] {
if (Array.isArray(value)) return validateNormalizedKeyedExpectations(value, path, targetName, options);
if (!isPlainRecord(value)) return [issue("invalid-type", path, "必须为对象", targetName)]; if (!isPlainRecord(value)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
@@ -196,12 +197,76 @@ function validateMatcherValue(key: string, value: unknown, path: string, targetN
} }
} }
function validateNormalizedContentExpectation(
expectation: Record<string, unknown>,
path: string,
targetName?: string,
): ConfigValidationIssue[] {
const kind = expectation["kind"];
const matcherPath = joinPath(path, "matcher");
const issues = validateRawValueExpectation(expectation["matcher"], matcherPath, targetName);
switch (kind) {
case "css":
if (!isString(expectation["selector"]) || expectation["selector"].trim() === "") {
issues.push(issue("invalid-type", joinPath(path, "selector"), "必须为非空字符串", targetName));
}
if ("attr" in expectation && !isString(expectation["attr"])) {
issues.push(issue("invalid-type", joinPath(path, "attr"), "必须为字符串", targetName));
}
return issues;
case "json":
return isString(expectation["path"])
? [...issues, ...validateJsonPath(expectation["path"], path, targetName)]
: [...issues, issue("invalid-type", joinPath(path, "path"), "必须为字符串", targetName)];
case "value":
return issues;
case "xpath":
return isString(expectation["path"])
? [...issues, ...validateXpathExpectation({ path: expectation["path"] }, path, targetName)]
: [...issues, issue("invalid-type", joinPath(path, "path"), "必须为非空字符串", targetName)];
default:
return [...issues, issue("invalid-type", joinPath(path, "kind"), "必须为 value、json、css 或 xpath", targetName)];
}
}
function validateNormalizedKeyedExpectations(
value: unknown[],
path: string,
targetName?: string,
options?: { caseInsensitive?: boolean },
): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const seen = new Map<string, string>();
for (let i = 0; i < value.length; i++) {
const itemPath = `${path}[${i}]`;
const item = value[i];
if (!isPlainRecord(item)) {
issues.push(issue("invalid-type", itemPath, "必须为对象", targetName));
continue;
}
if (!isString(item["key"])) {
issues.push(issue("invalid-type", joinPath(itemPath, "key"), "必须为字符串", targetName));
} else if (options?.caseInsensitive) {
const normalized = item["key"].toLowerCase();
const prev = seen.get(normalized);
if (prev !== undefined) {
issues.push(issue("duplicate-key", joinPath(itemPath, "key"), `与 "${prev}" 大小写归一化后重复`, targetName));
} else {
seen.set(normalized, item["key"]);
}
}
issues.push(...validateRawValueExpectation(item["matcher"], joinPath(itemPath, "matcher"), targetName));
}
return issues;
}
function validateRawContentExpectation( function validateRawContentExpectation(
expectation: unknown, expectation: unknown,
path: string, path: string,
targetName?: string, targetName?: string,
): ConfigValidationIssue[] { ): ConfigValidationIssue[] {
if (!isPlainRecord(expectation)) return [issue("invalid-type", path, "必须为对象", targetName)]; if (!isPlainRecord(expectation)) return [issue("invalid-type", path, "必须为对象", targetName)];
if (isString(expectation["kind"])) return validateNormalizedContentExpectation(expectation, path, targetName);
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
const extractors = Object.keys(expectation).filter((key) => CONTENT_EXTRACTOR_KEY_SET.has(key)); const extractors = Object.keys(expectation).filter((key) => CONTENT_EXTRACTOR_KEY_SET.has(key));

View File

@@ -0,0 +1,187 @@
import { isPlainObject } from "es-toolkit";
import type { ConfigValidationIssue } from "./schema/issues";
import type { AuthoringProbeConfig, NormalizedProbeConfig } from "./schema/types";
import type { RawTargetConfig } from "./types";
import { resolveContentExpectations } from "./expect/content";
import { CONTENT_EXTRACTOR_KEY_SET, MATCHER_KEY_SET } from "./expect/keys";
import { isValueMatcherObject, isValueMatcherPrimitive, resolveValueExpectation } from "./expect/value";
import { resolveVariables } from "./variables";
type ExpectRecord = Record<string, unknown>;
export function normalizeAuthoringConfig(config: unknown): {
config: unknown;
issues: ConfigValidationIssue[];
} {
const variableResult = resolveVariables(config);
if (!isPlainObject(variableResult.config)) {
return variableResult;
}
const normalized = { ...(variableResult.config as Record<string, unknown>) };
delete normalized["variables"];
if (Array.isArray(normalized["targets"])) {
normalized["targets"] = normalized["targets"].map((target) => normalizeTarget(target));
}
return { config: normalized, issues: variableResult.issues };
}
function canNormalizeContentEntry(value: unknown): boolean {
if (!isPlainObject(value)) return false;
const keys = Object.keys(value);
const extractorKeys = keys.filter((key) => CONTENT_EXTRACTOR_KEY_SET.has(key));
const matcherKeys = keys.filter((key) => MATCHER_KEY_SET.has(key));
if (extractorKeys.length === 0) return matcherKeys.length > 0 && matcherKeys.length === keys.length;
if (extractorKeys.length !== 1 || matcherKeys.length > 0 || keys.length !== 1) return false;
return isPlainObject((value as ExpectRecord)[extractorKeys[0]!]);
}
function compact(original: ExpectRecord, overrides: ExpectRecord): ExpectRecord {
const result: ExpectRecord = {};
for (const [key, value] of Object.entries(original)) {
if (value !== undefined) result[key] = value;
}
for (const [key, value] of Object.entries(overrides)) {
if (value !== undefined) result[key] = value;
}
return result;
}
function normalizeCommandExpect(raw: ExpectRecord): ExpectRecord {
return compact(raw, {
durationMs: normalizeValue(raw["durationMs"]),
exitCode: raw["exitCode"],
stderr: normalizeContent(raw["stderr"]),
stdout: normalizeContent(raw["stdout"]),
});
}
function normalizeContent(value: unknown): unknown {
if (value === undefined) return undefined;
if (!Array.isArray(value)) return value;
return (value as unknown[]).map((entry): unknown => {
if (!canNormalizeContentEntry(entry)) return entry;
const resolved = resolveContentExpectations([entry] as never);
return resolved?.[0];
});
}
function normalizeDbExpect(raw: ExpectRecord): ExpectRecord {
return compact(raw, {
durationMs: normalizeValue(raw["durationMs"]),
result: normalizeContent(raw["result"]),
rowCount: normalizeValue(raw["rowCount"]),
rows: Array.isArray(raw["rows"]) ? raw["rows"].map((row) => normalizeKeyed(row)) : raw["rows"],
});
}
function normalizeExpect(type: string, expect: unknown): unknown {
if (!isPlainObject(expect)) return expect;
const raw = expect as ExpectRecord;
switch (type) {
case "cmd":
return normalizeCommandExpect(raw);
case "db":
return normalizeDbExpect(raw);
case "http":
return normalizeHttpExpect(raw);
case "icmp":
return normalizeIcmpExpect(raw);
case "llm":
return normalizeLlmExpect(raw);
case "tcp":
return normalizeTcpExpect(raw);
case "udp":
return normalizeUdpExpect(raw);
default:
return expect;
}
}
function normalizeHttpExpect(raw: ExpectRecord): ExpectRecord {
return compact(raw, {
body: normalizeContent(raw["body"]),
durationMs: normalizeValue(raw["durationMs"]),
headers: normalizeKeyed(raw["headers"]),
status: raw["status"],
});
}
function normalizeIcmpExpect(raw: ExpectRecord): ExpectRecord {
return compact(raw, {
alive: raw["alive"],
avgLatencyMs: normalizeValue(raw["avgLatencyMs"]),
durationMs: normalizeValue(raw["durationMs"]),
maxLatencyMs: normalizeValue(raw["maxLatencyMs"]),
packetLossPercent: normalizeValue(raw["packetLossPercent"]),
});
}
function normalizeKeyed(value: unknown): unknown {
if (value === undefined) return undefined;
if (!isPlainObject(value)) return value;
return Object.entries(value as ExpectRecord).map(([key, item]) => ({ key, matcher: normalizeValue(item) }));
}
function normalizeLlmExpect(raw: ExpectRecord): ExpectRecord {
return compact(raw, {
durationMs: normalizeValue(raw["durationMs"]),
finishReason: normalizeValue(raw["finishReason"]),
headers: normalizeKeyed(raw["headers"]),
output: normalizeContent(raw["output"]),
rawFinishReason: normalizeValue(raw["rawFinishReason"]),
status: raw["status"],
stream: isPlainObject(raw["stream"])
? compact(raw["stream"] as ExpectRecord, {
completed: (raw["stream"] as ExpectRecord)["completed"],
firstTokenMs: normalizeValue((raw["stream"] as ExpectRecord)["firstTokenMs"]),
})
: raw["stream"],
usage: isPlainObject(raw["usage"])
? compact(raw["usage"] as ExpectRecord, {
inputTokens: normalizeValue((raw["usage"] as ExpectRecord)["inputTokens"]),
outputTokens: normalizeValue((raw["usage"] as ExpectRecord)["outputTokens"]),
totalTokens: normalizeValue((raw["usage"] as ExpectRecord)["totalTokens"]),
})
: raw["usage"],
});
}
function normalizeTarget(target: unknown): unknown {
if (!isPlainObject(target)) return target;
const result = { ...(target as RawTargetConfig) };
if (result.expect !== undefined) {
result.expect = normalizeExpect(result.type, result.expect);
}
return result;
}
function normalizeTcpExpect(raw: ExpectRecord): ExpectRecord {
return compact(raw, {
banner: normalizeContent(raw["banner"]),
connected: raw["connected"],
durationMs: normalizeValue(raw["durationMs"]),
});
}
function normalizeUdpExpect(raw: ExpectRecord): ExpectRecord {
return compact(raw, {
durationMs: normalizeValue(raw["durationMs"]),
responded: raw["responded"],
response: normalizeContent(raw["response"]),
responseSize: normalizeValue(raw["responseSize"]),
sourceHost: normalizeValue(raw["sourceHost"]),
sourcePort: normalizeValue(raw["sourcePort"]),
});
}
function normalizeValue(value: unknown): unknown {
if (value === undefined) return undefined;
if (isValueMatcherPrimitive(value) || isValueMatcherObject(value)) return resolveValueExpectation(value);
return value;
}
export type { AuthoringProbeConfig, NormalizedProbeConfig };

View File

@@ -3,16 +3,11 @@ import { resolve } from "node:path";
import type { CheckResult, RawTargetConfig } from "../../types"; import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types"; import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { import type { CommandTargetConfig, ResolvedCommandExpectConfig, ResolvedCommandTarget } from "./types";
CommandTargetConfig,
RawCommandExpectConfig,
ResolvedCommandExpectConfig,
ResolvedCommandTarget,
} from "./types";
import { checkContentExpectations, resolveContentExpectations } from "../../expect/content"; import { checkContentExpectations } from "../../expect/content";
import { errorFailure } from "../../expect/failure"; import { errorFailure } from "../../expect/failure";
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value"; import { checkValueExpectation } from "../../expect/value";
import { parseSize } from "../../utils"; import { parseSize } from "../../utils";
import { checkExitCode } from "./expect"; import { checkExitCode } from "./expect";
import { commandCheckerSchemas } from "./schema"; import { commandCheckerSchemas } from "./schema";
@@ -217,13 +212,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
const env = { ...process.env, ...(t.cmd.env ?? {}) } as Record<string, string>; const env = { ...process.env, ...(t.cmd.env ?? {}) } as Record<string, string>;
const rawExpect = target.expect as RawCommandExpectConfig | undefined; const expect = target.expect as ResolvedCommandExpectConfig | undefined;
const resolvedExpect: ResolvedCommandExpectConfig = rawExpect const resolvedExpect: ResolvedCommandExpectConfig = expect
? { ? {
durationMs: resolveValueExpectation(rawExpect.durationMs), ...expect,
exitCode: rawExpect.exitCode ?? [0], exitCode: expect.exitCode ?? [0],
stderr: resolveContentExpectations(rawExpect.stderr),
stdout: resolveContentExpectations(rawExpect.stdout),
} }
: { exitCode: [0] }; : { exitCode: [0] };
@@ -241,7 +234,6 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
id: t.id, id: t.id,
intervalMs: context.defaultIntervalMs, intervalMs: context.defaultIntervalMs,
name: t.name ?? null, name: t.name ?? null,
rawExpect,
timeoutMs: context.defaultTimeoutMs, timeoutMs: context.defaultTimeoutMs,
type: "cmd", type: "cmd",
} satisfies ResolvedCommandTarget; } satisfies ResolvedCommandTarget;

View File

@@ -3,14 +3,27 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types"; import type { CheckerSchemas } from "../types";
import { import {
createRawContentExpectationsSchema, createAuthoringContentExpectationsSchema,
createRawValueExpectationSchema, createAuthoringValueExpectationSchema,
createNormalizedContentExpectationsSchema,
createNormalizedValueExpectationSchema,
sizeSchema, sizeSchema,
stringMapSchema, stringMapSchema,
} from "../../schema/fragments"; } from "../../schema/fragments";
export const commandCheckerSchemas: CheckerSchemas = { export const commandCheckerSchemas: CheckerSchemas = {
config: Type.Object( authoring: {
config: createCommandConfigSchema(),
expect: createCommandExpectSchema("authoring"),
},
normalized: {
config: createCommandConfigSchema(),
expect: createCommandExpectSchema("normalized"),
},
};
function createCommandConfigSchema() {
return Type.Object(
{ {
args: Type.Optional(Type.Array(Type.String())), args: Type.Optional(Type.Array(Type.String())),
cwd: Type.Optional(Type.String()), cwd: Type.Optional(Type.String()),
@@ -19,14 +32,23 @@ export const commandCheckerSchemas: CheckerSchemas = {
maxOutputBytes: Type.Optional(sizeSchema), maxOutputBytes: Type.Optional(sizeSchema),
}, },
{ additionalProperties: false }, { additionalProperties: false },
), );
expect: Type.Object( }
function createCommandExpectSchema(kind: "authoring" | "normalized") {
return Type.Object(
{ {
durationMs: Type.Optional(createRawValueExpectationSchema()), durationMs: Type.Optional(
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
),
exitCode: Type.Optional(Type.Array(Type.Integer())), exitCode: Type.Optional(Type.Array(Type.Integer())),
stderr: Type.Optional(createRawContentExpectationsSchema()), stderr: Type.Optional(
stdout: Type.Optional(createRawContentExpectationsSchema()), kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(),
),
stdout: Type.Optional(
kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(),
),
}, },
{ additionalProperties: false }, { additionalProperties: false },
), );
}; }

View File

@@ -42,7 +42,6 @@ export interface ResolvedCommandTarget extends ResolvedTargetBase {
group: string; group: string;
intervalMs: number; intervalMs: number;
name: null | string; name: null | string;
rawExpect?: RawCommandExpectConfig;
timeoutMs: number; timeoutMs: number;
type: "cmd"; type: "cmd";
} }

View File

@@ -3,12 +3,11 @@ import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types"; import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types"; import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { DbTargetConfig, RawDbExpectConfig, ResolvedDbExpectConfig, ResolvedDbTarget } from "./types"; import type { DbTargetConfig, ResolvedDbExpectConfig, ResolvedDbTarget } from "./types";
import { checkContentExpectations, resolveContentExpectations } from "../../expect/content"; import { checkContentExpectations } from "../../expect/content";
import { errorFailure } from "../../expect/failure"; import { errorFailure } from "../../expect/failure";
import { resolveKeyedExpectations } from "../../expect/keyed"; import { checkValueExpectation } from "../../expect/value";
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value";
import { checkRowCount, checkRows } from "./expect"; import { checkRowCount, checkRows } from "./expect";
import { dbCheckerSchemas } from "./schema"; import { dbCheckerSchemas } from "./schema";
import { validateDbConfig } from "./validate"; import { validateDbConfig } from "./validate";
@@ -227,15 +226,7 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedDbTarget { resolve(target: RawTargetConfig, context: ResolveContext): ResolvedDbTarget {
const t = target as RawTargetConfig & { db: DbTargetConfig; type: "db" }; const t = target as RawTargetConfig & { db: DbTargetConfig; type: "db" };
const rawExpect = target.expect as RawDbExpectConfig | undefined; const resolvedExpect = target.expect as ResolvedDbExpectConfig | 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 { return {
db: { db: {
@@ -248,7 +239,6 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
id: t.id, id: t.id,
intervalMs: context.defaultIntervalMs, intervalMs: context.defaultIntervalMs,
name: t.name ?? null, name: t.name ?? null,
rawExpect,
timeoutMs: context.defaultTimeoutMs, timeoutMs: context.defaultTimeoutMs,
type: "db", type: "db",
} satisfies ResolvedDbTarget; } satisfies ResolvedDbTarget;

View File

@@ -3,13 +3,27 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types"; import type { CheckerSchemas } from "../types";
import { import {
createRawContentExpectationsSchema, createAuthoringContentExpectationsSchema,
createRawKeyedExpectationsSchema, createAuthoringKeyedExpectationsSchema,
createRawValueExpectationSchema, createAuthoringValueExpectationSchema,
createNormalizedContentExpectationsSchema,
createNormalizedKeyedExpectationsSchema,
createNormalizedValueExpectationSchema,
} from "../../schema/fragments"; } from "../../schema/fragments";
export const dbCheckerSchemas: CheckerSchemas = { export const dbCheckerSchemas: CheckerSchemas = {
config: Type.Object( authoring: {
config: createDbConfigSchema(),
expect: createDbExpectSchema("authoring"),
},
normalized: {
config: createDbConfigSchema(),
expect: createDbExpectSchema("normalized"),
},
};
function createDbConfigSchema() {
return Type.Object(
{ {
query: Type.Optional( query: Type.Optional(
Type.String({ Type.String({
@@ -19,14 +33,27 @@ export const dbCheckerSchemas: CheckerSchemas = {
url: Type.String({ minLength: 1 }), url: Type.String({ minLength: 1 }),
}, },
{ additionalProperties: false }, { additionalProperties: false },
), );
expect: Type.Object( }
function createDbExpectSchema(kind: "authoring" | "normalized") {
return Type.Object(
{ {
durationMs: Type.Optional(createRawValueExpectationSchema()), durationMs: Type.Optional(
result: Type.Optional(createRawContentExpectationsSchema()), kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
rowCount: Type.Optional(createRawValueExpectationSchema()), ),
rows: Type.Optional(Type.Array(createRawKeyedExpectationsSchema())), result: Type.Optional(
kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(),
),
rowCount: Type.Optional(
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
),
rows: Type.Optional(
Type.Array(
kind === "authoring" ? createAuthoringKeyedExpectationsSchema() : createNormalizedKeyedExpectationsSchema(),
),
),
}, },
{ additionalProperties: false }, { additionalProperties: false },
), );
}; }

View File

@@ -38,7 +38,6 @@ export interface ResolvedDbTarget extends ResolvedTargetBase {
group: string; group: string;
intervalMs: number; intervalMs: number;
name: null | string; name: null | string;
rawExpect?: RawDbExpectConfig;
timeoutMs: number; timeoutMs: number;
type: "db"; type: "db";
} }

View File

@@ -27,12 +27,7 @@ export function validateDbConfig(input: CheckerValidationInput): ConfigValidatio
function collectRowExpects(rows: unknown[], path: string, targetName?: string): ConfigValidationIssue[] { function collectRowExpects(rows: unknown[], path: string, targetName?: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
for (let i = 0; i < rows.length; i++) { for (let i = 0; i < rows.length; i++) {
const row = rows[i]!; issues.push(...validateRawKeyedExpectations(rows[i], `${path}[${i}]`, targetName));
if (!isPlainRecord(row)) {
issues.push(issue("invalid-type", `${path}[${i}]`, "必须为对象", targetName));
continue;
}
issues.push(...validateRawKeyedExpectations(row, `${path}[${i}]`, targetName));
} }
return issues; return issues;
} }

View File

@@ -2,14 +2,13 @@ import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types"; import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types"; import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { HttpTargetConfig, RawHttpExpectConfig, ResolvedHttpExpectConfig, ResolvedHttpTarget } from "./types"; import type { HttpTargetConfig, ResolvedHttpExpectConfig, ResolvedHttpTarget } from "./types";
import { checkContentExpectations, resolveContentExpectations } from "../../expect/content"; import { checkContentExpectations } from "../../expect/content";
import { errorFailure, mismatchFailure } from "../../expect/failure"; import { errorFailure, mismatchFailure } from "../../expect/failure";
import { checkHeaderExpectations } from "../../expect/headers"; import { checkHeaderExpectations } from "../../expect/headers";
import { resolveKeyedExpectations } from "../../expect/keyed";
import { checkStatusCode } from "../../expect/status"; import { checkStatusCode } from "../../expect/status";
import { checkValueExpectation, displayValueExpectation, resolveValueExpectation } from "../../expect/value"; import { checkValueExpectation, displayValueExpectation } from "../../expect/value";
import { parseSize } from "../../utils"; import { parseSize } from "../../utils";
import { httpCheckerSchemas } from "./schema"; import { httpCheckerSchemas } from "./schema";
import { validateHttpConfig } from "./validate"; import { validateHttpConfig } from "./validate";
@@ -179,13 +178,11 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
const method = t.http.method ?? "GET"; const method = t.http.method ?? "GET";
const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? "100MB"); const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? "100MB");
const rawExpect = target.expect as RawHttpExpectConfig | undefined; const expect = target.expect as ResolvedHttpExpectConfig | undefined;
const resolvedExpect: ResolvedHttpExpectConfig = rawExpect const resolvedExpect: ResolvedHttpExpectConfig = expect
? { ? {
body: resolveContentExpectations(rawExpect.body), ...expect,
durationMs: resolveValueExpectation(rawExpect.durationMs), status: expect.status ?? [200],
headers: resolveKeyedExpectations(rawExpect.headers),
status: rawExpect.status ?? [200],
} }
: { status: [200] }; : { status: [200] };
@@ -205,7 +202,6 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
id: t.id, id: t.id,
intervalMs: context.defaultIntervalMs, intervalMs: context.defaultIntervalMs,
name: t.name ?? null, name: t.name ?? null,
rawExpect,
timeoutMs: context.defaultTimeoutMs, timeoutMs: context.defaultTimeoutMs,
type: "http", type: "http",
} satisfies ResolvedHttpTarget; } satisfies ResolvedHttpTarget;

View File

@@ -3,9 +3,14 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types"; import type { CheckerSchemas } from "../types";
import { import {
createRawContentExpectationsSchema, createAuthoringContentExpectationsSchema,
createRawKeyedExpectationsSchema, createAuthoringFieldSchema,
createRawValueExpectationSchema, createAuthoringKeyedExpectationsSchema,
createAuthoringStringMapSchema,
createAuthoringValueExpectationSchema,
createNormalizedContentExpectationsSchema,
createNormalizedKeyedExpectationsSchema,
createNormalizedValueExpectationSchema,
httpMethodSchema, httpMethodSchema,
sizeSchema, sizeSchema,
statusCodePatternSchema, statusCodePatternSchema,
@@ -13,25 +18,47 @@ import {
} from "../../schema/fragments"; } from "../../schema/fragments";
export const httpCheckerSchemas: CheckerSchemas = { export const httpCheckerSchemas: CheckerSchemas = {
config: Type.Object( authoring: {
config: createHttpConfigSchema("authoring"),
expect: createHttpExpectSchema("authoring"),
},
normalized: {
config: createHttpConfigSchema("normalized"),
expect: createHttpExpectSchema("normalized"),
},
};
function createHttpConfigSchema(kind: "authoring" | "normalized") {
const bool = Type.Boolean();
const redirects = Type.Integer({ minimum: 0 });
return Type.Object(
{ {
body: Type.Optional(Type.String()), body: Type.Optional(Type.String()),
headers: Type.Optional(stringMapSchema), headers: Type.Optional(kind === "authoring" ? createAuthoringStringMapSchema() : stringMapSchema),
ignoreSSL: Type.Optional(Type.Boolean()), ignoreSSL: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(bool) : bool),
maxBodyBytes: Type.Optional(sizeSchema), maxBodyBytes: Type.Optional(sizeSchema),
maxRedirects: Type.Optional(Type.Integer({ minimum: 0 })), maxRedirects: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(redirects) : redirects),
method: Type.Optional(httpMethodSchema), method: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(httpMethodSchema) : httpMethodSchema),
url: Type.String({ minLength: 1 }), url: Type.String({ minLength: 1 }),
}, },
{ additionalProperties: false }, { additionalProperties: false },
), );
expect: Type.Object( }
function createHttpExpectSchema(kind: "authoring" | "normalized") {
return Type.Object(
{ {
body: Type.Optional(createRawContentExpectationsSchema()), body: Type.Optional(
durationMs: Type.Optional(createRawValueExpectationSchema()), kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(),
headers: Type.Optional(createRawKeyedExpectationsSchema()), ),
durationMs: Type.Optional(
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
),
headers: Type.Optional(
kind === "authoring" ? createAuthoringKeyedExpectationsSchema() : createNormalizedKeyedExpectationsSchema(),
),
status: Type.Optional(Type.Array(statusCodePatternSchema)), status: Type.Optional(Type.Array(statusCodePatternSchema)),
}, },
{ additionalProperties: false }, { additionalProperties: false },
), );
}; }

View File

@@ -48,7 +48,6 @@ export interface ResolvedHttpTarget extends ResolvedTargetBase {
http: ResolvedHttpConfig; http: ResolvedHttpConfig;
intervalMs: number; intervalMs: number;
name: null | string; name: null | string;
rawExpect?: RawHttpExpectConfig;
timeoutMs: number; timeoutMs: number;
type: "http"; type: "http";
} }

View File

@@ -43,7 +43,7 @@ function validateHttpExpect(target: Record<string, unknown>, path: string): Conf
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect"); const expectPath = joinPath(path, "expect");
if (isPlainRecord(expect["headers"])) { if (expect["headers"] !== undefined) {
issues.push( issues.push(
...validateRawKeyedExpectations(expect["headers"], joinPath(expectPath, "headers"), targetName, { ...validateRawKeyedExpectations(expect["headers"], joinPath(expectPath, "headers"), targetName, {
caseInsensitive: true, caseInsensitive: true,

View File

@@ -2,16 +2,10 @@ import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types"; import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types"; import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { import type { PingStats, PingTargetConfig, ResolvedIcmpExpectConfig, ResolvedPingTarget } from "./types";
PingStats,
PingTargetConfig,
RawIcmpExpectConfig,
ResolvedIcmpExpectConfig,
ResolvedPingTarget,
} from "./types";
import { errorFailure } from "../../expect/failure"; import { errorFailure } from "../../expect/failure";
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value"; import { checkValueExpectation } from "../../expect/value";
import { buildPingCommand } from "./command"; import { buildPingCommand } from "./command";
import { checkAlive, checkAvgLatency, checkMaxLatency, checkPacketLoss } from "./expect"; import { checkAlive, checkAvgLatency, checkMaxLatency, checkPacketLoss } from "./expect";
import { parsePingOutput } from "./parse"; import { parsePingOutput } from "./parse";
@@ -162,14 +156,11 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedPingTarget { resolve(target: RawTargetConfig, context: ResolveContext): ResolvedPingTarget {
const t = target as RawTargetConfig & { icmp: PingTargetConfig; type: "icmp" }; const t = target as RawTargetConfig & { icmp: PingTargetConfig; type: "icmp" };
const rawExpect = target.expect as RawIcmpExpectConfig | undefined; const expect = target.expect as ResolvedIcmpExpectConfig | undefined;
const resolvedExpect: ResolvedIcmpExpectConfig = rawExpect const resolvedExpect: ResolvedIcmpExpectConfig = expect
? { ? {
alive: rawExpect.alive ?? true, ...expect,
avgLatencyMs: resolveValueExpectation(rawExpect.avgLatencyMs), alive: expect.alive ?? true,
durationMs: resolveValueExpectation(rawExpect.durationMs),
maxLatencyMs: resolveValueExpectation(rawExpect.maxLatencyMs),
packetLossPercent: resolveValueExpectation(rawExpect.packetLossPercent),
} }
: { alive: true }; : { alive: true };
@@ -185,7 +176,6 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
id: t.id, id: t.id,
intervalMs: context.defaultIntervalMs, intervalMs: context.defaultIntervalMs,
name: t.name ?? null, name: t.name ?? null,
rawExpect,
timeoutMs: context.defaultTimeoutMs, timeoutMs: context.defaultTimeoutMs,
type: "icmp", type: "icmp",
} satisfies ResolvedPingTarget; } satisfies ResolvedPingTarget;

View File

@@ -2,25 +2,54 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types"; import type { CheckerSchemas } from "../types";
import { createRawValueExpectationSchema } from "../../schema/fragments"; import {
createAuthoringFieldSchema,
createAuthoringValueExpectationSchema,
createNormalizedValueExpectationSchema,
} from "../../schema/fragments";
export const icmpCheckerSchemas: CheckerSchemas = { export const icmpCheckerSchemas: CheckerSchemas = {
config: Type.Object( authoring: {
{ config: createIcmpConfigSchema("authoring"),
count: Type.Optional(Type.Integer({ maximum: 100, minimum: 1 })), expect: createIcmpExpectSchema("authoring"),
host: Type.String({ minLength: 1 }), },
packetSize: Type.Optional(Type.Integer({ maximum: 65500, minimum: 1 })), normalized: {
}, config: createIcmpConfigSchema("normalized"),
{ additionalProperties: false }, expect: createIcmpExpectSchema("normalized"),
), },
expect: Type.Object(
{
alive: Type.Optional(Type.Boolean()),
avgLatencyMs: Type.Optional(createRawValueExpectationSchema()),
durationMs: Type.Optional(createRawValueExpectationSchema()),
maxLatencyMs: Type.Optional(createRawValueExpectationSchema()),
packetLossPercent: Type.Optional(createRawValueExpectationSchema()),
},
{ additionalProperties: false },
),
}; };
function createIcmpConfigSchema(kind: "authoring" | "normalized") {
const count = Type.Integer({ maximum: 100, minimum: 1 });
const packetSize = Type.Integer({ maximum: 65500, minimum: 1 });
return Type.Object(
{
count: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(count) : count),
host: Type.String({ minLength: 1 }),
packetSize: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(packetSize) : packetSize),
},
{ additionalProperties: false },
);
}
function createIcmpExpectSchema(kind: "authoring" | "normalized") {
const bool = Type.Boolean();
return Type.Object(
{
alive: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(bool) : bool),
avgLatencyMs: Type.Optional(
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
),
durationMs: Type.Optional(
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
),
maxLatencyMs: Type.Optional(
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
),
packetLossPercent: Type.Optional(
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
),
},
{ additionalProperties: false },
);
}

View File

@@ -45,7 +45,6 @@ export interface ResolvedPingTarget extends ResolvedTargetBase {
icmp: ResolvedPingConfig; icmp: ResolvedPingConfig;
intervalMs: number; intervalMs: number;
name: null | string; name: null | string;
rawExpect?: RawIcmpExpectConfig;
timeoutMs: number; timeoutMs: number;
type: "icmp"; type: "icmp";
} }

View File

@@ -3,12 +3,10 @@ import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types"; import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types"; import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { LlmTargetConfig, RawLlmExpectConfig, ResolvedLlmExpectConfig, ResolvedLlmTarget } from "./types"; import type { LlmTargetConfig, ResolvedLlmExpectConfig, ResolvedLlmTarget } from "./types";
import { resolveContentExpectations } from "../../expect/content";
import { errorFailure } from "../../expect/failure"; import { errorFailure } from "../../expect/failure";
import { resolveKeyedExpectations } from "../../expect/keyed"; import { checkValueExpectation } from "../../expect/value";
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value";
import { runExpects } from "./expect"; import { runExpects } from "./expect";
import { import {
buildObservationFromApiCallError, buildObservationFromApiCallError,
@@ -155,26 +153,15 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
url: t.llm.url, url: t.llm.url,
}; };
const rawExpect = target.expect as RawLlmExpectConfig | undefined; const expect = target.expect as ResolvedLlmExpectConfig | undefined;
const resolvedExpect: ResolvedLlmExpectConfig = rawExpect const resolvedExpect: ResolvedLlmExpectConfig = expect
? { ? {
durationMs: resolveValueExpectation(rawExpect.durationMs), ...expect,
finishReason: resolveValueExpectation(rawExpect.finishReason), status: expect.status ?? [200],
headers: resolveKeyedExpectations(rawExpect.headers), stream: expect.stream
output: resolveContentExpectations(rawExpect.output),
rawFinishReason: resolveValueExpectation(rawExpect.rawFinishReason),
status: rawExpect.status ?? [200],
stream: rawExpect.stream
? { ? {
completed: rawExpect.stream.completed ?? true, ...expect.stream,
firstTokenMs: resolveValueExpectation(rawExpect.stream.firstTokenMs), completed: expect.stream.completed ?? true,
}
: undefined,
usage: rawExpect.usage
? {
inputTokens: resolveValueExpectation(rawExpect.usage.inputTokens),
outputTokens: resolveValueExpectation(rawExpect.usage.outputTokens),
totalTokens: resolveValueExpectation(rawExpect.usage.totalTokens),
} }
: undefined, : undefined,
} }
@@ -188,7 +175,6 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
intervalMs: context.defaultIntervalMs, intervalMs: context.defaultIntervalMs,
llm: resolvedConfig, llm: resolvedConfig,
name: (target.name as null | string) ?? null, name: (target.name as null | string) ?? null,
rawExpect,
timeoutMs: context.defaultTimeoutMs, timeoutMs: context.defaultTimeoutMs,
type: "llm", type: "llm",
} satisfies ResolvedLlmTarget; } satisfies ResolvedLlmTarget;

View File

@@ -3,18 +3,26 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types"; import type { CheckerSchemas } from "../types";
import { import {
createRawContentExpectationsSchema, createAuthoringContentExpectationsSchema,
createRawKeyedExpectationsSchema, createAuthoringFieldSchema,
createRawValueExpectationSchema, createAuthoringKeyedExpectationsSchema,
createAuthoringStringMapSchema,
createAuthoringValueExpectationSchema,
createNormalizedContentExpectationsSchema,
createNormalizedKeyedExpectationsSchema,
createNormalizedValueExpectationSchema,
statusCodePatternSchema, statusCodePatternSchema,
stringMapSchema, stringMapSchema,
} from "../../schema/fragments"; } from "../../schema/fragments";
function createLlmOptionsSchema() { function createLlmOptionsSchema(kind: "authoring" | "normalized") {
const maxOutputTokens = Type.Integer({ minimum: 1 });
return Type.Object( return Type.Object(
{ {
frequencyPenalty: Type.Optional(Type.Number()), frequencyPenalty: Type.Optional(Type.Number()),
maxOutputTokens: Type.Optional(Type.Integer({ minimum: 1 })), maxOutputTokens: Type.Optional(
kind === "authoring" ? createAuthoringFieldSchema(maxOutputTokens) : maxOutputTokens,
),
presencePenalty: Type.Optional(Type.Number()), presencePenalty: Type.Optional(Type.Number()),
seed: Type.Optional(Type.Number()), seed: Type.Optional(Type.Number()),
stopSequences: Type.Optional(Type.Array(Type.String())), stopSequences: Type.Optional(Type.Array(Type.String())),
@@ -27,35 +35,59 @@ function createLlmOptionsSchema() {
} }
export const llmCheckerSchemas: CheckerSchemas = { export const llmCheckerSchemas: CheckerSchemas = {
config: Type.Object( authoring: {
config: createLlmConfigSchema("authoring"),
expect: createLlmExpectSchema("authoring"),
},
normalized: {
config: createLlmConfigSchema("normalized"),
expect: createLlmExpectSchema("normalized"),
},
};
function createLlmConfigSchema(kind: "authoring" | "normalized") {
const bool = Type.Boolean();
const mode = createLlmModeSchema();
const provider = createLlmProviderSchema();
return Type.Object(
{ {
authToken: Type.Optional(Type.String()), authToken: Type.Optional(Type.String()),
headers: Type.Optional(stringMapSchema), headers: Type.Optional(kind === "authoring" ? createAuthoringStringMapSchema() : stringMapSchema),
ignoreSSL: Type.Optional(Type.Boolean()), ignoreSSL: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(bool) : bool),
key: Type.Optional(Type.String()), key: Type.Optional(Type.String()),
mode: Type.Optional(Type.Union([Type.Literal("http"), Type.Literal("stream")])), mode: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(mode) : mode),
model: Type.String({ minLength: 1 }), model: Type.String({ minLength: 1 }),
options: Type.Optional(createLlmOptionsSchema()), options: Type.Optional(createLlmOptionsSchema(kind)),
prompt: Type.String({ minLength: 1 }), prompt: Type.String({ minLength: 1 }),
provider: Type.Union([Type.Literal("openai"), Type.Literal("openai-responses"), Type.Literal("anthropic")]), provider: kind === "authoring" ? createAuthoringFieldSchema(provider) : provider,
providerOptions: Type.Optional(Type.Record(Type.String(), Type.Object({}, { additionalProperties: true }))), providerOptions: Type.Optional(Type.Record(Type.String(), Type.Object({}, { additionalProperties: true }))),
url: Type.String({ minLength: 1 }), url: Type.String({ minLength: 1 }),
}, },
{ additionalProperties: false }, { additionalProperties: false },
), );
expect: Type.Object( }
function createLlmExpectSchema(kind: "authoring" | "normalized") {
const bool = Type.Boolean();
const valueExpectation =
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema();
return Type.Object(
{ {
durationMs: Type.Optional(createRawValueExpectationSchema()), durationMs: Type.Optional(valueExpectation),
finishReason: Type.Optional(createRawValueExpectationSchema()), finishReason: Type.Optional(valueExpectation),
headers: Type.Optional(createRawKeyedExpectationsSchema()), headers: Type.Optional(
output: Type.Optional(createRawContentExpectationsSchema()), kind === "authoring" ? createAuthoringKeyedExpectationsSchema() : createNormalizedKeyedExpectationsSchema(),
rawFinishReason: Type.Optional(createRawValueExpectationSchema()), ),
output: Type.Optional(
kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(),
),
rawFinishReason: Type.Optional(valueExpectation),
status: Type.Optional(Type.Array(statusCodePatternSchema)), status: Type.Optional(Type.Array(statusCodePatternSchema)),
stream: Type.Optional( stream: Type.Optional(
Type.Object( Type.Object(
{ {
completed: Type.Optional(Type.Boolean()), completed: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(bool) : bool),
firstTokenMs: Type.Optional(createRawValueExpectationSchema()), firstTokenMs: Type.Optional(valueExpectation),
}, },
{ additionalProperties: false }, { additionalProperties: false },
), ),
@@ -63,14 +95,22 @@ export const llmCheckerSchemas: CheckerSchemas = {
usage: Type.Optional( usage: Type.Optional(
Type.Object( Type.Object(
{ {
inputTokens: Type.Optional(createRawValueExpectationSchema()), inputTokens: Type.Optional(valueExpectation),
outputTokens: Type.Optional(createRawValueExpectationSchema()), outputTokens: Type.Optional(valueExpectation),
totalTokens: Type.Optional(createRawValueExpectationSchema()), totalTokens: Type.Optional(valueExpectation),
}, },
{ additionalProperties: false }, { additionalProperties: false },
), ),
), ),
}, },
{ additionalProperties: false }, { additionalProperties: false },
), );
}; }
function createLlmModeSchema() {
return Type.Union([Type.Literal("http"), Type.Literal("stream")]);
}
function createLlmProviderSchema() {
return Type.Union([Type.Literal("openai"), Type.Literal("openai-responses"), Type.Literal("anthropic")]);
}

View File

@@ -152,7 +152,6 @@ export interface ResolvedLlmTarget extends ResolvedTargetBase {
intervalMs: number; intervalMs: number;
llm: ResolvedLlmConfig; llm: ResolvedLlmConfig;
name: null | string; name: null | string;
rawExpect?: RawLlmExpectConfig;
timeoutMs: number; timeoutMs: number;
type: "llm"; type: "llm";
} }

View File

@@ -2,11 +2,10 @@ import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types"; import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types"; import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { RawTcpExpectConfig, ResolvedTcpExpectConfig, ResolvedTcpTarget, TcpTargetConfig } from "./types"; import type { ResolvedTcpExpectConfig, ResolvedTcpTarget, TcpTargetConfig } from "./types";
import { resolveContentExpectations } from "../../expect/content";
import { errorFailure } from "../../expect/failure"; import { errorFailure } from "../../expect/failure";
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value"; import { checkValueExpectation } from "../../expect/value";
import { parseSize } from "../../utils"; import { parseSize } from "../../utils";
import { checkBanner, checkConnected } from "./expect"; import { checkBanner, checkConnected } from "./expect";
import { tcpCheckerSchemas } from "./schema"; import { tcpCheckerSchemas } from "./schema";
@@ -210,12 +209,11 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
const maxBannerBytes = parseSize(t.tcp.maxBannerBytes ?? DEFAULT_MAX_BANNER_BYTES); const maxBannerBytes = parseSize(t.tcp.maxBannerBytes ?? DEFAULT_MAX_BANNER_BYTES);
const bannerReadTimeout = t.tcp.bannerReadTimeout ?? DEFAULT_BANNER_READ_TIMEOUT; const bannerReadTimeout = t.tcp.bannerReadTimeout ?? DEFAULT_BANNER_READ_TIMEOUT;
const rawExpect = target.expect as RawTcpExpectConfig | undefined; const expect = target.expect as ResolvedTcpExpectConfig | undefined;
const resolvedExpect: ResolvedTcpExpectConfig = rawExpect const resolvedExpect: ResolvedTcpExpectConfig = expect
? { ? {
banner: resolveContentExpectations(rawExpect.banner), ...expect,
connected: rawExpect.connected ?? true, connected: expect.connected ?? true,
durationMs: resolveValueExpectation(rawExpect.durationMs),
} }
: { connected: true }; : { connected: true };
@@ -226,7 +224,6 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
id: t.id, id: t.id,
intervalMs: context.defaultIntervalMs, intervalMs: context.defaultIntervalMs,
name: t.name ?? null, name: t.name ?? null,
rawExpect,
tcp: { tcp: {
bannerReadTimeout, bannerReadTimeout,
host: t.tcp.host, host: t.tcp.host,

View File

@@ -3,28 +3,52 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types"; import type { CheckerSchemas } from "../types";
import { import {
createRawContentExpectationsSchema, createAuthoringContentExpectationsSchema,
createRawValueExpectationSchema, createAuthoringFieldSchema,
createAuthoringValueExpectationSchema,
createNormalizedContentExpectationsSchema,
createNormalizedValueExpectationSchema,
sizeSchema, sizeSchema,
} from "../../schema/fragments"; } from "../../schema/fragments";
export const tcpCheckerSchemas: CheckerSchemas = { export const tcpCheckerSchemas: CheckerSchemas = {
config: Type.Object( authoring: {
config: createTcpConfigSchema("authoring"),
expect: createTcpExpectSchema("authoring"),
},
normalized: {
config: createTcpConfigSchema("normalized"),
expect: createTcpExpectSchema("normalized"),
},
};
function createTcpConfigSchema(kind: "authoring" | "normalized") {
const port = Type.Integer({ maximum: 65535, minimum: 1 });
const readBanner = Type.Boolean();
return Type.Object(
{ {
bannerReadTimeout: Type.Optional(Type.Number({ minimum: 0 })), bannerReadTimeout: Type.Optional(Type.Number({ minimum: 0 })),
host: Type.String({ minLength: 1 }), host: Type.String({ minLength: 1 }),
maxBannerBytes: Type.Optional(sizeSchema), maxBannerBytes: Type.Optional(sizeSchema),
port: Type.Integer({ maximum: 65535, minimum: 1 }), port: kind === "authoring" ? createAuthoringFieldSchema(port) : port,
readBanner: Type.Optional(Type.Boolean()), readBanner: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(readBanner) : readBanner),
}, },
{ additionalProperties: false }, { additionalProperties: false },
), );
expect: Type.Object( }
function createTcpExpectSchema(kind: "authoring" | "normalized") {
const connected = Type.Boolean();
return Type.Object(
{ {
banner: Type.Optional(createRawContentExpectationsSchema()), banner: Type.Optional(
connected: Type.Optional(Type.Boolean()), kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(),
durationMs: Type.Optional(createRawValueExpectationSchema()), ),
connected: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(connected) : connected),
durationMs: Type.Optional(
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
),
}, },
{ additionalProperties: false }, { additionalProperties: false },
), );
}; }

View File

@@ -31,7 +31,6 @@ export interface ResolvedTcpTarget extends ResolvedTargetBase {
group: string; group: string;
intervalMs: number; intervalMs: number;
name: null | string; name: null | string;
rawExpect?: RawTcpExpectConfig;
tcp: ResolvedTcpConfig; tcp: ResolvedTcpConfig;
timeoutMs: number; timeoutMs: number;
type: "tcp"; type: "tcp";

View File

@@ -20,11 +20,16 @@ export interface CheckerDefinition<TResolved extends ResolvedTargetBase = Resolv
validate(input: CheckerValidationInput): ConfigValidationIssue[]; validate(input: CheckerValidationInput): ConfigValidationIssue[];
} }
export interface CheckerSchemas { export interface CheckerSchemaPair {
config: TSchema; config: TSchema;
expect: TSchema; expect: TSchema;
} }
export interface CheckerSchemas {
authoring: CheckerSchemaPair;
normalized: CheckerSchemaPair;
}
export interface CheckerValidationInput { export interface CheckerValidationInput {
targets: RawTargetConfig[]; targets: RawTargetConfig[];
} }

View File

@@ -2,11 +2,10 @@ import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types"; import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types"; import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { RawUdpExpectConfig, ResolvedUdpExpectConfig, ResolvedUdpTarget, UdpTargetConfig } from "./types"; import type { ResolvedUdpExpectConfig, ResolvedUdpTarget, UdpTargetConfig } from "./types";
import { resolveContentExpectations } from "../../expect/content";
import { errorFailure } from "../../expect/failure"; import { errorFailure } from "../../expect/failure";
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value"; import { checkValueExpectation } from "../../expect/value";
import { parseSize } from "../../utils"; import { parseSize } from "../../utils";
import { decodePayload, encodeResponse } from "./encoding"; import { decodePayload, encodeResponse } from "./encoding";
import { checkResponded, checkResponseSize, checkResponseText, checkSourceHost, checkSourcePort } from "./expect"; import { checkResponded, checkResponseSize, checkResponseText, checkSourceHost, checkSourcePort } from "./expect";
@@ -303,15 +302,11 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
const responseEncoding = t.udp.responseEncoding ?? "text"; const responseEncoding = t.udp.responseEncoding ?? "text";
const maxResponseBytes = parseSize(t.udp.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES); const maxResponseBytes = parseSize(t.udp.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES);
const rawExpect = target.expect as RawUdpExpectConfig | undefined; const expect = target.expect as ResolvedUdpExpectConfig | undefined;
const resolvedExpect: ResolvedUdpExpectConfig = rawExpect const resolvedExpect: ResolvedUdpExpectConfig = expect
? { ? {
durationMs: resolveValueExpectation(rawExpect.durationMs), ...expect,
responded: rawExpect.responded ?? true, responded: expect.responded ?? true,
response: resolveContentExpectations(rawExpect.response),
responseSize: resolveValueExpectation(rawExpect.responseSize),
sourceHost: resolveValueExpectation(rawExpect.sourceHost),
sourcePort: resolveValueExpectation(rawExpect.sourcePort),
} }
: { responded: true }; : { responded: true };
@@ -322,7 +317,6 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
id: t.id, id: t.id,
intervalMs: context.defaultIntervalMs, intervalMs: context.defaultIntervalMs,
name: t.name ?? null, name: t.name ?? null,
rawExpect,
timeoutMs: context.defaultTimeoutMs, timeoutMs: context.defaultTimeoutMs,
type: "udp", type: "udp",
udp: { udp: {

View File

@@ -3,32 +3,69 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types"; import type { CheckerSchemas } from "../types";
import { import {
createRawContentExpectationsSchema, createAuthoringContentExpectationsSchema,
createRawValueExpectationSchema, createAuthoringFieldSchema,
createAuthoringValueExpectationSchema,
createNormalizedContentExpectationsSchema,
createNormalizedValueExpectationSchema,
sizeSchema, sizeSchema,
} from "../../schema/fragments"; } from "../../schema/fragments";
export const udpCheckerSchemas: CheckerSchemas = { export const udpCheckerSchemas: CheckerSchemas = {
config: Type.Object( authoring: {
config: createUdpConfigSchema("authoring"),
expect: createUdpExpectSchema("authoring"),
},
normalized: {
config: createUdpConfigSchema("normalized"),
expect: createUdpExpectSchema("normalized"),
},
};
function createEncodingSchema() {
return Type.Union([Type.Literal("text"), Type.Literal("hex"), Type.Literal("base64")]);
}
function createUdpConfigSchema(kind: "authoring" | "normalized") {
const port = Type.Integer({ maximum: 65535, minimum: 1 });
const encoding = createEncodingSchema();
const responseEncoding = createEncodingSchema();
return Type.Object(
{ {
encoding: Type.Optional(Type.Union([Type.Literal("text"), Type.Literal("hex"), Type.Literal("base64")])), encoding: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(encoding) : encoding),
host: Type.String({ minLength: 1 }), host: Type.String({ minLength: 1 }),
maxResponseBytes: Type.Optional(sizeSchema), maxResponseBytes: Type.Optional(sizeSchema),
payload: Type.Optional(Type.String()), payload: Type.Optional(Type.String()),
port: Type.Integer({ maximum: 65535, minimum: 1 }), port: kind === "authoring" ? createAuthoringFieldSchema(port) : port,
responseEncoding: Type.Optional(Type.Union([Type.Literal("text"), Type.Literal("hex"), Type.Literal("base64")])), responseEncoding: Type.Optional(
kind === "authoring" ? createAuthoringFieldSchema(responseEncoding) : responseEncoding,
),
}, },
{ additionalProperties: false }, { additionalProperties: false },
), );
expect: Type.Object( }
function createUdpExpectSchema(kind: "authoring" | "normalized") {
const responded = Type.Boolean();
return Type.Object(
{ {
durationMs: Type.Optional(createRawValueExpectationSchema()), durationMs: Type.Optional(
responded: Type.Optional(Type.Boolean()), kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
response: Type.Optional(createRawContentExpectationsSchema()), ),
responseSize: Type.Optional(createRawValueExpectationSchema()), responded: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(responded) : responded),
sourceHost: Type.Optional(createRawValueExpectationSchema()), response: Type.Optional(
sourcePort: Type.Optional(createRawValueExpectationSchema()), kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(),
),
responseSize: Type.Optional(
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
),
sourceHost: Type.Optional(
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
),
sourcePort: Type.Optional(
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
),
}, },
{ additionalProperties: false }, { additionalProperties: false },
), );
}; }

View File

@@ -38,7 +38,6 @@ export interface ResolvedUdpTarget extends ResolvedTargetBase {
group: string; group: string;
intervalMs: number; intervalMs: number;
name: null | string; name: null | string;
rawExpect?: RawUdpExpectConfig;
timeoutMs: number; timeoutMs: number;
type: "udp"; type: "udp";
udp: ResolvedUdpConfig; udp: ResolvedUdpConfig;

View File

@@ -5,9 +5,10 @@ import { Type } from "@sinclair/typebox";
import type { CheckerDefinition } from "../runner/types"; import type { CheckerDefinition } from "../runner/types";
import { import {
createRawContentExpectationsSchema, createAuthoringContentExpectationsSchema,
createRawKeyedExpectationsSchema, createAuthoringFieldSchema,
createRawValueExpectationSchema, createAuthoringKeyedExpectationsSchema,
createAuthoringValueExpectationSchema,
createValueMatcherObjectSchema, createValueMatcherObjectSchema,
durationSchema, durationSchema,
sizeSchema, sizeSchema,
@@ -16,62 +17,58 @@ import {
const LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "fatal"] as const; const LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "fatal"] as const;
const ROTATION_FREQUENCIES = ["hourly", "daily", "weekly"] as const; const ROTATION_FREQUENCIES = ["hourly", "daily", "weekly"] as const;
type SchemaKind = "authoring" | "normalized";
export function createAuthoringProbeConfigSchema(checkers: CheckerDefinition[], external = false): TSchema {
return createProbeConfigSchemaForKind(checkers, "authoring", external);
}
export function createAuthoringTargetSchema(checker: CheckerDefinition): TSchema {
return createTargetSchemaForKind(checker, "authoring");
}
export function createExternalProbeConfigSchema(checkers: CheckerDefinition[]): Record<string, unknown> { export function createExternalProbeConfigSchema(checkers: CheckerDefinition[]): Record<string, unknown> {
return { return {
...cloneSchema(createProbeConfigSchema(checkers, true)), ...cloneSchema(createAuthoringProbeConfigSchema(checkers, true)),
$id: "https://dial.local/probe-config.schema.json", $id: "https://dial.local/probe-config.schema.json",
$schema: "http://json-schema.org/draft-07/schema#", $schema: "http://json-schema.org/draft-07/schema#",
definitions: { definitions: {
ContentExpectations: cloneSchema(createRawContentExpectationsSchema()), ContentExpectations: cloneSchema(createAuthoringContentExpectationsSchema()),
KeyedExpectations: cloneSchema(createRawKeyedExpectationsSchema()), KeyedExpectations: cloneSchema(createAuthoringKeyedExpectationsSchema()),
ValueExpectation: cloneSchema(createRawValueExpectationSchema()), ValueExpectation: cloneSchema(createAuthoringValueExpectationSchema()),
ValueMatcher: cloneSchema(createValueMatcherObjectSchema()), ValueMatcher: cloneSchema(createValueMatcherObjectSchema()),
}, },
}; };
} }
export function createNormalizedProbeConfigSchema(checkers: CheckerDefinition[], external = false): TSchema {
return createProbeConfigSchemaForKind(checkers, "normalized", external);
}
export function createNormalizedTargetSchema(checker: CheckerDefinition): TSchema {
return createTargetSchemaForKind(checker, "normalized");
}
export function createProbeConfigSchema(checkers: CheckerDefinition[], external = false): TSchema { export function createProbeConfigSchema(checkers: CheckerDefinition[], external = false): TSchema {
return Type.Object( return createNormalizedProbeConfigSchema(checkers, external);
{
probes: Type.Optional(createProbesSchema()),
server: Type.Optional(createServerSchema()),
targets: Type.Array(external ? createExternalTargetSchema(checkers) : createBaseTargetSchema(checkers), {
minItems: 1,
}),
variables: Type.Optional(Type.Record(Type.String({ pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$" }), variableValueSchema)),
},
{ additionalProperties: false },
);
} }
export function createTargetSchema(checker: CheckerDefinition): TSchema { export function createTargetSchema(checker: CheckerDefinition): TSchema {
const properties: Record<string, TSchema> = { return createNormalizedTargetSchema(checker);
description: Type.Optional(Type.Union([Type.Null(), Type.String({ maxLength: 500 })])),
expect: Type.Optional(checker.schemas.expect),
group: Type.Optional(Type.String()),
id: Type.String({ maxLength: 30, minLength: 1 }),
interval: Type.Optional(durationSchema),
name: Type.Optional(Type.Union([Type.Null(), Type.String({ maxLength: 30, minLength: 1 })])),
timeout: Type.Optional(durationSchema),
type: Type.Literal(checker.type),
};
properties[checker.configKey] = checker.schemas.config;
return Type.Object(properties, { additionalProperties: false });
} }
function cloneSchema(schema: TSchema): Record<string, unknown> { function cloneSchema(schema: TSchema): Record<string, unknown> {
return JSON.parse(JSON.stringify(schema)) as Record<string, unknown>; return JSON.parse(JSON.stringify(schema)) as Record<string, unknown>;
} }
function createBaseTargetSchema(checkers: CheckerDefinition[]): TSchema { function createBaseTargetSchema(checkers: CheckerDefinition[], kind: SchemaKind): TSchema {
return Type.Object( return Type.Object(
{ {
description: Type.Optional(Type.Union([Type.Null(), Type.String({ maxLength: 500 })])), description: Type.Optional(Type.Union([Type.Null(), stringForKind(kind, { maxLength: 500 })])),
group: Type.Optional(Type.String()), group: Type.Optional(Type.String()),
id: Type.String({ maxLength: 30, minLength: 1 }), id: Type.String({ maxLength: 30, minLength: 1 }),
interval: Type.Optional(durationSchema), interval: Type.Optional(durationSchema),
name: Type.Optional(Type.Union([Type.Null(), Type.String({ maxLength: 30, minLength: 1 })])), name: Type.Optional(Type.Union([Type.Null(), stringForKind(kind, { maxLength: 30, minLength: 1 })])),
timeout: Type.Optional(durationSchema), timeout: Type.Optional(durationSchema),
type: Type.Union(checkers.map((checker) => Type.Literal(checker.type)) as unknown as [TSchema, ...TSchema[]]), type: Type.Union(checkers.map((checker) => Type.Literal(checker.type)) as unknown as [TSchema, ...TSchema[]]),
}, },
@@ -79,27 +76,30 @@ function createBaseTargetSchema(checkers: CheckerDefinition[]): TSchema {
); );
} }
function createExternalTargetSchema(checkers: CheckerDefinition[]): TSchema { function createExternalTargetSchema(checkers: CheckerDefinition[], kind: SchemaKind): TSchema {
return Type.Union(checkers.map((checker) => createTargetSchema(checker)) as [TSchema, ...TSchema[]]); return Type.Union(checkers.map((checker) => createTargetSchemaForKind(checker, kind)) as [TSchema, ...TSchema[]]);
} }
function createLoggingSchema(): TSchema { function createLoggingSchema(kind: SchemaKind): TSchema {
const logLevelSchema = Type.Union(LOG_LEVELS.map((l) => Type.Literal(l)) as unknown as [TSchema, ...TSchema[]]); const logLevelSchema = Type.Union(LOG_LEVELS.map((l) => Type.Literal(l)) as unknown as [TSchema, ...TSchema[]]);
const logLevel = enumForKind(kind, logLevelSchema);
const frequency = enumForKind(
kind,
Type.Union(ROTATION_FREQUENCIES.map((f) => Type.Literal(f)) as unknown as [TSchema, ...TSchema[]]),
);
return Type.Object( return Type.Object(
{ {
console: Type.Optional(Type.Object({ level: Type.Optional(logLevelSchema) }, { additionalProperties: false })), console: Type.Optional(Type.Object({ level: Type.Optional(logLevel) }, { additionalProperties: false })),
file: Type.Optional( file: Type.Optional(
Type.Object( Type.Object(
{ {
level: Type.Optional(logLevelSchema), level: Type.Optional(logLevel),
path: Type.Optional(Type.String({ minLength: 1 })), path: Type.Optional(Type.String({ minLength: 1 })),
rotation: Type.Optional( rotation: Type.Optional(
Type.Object( Type.Object(
{ {
frequency: Type.Optional( frequency: Type.Optional(frequency),
Type.Union(ROTATION_FREQUENCIES.map((f) => Type.Literal(f)) as unknown as [TSchema, ...TSchema[]]), maxFiles: Type.Optional(integerForKind(kind, { minimum: 1 })),
),
maxFiles: Type.Optional(Type.Integer({ minimum: 1 })),
size: Type.Optional(sizeSchema), size: Type.Optional(sizeSchema),
}, },
{ additionalProperties: false }, { additionalProperties: false },
@@ -109,19 +109,38 @@ function createLoggingSchema(): TSchema {
{ additionalProperties: false }, { additionalProperties: false },
), ),
), ),
level: Type.Optional(logLevelSchema), level: Type.Optional(logLevel),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
} }
function createProbesSchema(): TSchema { function createProbeConfigSchemaForKind(checkers: CheckerDefinition[], kind: SchemaKind, external: boolean): TSchema {
const properties: Record<string, TSchema> = {
probes: Type.Optional(createProbesSchema(kind)),
server: Type.Optional(createServerSchema(kind)),
targets: Type.Array(
external ? createExternalTargetSchema(checkers, kind) : createBaseTargetSchema(checkers, kind),
{
minItems: 1,
},
),
};
if (kind === "authoring") {
properties["variables"] = Type.Optional(
Type.Record(Type.String({ pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$" }), variableValueSchema),
);
}
return Type.Object(properties, { additionalProperties: false });
}
function createProbesSchema(kind: SchemaKind): TSchema {
return Type.Object( return Type.Object(
{ {
execution: Type.Optional( execution: Type.Optional(
Type.Object( Type.Object(
{ {
maxConcurrentChecks: Type.Optional(Type.Integer({ minimum: 1 })), maxConcurrentChecks: Type.Optional(integerForKind(kind, { minimum: 1 })),
}, },
{ additionalProperties: false }, { additionalProperties: false },
), ),
@@ -131,19 +150,19 @@ function createProbesSchema(): TSchema {
); );
} }
function createServerSchema(): TSchema { function createServerSchema(kind: SchemaKind): TSchema {
return Type.Object( return Type.Object(
{ {
listen: Type.Optional( listen: Type.Optional(
Type.Object( Type.Object(
{ {
host: Type.Optional(Type.String()), host: Type.Optional(Type.String()),
port: Type.Optional(Type.Integer({ maximum: 65535, minimum: 0 })), port: Type.Optional(integerForKind(kind, { maximum: 65535, minimum: 0 })),
}, },
{ additionalProperties: false }, { additionalProperties: false },
), ),
), ),
logging: Type.Optional(createLoggingSchema()), logging: Type.Optional(createLoggingSchema(kind)),
storage: Type.Optional( storage: Type.Optional(
Type.Object( Type.Object(
{ {
@@ -157,3 +176,32 @@ function createServerSchema(): TSchema {
{ additionalProperties: false }, { additionalProperties: false },
); );
} }
function createTargetSchemaForKind(checker: CheckerDefinition, kind: SchemaKind): TSchema {
const properties: Record<string, TSchema> = {
description: Type.Optional(Type.Union([Type.Null(), stringForKind(kind, { maxLength: 500 })])),
expect: Type.Optional(checker.schemas[kind].expect),
group: Type.Optional(Type.String()),
id: Type.String({ maxLength: 30, minLength: 1 }),
interval: Type.Optional(durationSchema),
name: Type.Optional(Type.Union([Type.Null(), stringForKind(kind, { maxLength: 30, minLength: 1 })])),
timeout: Type.Optional(durationSchema),
type: Type.Literal(checker.type),
};
properties[checker.configKey] = checker.schemas[kind].config;
return Type.Object(properties, { additionalProperties: false });
}
function enumForKind(kind: SchemaKind, schema: TSchema): TSchema {
return kind === "authoring" ? createAuthoringFieldSchema(schema) : schema;
}
function integerForKind(kind: SchemaKind, options?: Parameters<typeof Type.Integer>[0]): TSchema {
const schema = Type.Integer(options);
return kind === "authoring" ? createAuthoringFieldSchema(schema) : schema;
}
function stringForKind(kind: SchemaKind, options?: Parameters<typeof Type.String>[0]): TSchema {
const schema = Type.String(options);
return kind === "authoring" ? createAuthoringFieldSchema(schema) : schema;
}

View File

@@ -31,6 +31,8 @@ export const sizeSchema = Type.Union([Type.String(), Type.Integer({ minimum: 0 }
export const variableValueSchema = Type.Union([Type.String(), Type.Number(), Type.Boolean()]); export const variableValueSchema = Type.Union([Type.String(), Type.Number(), Type.Boolean()]);
export const variableReferenceSchema = Type.String({ pattern: "^\\$\\{[^}]+\\}$" });
export const statusCodePatternSchema = Type.Union([ export const statusCodePatternSchema = Type.Union([
Type.Integer({ maximum: 599, minimum: 100 }), Type.Integer({ maximum: 599, minimum: 100 }),
Type.String({ pattern: "^[1-5]xx$" }), Type.String({ pattern: "^[1-5]xx$" }),
@@ -41,6 +43,81 @@ export const stringMapSchema = Type.Unsafe<Record<string, string>>({
type: "object", type: "object",
}); });
export function createAuthoringContentExpectationsSchema(): TSchema {
return createRawContentExpectationsSchema();
}
export function createAuthoringFieldSchema(schema: TSchema): TSchema {
return Type.Unsafe({ anyOf: [schema, variableReferenceSchema] });
}
export function createAuthoringKeyedExpectationsSchema(): TSchema {
return createRawKeyedExpectationsSchema();
}
export function createAuthoringStringMapSchema(): TSchema {
return Type.Unsafe<Record<string, string>>({
additionalProperties: { anyOf: [{ type: "string" }, variableReferenceSchema] },
type: "object",
});
}
export function createAuthoringValueExpectationSchema(): TSchema {
return createRawValueExpectationSchema();
}
export function createNormalizedContentExpectationsSchema(): TSchema {
const valueExpectation = Type.Object(
{
kind: Type.Literal("value"),
matcher: createValueMatcherObjectSchema(),
},
{ additionalProperties: false },
);
const jsonExpectation = Type.Object(
{
kind: Type.Literal("json"),
matcher: createValueMatcherObjectSchema(),
path: Type.String(),
},
{ additionalProperties: false },
);
const cssExpectation = Type.Object(
{
attr: Type.Optional(Type.String()),
kind: Type.Literal("css"),
matcher: createValueMatcherObjectSchema(),
selector: Type.String({ minLength: 1 }),
},
{ additionalProperties: false },
);
const xpathExpectation = Type.Object(
{
kind: Type.Literal("xpath"),
matcher: createValueMatcherObjectSchema(),
path: Type.String({ minLength: 1 }),
},
{ additionalProperties: false },
);
return Type.Array(Type.Union([valueExpectation, jsonExpectation, cssExpectation, xpathExpectation]));
}
export function createNormalizedKeyedExpectationsSchema(): TSchema {
return Type.Array(
Type.Object(
{
key: Type.String(),
matcher: createValueMatcherObjectSchema(),
},
{ additionalProperties: false },
),
);
}
export function createNormalizedValueExpectationSchema(): TSchema {
return createValueMatcherObjectSchema();
}
export function createRawContentExpectationsSchema(): TSchema { export function createRawContentExpectationsSchema(): TSchema {
return Type.Array( return Type.Array(
Type.Object( Type.Object(

View File

@@ -2,12 +2,16 @@ import type { ProbeConfig } from "../types";
declare const validatedConfigBrand: unique symbol; declare const validatedConfigBrand: unique symbol;
export type AuthoringProbeConfig = ProbeConfig;
export type JsonValue = boolean | JsonValue[] | null | number | string | { [key: string]: JsonValue }; export type JsonValue = boolean | JsonValue[] | null | number | string | { [key: string]: JsonValue };
export type RawProbeConfig = ProbeConfig; export type NormalizedProbeConfig = Omit<ProbeConfig, "variables">;
export type ValidatedProbeConfig = RawProbeConfig & { readonly [validatedConfigBrand]: true }; export type RawProbeConfig = AuthoringProbeConfig;
export function asValidatedConfig(config: RawProbeConfig): ValidatedProbeConfig { export type ValidatedProbeConfig = NormalizedProbeConfig & { readonly [validatedConfigBrand]: true };
export function asValidatedConfig(config: NormalizedProbeConfig): ValidatedProbeConfig {
return config as ValidatedProbeConfig; return config as ValidatedProbeConfig;
} }

View File

@@ -5,9 +5,9 @@ import { isPlainObject, isString } from "es-toolkit";
import type { CheckerRegistry } from "../runner/registry"; import type { CheckerRegistry } from "../runner/registry";
import type { ConfigValidationIssue } from "./issues"; import type { ConfigValidationIssue } from "./issues";
import type { RawProbeConfig } from "./types"; import type { NormalizedProbeConfig } from "./types";
import { createProbeConfigSchema, createTargetSchema } from "./builder"; import { createNormalizedProbeConfigSchema, createNormalizedTargetSchema } from "./builder";
import { issue } from "./issues"; import { issue } from "./issues";
export function createConfigAjv(): Ajv { export function createConfigAjv(): Ajv {
@@ -21,11 +21,11 @@ export function issuesFromAjvErrors(errors: ErrorObject[], root: unknown, basePa
export function validateProbeConfigContract( export function validateProbeConfigContract(
config: unknown, config: unknown,
registry: CheckerRegistry, registry: CheckerRegistry,
): { config: null; issues: ConfigValidationIssue[] } | { config: RawProbeConfig; issues: [] } { ): { config: NormalizedProbeConfig; issues: [] } | { config: null; issues: ConfigValidationIssue[] } {
const ajv = createConfigAjv(); const ajv = createConfigAjv();
const checkers = registry.definitions; const checkers = registry.definitions;
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
const rootValidate = ajv.compile(createProbeConfigSchema(checkers)); const rootValidate = ajv.compile(createNormalizedProbeConfigSchema(checkers));
if (!rootValidate(config)) { if (!rootValidate(config)) {
issues.push(...issuesFromAjvErrors(rootValidate.errors ?? [], config)); issues.push(...issuesFromAjvErrors(rootValidate.errors ?? [], config));
} }
@@ -34,7 +34,7 @@ export function validateProbeConfigContract(
const configRecord = config as Record<string, unknown>; const configRecord = config as Record<string, unknown>;
const targetsValue: unknown = configRecord["targets"]; const targetsValue: unknown = configRecord["targets"];
if (!Array.isArray(targetsValue)) if (!Array.isArray(targetsValue))
return issues.length > 0 ? { config: null, issues } : { config: config as RawProbeConfig, issues: [] }; return issues.length > 0 ? { config: null, issues } : { config: config as NormalizedProbeConfig, issues: [] };
const targets = targetsValue; const targets = targetsValue;
for (let i = 0; i < targets.length; i++) { for (let i = 0; i < targets.length; i++) {
const target: unknown = targets[i]; const target: unknown = targets[i];
@@ -44,14 +44,14 @@ export function validateProbeConfigContract(
if (!isString(targetType)) continue; if (!isString(targetType)) continue;
const checker = registry.tryGet(targetType); const checker = registry.tryGet(targetType);
if (!checker) continue; if (!checker) continue;
const targetValidate = ajv.compile(createTargetSchema(checker)); const targetValidate = ajv.compile(createNormalizedTargetSchema(checker));
if (!targetValidate(target)) { if (!targetValidate(target)) {
issues.push(...issuesFromAjvErrors(targetValidate.errors ?? [], config, `targets[${i}]`)); issues.push(...issuesFromAjvErrors(targetValidate.errors ?? [], config, `targets[${i}]`));
} }
} }
} }
return issues.length > 0 ? { config: null, issues } : { config: config as RawProbeConfig, issues: [] }; return issues.length > 0 ? { config: null, issues } : { config: config as NormalizedProbeConfig, issues: [] };
} }
function buildIssuePath(basePath: string, error: ErrorObject): string { function buildIssuePath(basePath: string, error: ErrorObject): string {

View File

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

View File

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

View File

@@ -84,6 +84,69 @@ describe("config contract", () => {
expect(validate(target({ equals: { status: "ok" } }))).toBe(true); expect(validate(target({ equals: { status: "ok" } }))).toBe(true);
}); });
test("Authoring schema 接受变量引用Normalized schema 拒绝变量引用和 primitive 简写", () => {
const ajv = new Ajv({
allErrors: true,
coerceTypes: false,
removeAdditional: false,
strict: true,
useDefaults: false,
});
const registry = createDefaultCheckerRegistry();
const authoring = ajv.compile(createProbeConfigJsonSchema(registry));
const normalizedConfig = {
targets: [
{
expect: { durationMs: 5000 },
http: { maxRedirects: "${MAX|5}", url: "https://example.com" },
id: "api",
type: "http",
},
],
};
expect(
authoring({
targets: [
{
expect: { durationMs: 5000, status: ["${STATUS}"] },
http: { ignoreSSL: "${SSL|false}", maxRedirects: "${MAX|5}", url: "https://example.com" },
id: "api",
type: "http",
},
],
}),
).toBe(false);
expect(
authoring({
targets: [
{
expect: { durationMs: 5000 },
http: {
ignoreSSL: "${SSL|false}",
maxRedirects: "${MAX|5}",
method: "${METHOD|GET}",
url: "https://example.com",
},
id: "api",
type: "http",
},
{
id: "llm-api",
llm: {
model: "gpt-4o-mini",
prompt: "ping",
provider: "${P|openai}",
url: "https://example.com/v1/chat/completions",
},
type: "llm",
},
],
}),
).toBe(true);
expect(validateProbeConfigContract(normalizedConfig, registry).config).toBeNull();
});
test("导出 schema 拒绝 KeyedExpectations 的数组和对象简写", () => { test("导出 schema 拒绝 KeyedExpectations 的数组和对象简写", () => {
const ajv = new Ajv({ const ajv = new Ajv({
allErrors: true, allErrors: true,
@@ -144,4 +207,71 @@ describe("config contract", () => {
expect(message).toBe('target "api" 的 http.extra 是未知字段\ntarget "api" 的 expect.body[0].regex 正则不合法'); expect(message).toBe('target "api" 的 http.extra 是未知字段\ntarget "api" 的 expect.body[0].regex 正则不合法');
}); });
test("Authoring schema 接受 server.listen.port、llm.options.maxOutputTokens 变量引用", () => {
const ajv = new Ajv({
allErrors: true,
coerceTypes: false,
removeAdditional: false,
strict: true,
useDefaults: false,
});
const validate = ajv.compile(createProbeConfigJsonSchema(createDefaultCheckerRegistry()));
expect(
validate({
server: { listen: { port: "${SERVER_PORT|3000}" } },
targets: [
{
id: "llm",
llm: {
model: "gpt-4o-mini",
options: { maxOutputTokens: "${MAX_TOKENS|100}" },
prompt: "ping",
provider: "openai",
url: "https://example.com/v1/chat/completions",
},
type: "llm",
},
],
}),
).toBe(true);
});
test("Authoring schema 拒绝 expect.status 和 expect.exitCode 数组内的变量引用", () => {
const ajv = new Ajv({
allErrors: true,
coerceTypes: false,
removeAdditional: false,
strict: true,
useDefaults: false,
});
const validate = ajv.compile(createProbeConfigJsonSchema(createDefaultCheckerRegistry()));
expect(
validate({
targets: [
{
expect: { status: ["${STATUS}"] },
http: { url: "https://example.com" },
id: "http",
type: "http",
},
],
}),
).toBe(false);
expect(
validate({
targets: [
{
cmd: { exec: "true" },
expect: { exitCode: ["${CODE}"] },
id: "cmd",
type: "cmd",
},
],
}),
).toBe(false);
});
}); });

View File

@@ -911,11 +911,6 @@ targets:
const config = await loadConfig(configPath); const config = await loadConfig(configPath);
const t = config.targets[0]!; const t = config.targets[0]!;
if (t.type === "http") { if (t.type === "http") {
expect(t.rawExpect).toEqual({
body: [{ contains: "ok" }, { json: { equals: "ok", path: "$.status" } }],
durationMs: { lte: 3000 },
status: [200, 201],
});
expect(t.expect).toEqual({ expect(t.expect).toEqual({
body: [ body: [
{ kind: "value", matcher: { contains: "ok" } }, { kind: "value", matcher: { contains: "ok" } },
@@ -952,12 +947,6 @@ targets:
const config = await loadConfig(configPath); const config = await loadConfig(configPath);
const t = config.targets[0]!; const t = config.targets[0]!;
if (t.type === "cmd") { if (t.type === "cmd") {
expect(t.rawExpect).toEqual({
durationMs: { lte: 5000 },
exitCode: [0, 2],
stderr: [{ empty: true }],
stdout: [{ contains: "ok" }, { regex: "done" }],
});
expect(t.expect).toEqual({ expect(t.expect).toEqual({
durationMs: { lte: 5000 }, durationMs: { lte: 5000 },
exitCode: [0, 2], exitCode: [0, 2],
@@ -1291,7 +1280,7 @@ targets:
equals: "ok" equals: "ok"
`, `,
); );
await expectConfigLoadError(configPath, "json.path"); await expectConfigLoadError(configPath, "path 必须为以");
}); });
test("body css selector 为空抛出错误", async () => { test("body css selector 为空抛出错误", async () => {
@@ -1310,7 +1299,7 @@ targets:
selector: "" selector: ""
`, `,
); );
await expectConfigLoadError(configPath, "css.selector 必须为非空字符串"); await expectConfigLoadError(configPath, "selector 必须为非空字符串");
}); });
test("旧 match matcher 抛出错误", async () => { test("旧 match matcher 抛出错误", async () => {
@@ -1329,7 +1318,7 @@ targets:
match: "[invalid" match: "[invalid"
`, `,
); );
await expectConfigLoadError(configPath, "match 是未知 matcher"); await expectConfigLoadError(configPath, "match 是未知字段");
}); });
test("operator gte 非数字抛出错误", async () => { test("operator gte 非数字抛出错误", async () => {
@@ -1410,7 +1399,7 @@ targets:
path: "" path: ""
`, `,
); );
await expectConfigLoadError(configPath, "xpath.path 必须为非空字符串"); await expectConfigLoadError(configPath, "path 必须为非空字符串");
}); });
test("expect headers 非对象抛出错误", async () => { test("expect headers 非对象抛出错误", async () => {

View File

@@ -183,7 +183,7 @@ describe("CommandChecker", () => {
test("resolve 未配置 expect 时物化默认 exitCode", () => { test("resolve 未配置 expect 时物化默认 exitCode", () => {
const result = checker.resolve({ cmd: { exec: "true" }, id: "test", type: "cmd" }, makeResolveContext()); const result = checker.resolve({ cmd: { exec: "true" }, id: "test", type: "cmd" }, makeResolveContext());
expect(result.rawExpect).toBeUndefined(); expect("rawExpect" in result).toBe(false);
expect(result.expect).toEqual({ exitCode: [0] }); expect(result.expect).toEqual({ exitCode: [0] });
}); });
}); });

View File

@@ -975,7 +975,7 @@ describe("HttpChecker.resolve", () => {
makeResolveContext(), makeResolveContext(),
); );
expect(result.rawExpect).toBeUndefined(); expect("rawExpect" in result).toBe(false);
expect(result.expect).toEqual({ status: [200] }); expect(result.expect).toEqual({ status: [200] });
}); });

View File

@@ -128,7 +128,7 @@ describe("IcmpChecker resolve", () => {
); );
expect(target.icmp).toEqual({ count: 3, host: "10.0.0.1", packetSize: 56 }); expect(target.icmp).toEqual({ count: 3, host: "10.0.0.1", packetSize: 56 });
expect(target.group).toBe("default"); expect(target.group).toBe("default");
expect(target.rawExpect).toBeUndefined(); expect("rawExpect" in target).toBe(false);
expect(target.expect).toEqual({ alive: true }); expect(target.expect).toEqual({ alive: true });
}); });

View File

@@ -16,8 +16,10 @@ describe("LLM registry integration", () => {
test("llm checker schemas 有效", () => { test("llm checker schemas 有效", () => {
const checker = checkerRegistry.get("llm"); const checker = checkerRegistry.get("llm");
expect(checker.schemas.config).toBeDefined(); expect(checker.schemas.authoring.config).toBeDefined();
expect(checker.schemas.expect).toBeDefined(); expect(checker.schemas.authoring.expect).toBeDefined();
expect(checker.schemas.normalized.config).toBeDefined();
expect(checker.schemas.normalized.expect).toBeDefined();
}); });
test("llm checker validate 方法可用", () => { test("llm checker validate 方法可用", () => {

View File

@@ -60,9 +60,11 @@ describe("LlmChecker schema", () => {
expect(checker?.configKey).toBe("llm"); expect(checker?.configKey).toBe("llm");
}); });
test("schemas 包含 configexpect", () => { test("schemas 包含 authoring 和 normalized config/expect", () => {
expect(checker).toBeDefined(); expect(checker).toBeDefined();
expect(Object.keys(checker!.schemas).sort()).toEqual(["config", "expect"].sort()); expect(Object.keys(checker!.schemas).sort()).toEqual(["authoring", "normalized"].sort());
expect(checker!.schemas.authoring.config).toBeDefined();
expect(checker!.schemas.normalized.expect).toBeDefined();
}); });
}); });
@@ -249,13 +251,13 @@ describe("LlmChecker resolve", () => {
expect(resolved.group).toBe("default"); expect(resolved.group).toBe("default");
expect(resolved.intervalMs).toBe(30000); expect(resolved.intervalMs).toBe(30000);
expect(resolved.timeoutMs).toBe(10000); expect(resolved.timeoutMs).toBe(10000);
expect(resolved.rawExpect).toBeUndefined(); expect("rawExpect" in resolved).toBe(false);
expect(resolved.expect).toEqual({ status: [200] }); expect(resolved.expect).toEqual({ status: [200] });
}); });
test("stream mode 未配置 expect.stream 时不物化 completed", () => { test("stream mode 未配置 expect.stream 时不物化 completed", () => {
const raw = makeRawTarget({ const raw = makeRawTarget({
expect: { output: [{ contains: "OK" }] }, expect: { output: [{ kind: "value", matcher: { contains: "OK" } }] },
llm: { llm: {
mode: "stream", mode: "stream",
model: "gpt-4o-mini", model: "gpt-4o-mini",
@@ -267,13 +269,13 @@ describe("LlmChecker resolve", () => {
const resolved = asLlm(checker.resolve(raw, makeResolveContext())); const resolved = asLlm(checker.resolve(raw, makeResolveContext()));
expect(resolved.rawExpect).toEqual({ output: [{ contains: "OK" }] }); expect("rawExpect" in resolved).toBe(false);
expect(resolved.expect?.stream).toBeUndefined(); expect(resolved.expect?.stream).toBeUndefined();
}); });
test("配置 expect.stream 但省略 completed 时默认 true", () => { test("配置 expect.stream 但省略 completed 时默认 true", () => {
const raw = makeRawTarget({ const raw = makeRawTarget({
expect: { stream: { firstTokenMs: 100 } }, expect: { stream: { firstTokenMs: { equals: 100 } } },
llm: { llm: {
mode: "stream", mode: "stream",
model: "gpt-4o-mini", model: "gpt-4o-mini",
@@ -285,7 +287,7 @@ describe("LlmChecker resolve", () => {
const resolved = asLlm(checker.resolve(raw, makeResolveContext())); const resolved = asLlm(checker.resolve(raw, makeResolveContext()));
expect(resolved.rawExpect).toEqual({ stream: { firstTokenMs: 100 } }); expect("rawExpect" in resolved).toBe(false);
expect(resolved.expect?.stream).toEqual({ completed: true, firstTokenMs: { equals: 100 } }); expect(resolved.expect?.stream).toEqual({ completed: true, firstTokenMs: { equals: 100 } });
}); });

View File

@@ -14,8 +14,14 @@ function createChecker(type: string): Checker {
execute: () => Promise.resolve<CheckResult>({} as unknown as CheckResult), execute: () => Promise.resolve<CheckResult>({} as unknown as CheckResult),
resolve: () => ({}) as unknown as ResolvedTargetBase, resolve: () => ({}) as unknown as ResolvedTargetBase,
schemas: { schemas: {
config: Type.Object({}, { additionalProperties: false }), authoring: {
expect: Type.Object({}, { additionalProperties: false }), config: Type.Object({}, { additionalProperties: false }),
expect: Type.Object({}, { additionalProperties: false }),
},
normalized: {
config: Type.Object({}, { additionalProperties: false }),
expect: Type.Object({}, { additionalProperties: false }),
},
}, },
serialize: () => ({ config: "", target: "" }), serialize: () => ({ config: "", target: "" }),
type, type,
@@ -68,7 +74,9 @@ describe("CheckerRegistry", () => {
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "custom"]); expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "custom"]);
expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm"]); expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm"]);
expect(first.definitions.every((checker) => checker.schemas.config && checker.schemas.expect)).toBe(true); expect(
first.definitions.every((checker) => checker.schemas.authoring.config && checker.schemas.normalized.expect),
).toBe(true);
}); });
test("默认 registry 注册 icmp type", () => { test("默认 registry 注册 icmp type", () => {

View File

@@ -1,7 +1,9 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import type { CheckerValidationInput } from "../../../../../src/server/checker/runner/types"; import type { CheckerValidationInput } from "../../../../../src/server/checker/runner/types";
import type { RawTargetConfig } from "../../../../../src/server/checker/types";
import { normalizeAuthoringConfig } from "../../../../../src/server/checker/normalizer";
import { validateDbConfig } from "../../../../../src/server/checker/runner/db/validate"; import { validateDbConfig } from "../../../../../src/server/checker/runner/db/validate";
import { validateHttpConfig } from "../../../../../src/server/checker/runner/http/validate"; import { validateHttpConfig } from "../../../../../src/server/checker/runner/http/validate";
import { validateLlmConfig } from "../../../../../src/server/checker/runner/llm/validate"; import { validateLlmConfig } from "../../../../../src/server/checker/runner/llm/validate";
@@ -52,14 +54,45 @@ describe("HTTP/LLM headers reject case-insensitive duplicate keys", () => {
expect(validateHttpConfig(input(target)).some((i) => i.code === "duplicate-key")).toBe(false); expect(validateHttpConfig(input(target)).some((i) => i.code === "duplicate-key")).toBe(false);
}); });
test("DB rows 保留大小写敏感不触发 duplicate-key", () => { test("HTTP normalized headers 大小写不同的重复 key 报错", () => {
const target = { const authoring = {
expect: { headers: { "Content-Type": "application/json", "content-type": "text/plain" } },
http: { url: "https://example.com" },
id: "dup",
type: "http",
};
const result = normalizeAuthoringConfig({ targets: [authoring] });
const normalized = result.config as { targets: RawTargetConfig[] };
const issues = validateHttpConfig({ targets: normalized.targets });
expect(issues.some((i) => i.code === "duplicate-key" && i.path.includes("headers"))).toBe(true);
});
test("HTTP normalized headers unsafe regex 报错", () => {
const authoring = {
expect: { headers: { "x-test": { regex: "(\\d+)*x" } } },
http: { url: "https://example.com" },
id: "unsafe",
type: "http",
};
const result = normalizeAuthoringConfig({ targets: [authoring] });
const normalized = result.config as { targets: RawTargetConfig[] };
const issues = validateHttpConfig({ targets: normalized.targets });
expect(issues.some((i) => i.code === "unsafe-regex" && i.path.includes("headers"))).toBe(true);
});
test("DB normalized rows 通过校验", () => {
const authoring = {
db: { query: "SELECT 1", url: "sqlite://:memory:" }, db: { query: "SELECT 1", url: "sqlite://:memory:" },
expect: { rows: [{ Name: "a", name: "b" }] }, expect: { rows: [{ Name: "Alice" }] },
id: "dup-rows", id: "db-rows",
type: "db", type: "db",
}; };
expect(validateDbConfig(input(target)).some((i) => i.code === "duplicate-key")).toBe(false); const result = normalizeAuthoringConfig({ targets: [authoring] });
const normalized = result.config as { targets: RawTargetConfig[] };
const issues = validateDbConfig({ targets: normalized.targets });
expect(issues).toHaveLength(0);
}); });
}); });

View File

@@ -314,7 +314,7 @@ describe("TcpChecker resolve", () => {
expect(target.name).toBeNull(); expect(target.name).toBeNull();
expect(target.intervalMs).toBe(30000); expect(target.intervalMs).toBe(30000);
expect(target.timeoutMs).toBe(10000); expect(target.timeoutMs).toBe(10000);
expect(target.rawExpect).toBeUndefined(); expect("rawExpect" in target).toBe(false);
expect(target.expect).toEqual({ connected: true }); expect(target.expect).toEqual({ connected: true });
}); });
@@ -343,7 +343,11 @@ describe("TcpChecker resolve", () => {
test("expect 配置解析", () => { test("expect 配置解析", () => {
const target = checker.resolve( const target = checker.resolve(
{ {
expect: { banner: [{ contains: "ESMTP" }], connected: false, durationMs: { lte: 5000 } }, expect: {
banner: [{ kind: "value", matcher: { contains: "ESMTP" } }],
connected: false,
durationMs: { lte: 5000 },
},
id: "t", id: "t",
tcp: { host: "127.0.0.1", port: 80, readBanner: true }, tcp: { host: "127.0.0.1", port: 80, readBanner: true },
type: "tcp", type: "tcp",
@@ -355,7 +359,7 @@ describe("TcpChecker resolve", () => {
connected: false, connected: false,
durationMs: { lte: 5000 }, durationMs: { lte: 5000 },
}); });
expect(target.rawExpect).toEqual({ banner: [{ contains: "ESMTP" }], connected: false, durationMs: { lte: 5000 } }); expect("rawExpect" in target).toBe(false);
}); });
test("name 和 group 解析", () => { test("name 和 group 解析", () => {

View File

@@ -340,7 +340,7 @@ describe("UdpChecker resolve", () => {
expect(target.udp.encoding).toBe("text"); expect(target.udp.encoding).toBe("text");
expect(target.udp.responseEncoding).toBe("text"); expect(target.udp.responseEncoding).toBe("text");
expect(target.udp.maxResponseBytes).toBe(4096); expect(target.udp.maxResponseBytes).toBe(4096);
expect(target.rawExpect).toBeUndefined(); expect("rawExpect" in target).toBe(false);
expect(target.expect).toEqual({ responded: true }); expect(target.expect).toEqual({ responded: true });
}); });

View File

@@ -39,7 +39,6 @@ const httpTarget: ResolvedHttpTarget = {
id: "test-http", id: "test-http",
intervalMs: 30000, intervalMs: 30000,
name: "test-http", name: "test-http",
rawExpect: { body: [{ contains: "ok" }], durationMs: 3000 },
timeoutMs: 10000, timeoutMs: 10000,
type: "http", type: "http",
}; };
@@ -107,7 +106,7 @@ describe("ProbeStore", () => {
expect(config.maxRedirects).toBe(0); expect(config.maxRedirects).toBe(0);
expect(t.interval_ms).toBe(30000); expect(t.interval_ms).toBe(30000);
expect(t.timeout_ms).toBe(10000); expect(t.timeout_ms).toBe(10000);
expect(JSON.parse(t.expect!)).toEqual({ body: [{ contains: "ok" }], durationMs: 3000 }); expect(t.expect).toBeNull();
}); });
test("cmd target 字段正确", () => { test("cmd target 字段正确", () => {

View File

@@ -1,8 +1,35 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { normalizeAuthoringConfig } from "../../../src/server/checker/normalizer";
import { extractVariables, resolveVariables } from "../../../src/server/checker/variables"; import { extractVariables, resolveVariables } from "../../../src/server/checker/variables";
describe("config variables", () => { describe("config variables", () => {
test("normalizeAuthoringConfig 替换变量、展开 expect 简写并移除 variables", () => {
const result = normalizeAuthoringConfig({
targets: [
{
expect: { durationMs: "${maxMs}" },
http: { url: "${url}" },
id: "api",
type: "http",
},
],
variables: { maxMs: 1000, url: "https://example.com" },
});
expect(result.issues).toHaveLength(0);
expect(result.config).toEqual({
targets: [
{
expect: { durationMs: { equals: 1000 } },
http: { url: "https://example.com" },
id: "api",
type: "http",
},
],
});
});
test("提取合法 variables 类型", () => { test("提取合法 variables 类型", () => {
const result = extractVariables({ variables: { enabled: true, host: "example.com", port: 5432 } }); const result = extractVariables({ variables: { enabled: true, host: "example.com", port: 5432 } });