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:
@@ -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(yaml:YAML 解析 → 变量替换 → 契约校验 → 语义校验 → resolve)
|
→ loadConfig(yaml:YAML 解析 → 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 片段描述用户可写 DSL,Normalized 片段描述 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*` 阈值字段不再支持。
|
||||||
|
|
||||||
**快速失败顺序**:
|
**快速失败顺序**:
|
||||||
|
|
||||||
|
|||||||
@@ -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 Config;checker 的 `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 的通用字段:
|
||||||
|
|||||||
@@ -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` 写入 NULL,MUST 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 resolve,MUST NOT 在中间层理解 checker 专属 expect 字段。
|
每个 checker 的 `resolve()` SHALL 接收已通过 Normalized schema 和语义校验的配置,不再包含变量引用、Authoring expect primitive 简写或 Raw content/keyed DSL。`config-loader` SHALL 继续通过 registry 委托 checker resolve,MUST 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 字段
|
||||||
|
|||||||
@@ -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 }`
|
||||||
|
|||||||
@@ -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"}}`
|
||||||
|
|||||||
@@ -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 expect;checker execute SHALL 只消费 Resolved expect。
|
配置加载流程 MUST NOT 保留变量替换后的 Raw expect 作为执行路径依赖。语义校验 SHALL 读取 Normalized expect 并报告问题。Store 持久化 MUST NOT 依赖 Raw expect;checker 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 durationMs,checker.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 durationMs,checker.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 不注入 status,checker.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 不注入 exitCode,checker.resolve() SHALL 在 Resolved cmd expect 中物化默认 `exitCode: [0]` 语义
|
||||||
|
|
||||||
#### Scenario: 不配置 expect
|
#### Scenario: 不配置 expect
|
||||||
- **WHEN** target 未配置任何 expect 规则
|
- **WHEN** target 未配置任何 expect 规则
|
||||||
- **THEN** 系统 SHALL 正常处理,Raw expect 快照为 undefined,Resolved expect 由各 checker 物化自身默认状态语义
|
- **THEN** 系统 SHALL 正常处理,Normalized Config 不包含 expect,Resolved 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 输入。
|
||||||
|
|
||||||
|
|||||||
@@ -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 均可为 NULL,expect 列保存变量替换后的 Raw expect JSON
|
- **THEN** 系统 SHALL 将所有目标插入 targets 表,包含 name、description、type、target、config、interval_ms、timeout_ms、expect、grp 和 active=1,其中 name 和 description 均可为 NULL,expect 列写入 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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
187
src/server/checker/normalizer.ts
Normal file
187
src/server/checker/normalizer.ts
Normal 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 };
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 },
|
||||||
),
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 },
|
||||||
),
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 },
|
||||||
),
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 },
|
||||||
),
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 },
|
||||||
),
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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] });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 方法可用", () => {
|
||||||
|
|||||||
@@ -60,9 +60,11 @@ describe("LlmChecker schema", () => {
|
|||||||
expect(checker?.configKey).toBe("llm");
|
expect(checker?.configKey).toBe("llm");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("schemas 包含 config、expect", () => {
|
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 } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 解析", () => {
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 字段正确", () => {
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user