From cf847ccd7ae7c54b4fb1f1a4c881357fc77554c9 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Fri, 22 May 2026 14:00:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E4=B8=BA=20Authoring/Norma?= =?UTF-8?q?lized/Resolved=20=E4=B8=89=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将变量替换和 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 --- DEVELOPMENT.md | 43 +- README.md | 2 + .../specs/checker-runner-abstraction/spec.md | 42 +- openspec/specs/config-variables/spec.md | 14 +- openspec/specs/expect-rule-system/spec.md | 64 +- openspec/specs/probe-config/spec.md | 118 ++- openspec/specs/probe-data-store/spec.md | 8 +- probe-config.schema.json | 684 +++++++++++++----- probes.example.yaml | 6 +- src/server/checker/config-loader.ts | 20 +- src/server/checker/expect/content.ts | 4 +- src/server/checker/expect/validate.ts | 65 ++ src/server/checker/normalizer.ts | 187 +++++ src/server/checker/runner/cmd/execute.ts | 22 +- src/server/checker/runner/cmd/schema.ts | 42 +- src/server/checker/runner/cmd/types.ts | 1 - src/server/checker/runner/db/execute.ts | 18 +- src/server/checker/runner/db/schema.ts | 51 +- src/server/checker/runner/db/types.ts | 1 - src/server/checker/runner/db/validate.ts | 7 +- src/server/checker/runner/http/execute.ts | 18 +- src/server/checker/runner/http/schema.ts | 57 +- src/server/checker/runner/http/types.ts | 1 - src/server/checker/runner/http/validate.ts | 2 +- src/server/checker/runner/icmp/execute.ts | 22 +- src/server/checker/runner/icmp/schema.ts | 67 +- src/server/checker/runner/icmp/types.ts | 1 - src/server/checker/runner/llm/execute.ts | 32 +- src/server/checker/runner/llm/schema.ts | 90 ++- src/server/checker/runner/llm/types.ts | 1 - src/server/checker/runner/tcp/execute.ts | 15 +- src/server/checker/runner/tcp/schema.ts | 48 +- src/server/checker/runner/tcp/types.ts | 1 - src/server/checker/runner/types.ts | 7 +- src/server/checker/runner/udp/execute.ts | 18 +- src/server/checker/runner/udp/schema.ts | 69 +- src/server/checker/runner/udp/types.ts | 1 - src/server/checker/schema/builder.ts | 144 ++-- src/server/checker/schema/fragments.ts | 77 ++ src/server/checker/schema/types.ts | 10 +- src/server/checker/schema/validate.ts | 14 +- src/server/checker/store.ts | 2 +- src/server/checker/types.ts | 1 - .../checker/config-contract/validate.test.ts | 130 ++++ tests/server/checker/config-loader.test.ts | 19 +- .../server/checker/runner/cmd/runner.test.ts | 2 +- .../server/checker/runner/http/runner.test.ts | 2 +- .../checker/runner/icmp/execute.test.ts | 2 +- .../checker/runner/llm/registry.test.ts | 6 +- .../llm/schema-validate-resolve.test.ts | 16 +- tests/server/checker/runner/registry.test.ts | 14 +- .../shared/duplicate-header-key.test.ts | 43 +- .../server/checker/runner/tcp/execute.test.ts | 10 +- .../server/checker/runner/udp/execute.test.ts | 2 +- tests/server/checker/store.test.ts | 3 +- tests/server/checker/variables.test.ts | 27 + 56 files changed, 1717 insertions(+), 656 deletions(-) create mode 100644 src/server/checker/normalizer.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f095ebf..dab7e67 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -139,7 +139,7 @@ probe-config.schema.json 用户配置 JSON Schema 导出物(用于 IDE 自动 启动流程: dev.ts / main.ts → readRuntimeConfig(cli args, 仅提取 configPath) → 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) } → createRuntimeLogger(logging) → Logger(配置加载失败时使用 ConsoleFallbackLogger) → ProbeStore(db) → store.syncTargets(targets) @@ -246,9 +246,13 @@ export function handleMetrics(idStr: string, url: URL, store: ProbeStore, mode: ### 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`。 @@ -375,29 +379,28 @@ TcpChecker implements Checker **`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` 确保类型正确 -**expect 五层管线**:每种断言模型从定义到执行经过五个阶段,每层使用对应的共享函数: +**expect 管线**:配置从定义到执行经过 Authoring → Normalized → Resolved → Execute 四层,简写展开在 normalizer 阶段完成: -| 断言模型 | 类型层(Raw) | Schema 层 | Validate 层 | Resolve 层 | Execute 层 | -| --------------------- | ----------------------------------- | ----------------------------------- | ---------------------------------- | ------------------------------ | ---------------------------- | -| `ValueExpectation` | `number \| ValueMatcher` | `createValueMatcherSchema()` | `validateRawValueExpectation()` | `resolveValueExpectation()` | `checkValueExpectation()` | -| `ContentExpectations` | `(ValueMatcher \| ExtractorRule)[]` | `createContentExpectationsSchema()` | `validateRawContentExpectations()` | `resolveContentExpectations()` | `checkContentExpectations()` | -| `KeyedExpectations` | `Record` | `createKeyedExpectationsSchema()` | `validateRawKeyedExpectations()` | `resolveKeyedExpectations()` | `checkKeyedExpectations()` | +| 断言模型 | Authoring 输入 | Normalizer 层 | Schema 层(Authoring / Normalized) | Validate 层(接收 Normalized) | Execute 层 | +| --------------------- | ----------------------------------- | ------------------------------ | -------------------------------------------------------------------- | ---------------------------------- | ---------------------------- | +| `ValueExpectation` | `number \| ValueMatcher` | `resolveValueExpectation()` | `createAuthoringValueExpectationSchema()` / `createNormalized*()` | `validateRawValueExpectation()` | `checkValueExpectation()` | +| `ContentExpectations` | `(ValueMatcher \| ExtractorRule)[]` | `resolveContentExpectations()` | `createAuthoringContentExpectationsSchema()` / `createNormalized*()` | `validateRawContentExpectations()` | `checkContentExpectations()` | +| `KeyedExpectations` | `Record` | `resolveKeyedExpectations()` | `createAuthoringKeyedExpectationsSchema()` / `createNormalized*()` | `validateRawKeyedExpectations()` | `checkKeyedExpectations()` | 选择哪种模型参考 [1.10 expect 字段选择规范](#110-expect-断言系统)的决策树。 **resolve 中的标准模式**: ```typescript -// resolve() 内:逐字段调用对应的 resolve 函数,未配置的字段保持 undefined -const rawExpect = raw.expect ?? {}; -expect: { - durationMs: rawExpect.durationMs != null ? resolveValueExpectation(rawExpect.durationMs) : undefined, - body: rawExpect.body != null ? resolveContentExpectations(rawExpect.body) : undefined, - headers: rawExpect.headers != null ? resolveKeyedExpectations(rawExpect.headers) : undefined, -} +// resolve() 内:expect 已是 normalized 形态,spread 后补默认值 +const expect = target.expect as ResolvedXxxExpectConfig | undefined; +const resolvedExpect: ResolvedXxxExpectConfig = expect + ? { ...expect, status: expect.status ?? [200] } + : { status: [200] }; ``` **execute 中的标准模式**: @@ -627,7 +630,7 @@ expect(logger.entries[0]!.msg).toContain("UP → DOWN"); 两层模型:**观测值收集** → **规则校验**。共享断言基础设施位于 `checker/expect/`,checker 专属状态断言位于各自目录。 -**Raw vs Resolved**:用户 YAML 写的是 Raw 形态(primitive 简写、`{ json: { path, equals } }` 提取器对象、`Record` 键值表),`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` 键值表),`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` | | `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` 仍对原始结构做深度相等。 -启动期语义校验统一由 `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*` 阈值字段不再支持。 **快速失败顺序**: diff --git a/README.md b/README.md index f3b14c2..8af55f9 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,8 @@ targets: # 拨测目标列表(必填) 解析优先级为 `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 — 拨测目标列表(必填) 每个 target 的通用字段: diff --git a/openspec/specs/checker-runner-abstraction/spec.md b/openspec/specs/checker-runner-abstraction/spec.md index 0b47a54..399bc72 100644 --- a/openspec/specs/checker-runner-abstraction/spec.md +++ b/openspec/specs/checker-runner-abstraction/spec.md @@ -5,7 +5,7 @@ ## Requirements ### 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 提供契约片段 - **WHEN** HTTP checker 被注册 @@ -21,7 +21,11 @@ #### Scenario: 外部 schema 通过 registry 生成 - **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 - **WHEN** 测试或 schema 生成流程需要组装配置契约 @@ -50,11 +54,11 @@ - **THEN** checker SHALL 返回 `ConfigValidationIssue`,而不是直接抛出最终用户错误字符串 ### Requirement: Checker 接口定义 -系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义面向扩展的泛型 `CheckerDefinition`,包含 `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`,包含 `type`、`configKey`、Authoring/Normalized TypeBox 配置契约、启动期语义校验、`resolve`、`execute`、`serialize`、`buildDetail` 成员。泛型参数 SHALL 约束 `execute` 和 `serialize` 方法的 target 参数类型,使 checker 实现内部获得编译期类型安全。默认泛型参数 `= ResolvedTargetBase` 保证中间层(registry、engine、config-loader)无需指定泛型。 #### Scenario: 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 - **WHEN** 引擎调用 `checker.execute(target, ctx)` @@ -62,7 +66,11 @@ #### Scenario: 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 默认一致 - **WHEN** checker 定义 `type: "tcp"` @@ -125,6 +133,14 @@ - **WHEN** config-loader 校验配置文件 - **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 - **WHEN** config-loader 解析一个 type 为 "cmd" 的 target - **THEN** config-loader SHALL 调用 `checkerRegistry.get("cmd")` 获取对应 checker,并委托该 checker 执行语义校验和 resolve @@ -146,22 +162,22 @@ - **THEN** HttpChecker 的语义校验 SHALL 抛出校验错误,提示 URL 格式不合法 ### 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 - **WHEN** store 同步 targets 表 - **THEN** store SHALL 对每个 target 调用对应 checker 的 `serialize()` 方法获取 `{ target, config }` -#### Scenario: expect 持久化使用 rawExpect +#### Scenario: expect 持久化不依赖 rawExpect - **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 -每个 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 字段。 +### Requirement: Checker resolve 只接收已去糖配置 +每个 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 模型 -- **WHEN** config-loader 解析一个带 `expect.durationMs: 1000` 的 target -- **THEN** 对应 checker 的 resolved target SHALL 包含 Raw expect 中的 `durationMs: 1000`,并在 Resolved expect 中包含 `{equals: 1000}` 形式的运行期 matcher +#### Scenario: resolve 不再展开 Raw expect +- **WHEN** config-loader 解析一个带 `expect.durationMs: {equals: 1000}` 的 target +- **THEN** 对应 checker 的 resolved target SHALL 直接使用 Normalized expect 中的 `{equals: 1000}`,resolve 只负责默认值和运行期配置转换 #### Scenario: 中间层不感知 checker expect 字段 - **WHEN** 新增 checker 定义自己的 Raw/Resolved expect 字段 diff --git a/openspec/specs/config-variables/spec.md b/openspec/specs/config-variables/spec.md index e76436b..0ba4e8e 100644 --- a/openspec/specs/config-variables/spec.md +++ b/openspec/specs/config-variables/spec.md @@ -133,7 +133,7 @@ - **THEN** 系统 SHALL 将该字段替换为 string 类型的空字符串 `""`,MUST NOT 推断为 number 0 ### 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 嵌套对象中的变量替换 - **WHEN** target 配置 `http.headers.Authorization: "${token}"` 且 variables 中定义 `token: "Bearer abc"` @@ -179,6 +179,10 @@ - **WHEN** 配置文件声明顶层 `defaults` - **THEN** 系统 SHALL 在契约校验阶段拒绝该未知字段,而不是尝试变量替换 +#### Scenario: Normalized Config 移除 variables 段 +- **WHEN** Authoring Config 包含顶层 `variables` 段且变量替换成功 +- **THEN** Normalized Config SHALL 不包含顶层 `variables` 字段 + ### Requirement: 变量替换错误报告 变量替换阶段的错误 SHALL 作为 `ConfigValidationIssue` 输出,code 为 `unresolved-variable`。错误信息 SHALL 包含字段路径和变量名。对于 `targets[i]` 内的错误,错误信息还 SHALL 包含 target 索引、target id 和 target 展示名上下文。 @@ -199,7 +203,7 @@ - **THEN** 系统 SHALL 收集所有缺失变量错误后统一输出,而非遇到第一个就退出 ### Requirement: 变量替换执行时机 -变量替换 SHALL 在 YAML 解析之后、schema 契约校验(AJV)之前执行。替换完成后的配置对象 SHALL 传入后续校验流程。 +变量替换 SHALL 在 YAML 解析之后、Normalized schema 契约校验(AJV)之前执行。变量替换 SHALL 是 `normalizeAuthoringConfig()` 的一部分,替换完成后的配置对象 SHALL 继续执行 expect 简写展开并形成 Normalized Config。Normalized Config SHALL 传入后续契约校验和语义校验流程。 #### Scenario: target 替换后通过 schema 校验 - **WHEN** target 配置 `http.maxRedirects: "${MAX_REDIRECTS}"` 且环境变量 `MAX_REDIRECTS=5` @@ -215,4 +219,8 @@ #### Scenario: probes 替换后通过 schema 校验 - **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 }` diff --git a/openspec/specs/expect-rule-system/spec.md b/openspec/specs/expect-rule-system/spec.md index 73e1fca..23cc64e 100644 --- a/openspec/specs/expect-rule-system/spec.md +++ b/openspec/specs/expect-rule-system/spec.md @@ -7,7 +7,7 @@ ### 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 均通过。 -所有类型为 `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 匹配对象 - **WHEN** 实际值为 `{status: "ok", count: 1}` 且 matcher 为 `{equals: {status: "ok", count: 1}}` @@ -25,28 +25,28 @@ - **WHEN** 实际值为 `"healthy"` 且 matcher 为 `{contains: "health", regex: "^ready$"}` - **THEN** 系统 SHALL 在任一 matcher 不满足时判定该 matcher 对象不通过 -#### Scenario: 字符串原始值简写等价 equals -- **WHEN** expect 字段配置为 `finishReason: "stop"` 且实际值为 `"stop"` -- **THEN** 系统 SHALL 在 resolve 阶段将 `"stop"` 归一化为 `{equals: "stop"}` 并判定通过 +#### Scenario: 字符串原始值简写在 Normalized 阶段等价 equals +- **WHEN** Authoring Config 中 expect 字段配置为 `finishReason: "stop"` +- **THEN** Normalized Config SHALL 将 `"stop"` 归一化为 `{equals: "stop"}`,运行期实际值为 `"stop"` 时判定通过 -#### Scenario: 数字原始值简写等价 equals -- **WHEN** expect 字段配置为 `rowCount: 1` 且实际值为 `1` -- **THEN** 系统 SHALL 在 resolve 阶段将 `1` 归一化为 `{equals: 1}` 并判定通过 +#### Scenario: 数字原始值简写在 Normalized 阶段等价 equals +- **WHEN** Authoring Config 中 expect 字段配置为 `rowCount: 1` +- **THEN** Normalized Config SHALL 将 `1` 归一化为 `{equals: 1}`,运行期实际值为 `1` 时判定通过 -#### Scenario: 布尔原始值简写等价 equals -- **WHEN** expect 字段配置为 RawValueExpectation 类型且值为 `true`,实际值为 `true` -- **THEN** 系统 SHALL 在 resolve 阶段将 `true` 归一化为 `{equals: true}` 并判定通过 +#### Scenario: 布尔原始值简写在 Normalized 阶段等价 equals +- **WHEN** Authoring Config 中 RawValueExpectation 类型字段值为 `true` +- **THEN** Normalized Config SHALL 将 `true` 归一化为 `{equals: true}`,运行期实际值为 `true` 时判定通过 -#### Scenario: null 原始值简写等价 equals -- **WHEN** expect 字段配置为 RawValueExpectation 类型且值为 `null`,实际值为 `null` -- **THEN** 系统 SHALL 在 resolve 阶段将 `null` 归一化为 `{equals: null}` 并判定通过 +#### Scenario: null 原始值简写在 Normalized 阶段等价 equals +- **WHEN** Authoring Config 中 RawValueExpectation 类型字段值为 `null` +- **THEN** Normalized Config SHALL 将 `null` 归一化为 `{equals: null}`,运行期实际值为 `null` 时判定通过 #### Scenario: 原始值简写不匹配 - **WHEN** expect 字段配置为 `finishReason: "stop"` 且实际值为 `"error"` - **THEN** 系统 SHALL 判定不通过并生成 mismatch failure ### 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 对象被拒绝 - **WHEN** YAML 配置中任一 matcher 对象为空 `{}` @@ -64,17 +64,17 @@ - **WHEN** YAML 配置中任一 matcher 的 `exists` 或 `empty` 值不是布尔类型 - **THEN** 系统 SHALL 在启动期配置校验失败,提示该字段必须为布尔值 -#### Scenario: 字符串原始值校验通过 -- **WHEN** YAML 配置中 RawValueExpectation 字段值为字符串 `"stop"` -- **THEN** 系统 SHALL 接受该配置,但 SHALL NOT 在语义校验阶段修改输入对象 +#### Scenario: 字符串原始值在 Normalized schema 中被拒绝 +- **WHEN** Normalized schema 校验 RawValueExpectation 字段值为字符串 `"stop"` +- **THEN** schema 校验 SHALL 失败,因为 Normalized Config 只接受 `ValueMatcher` 对象 -#### Scenario: 数字原始值校验通过 -- **WHEN** YAML 配置中 RawValueExpectation 字段值为数字 `5000` -- **THEN** 系统 SHALL 接受该配置,但 SHALL NOT 在语义校验阶段修改输入对象 +#### Scenario: 数字原始值在 Normalized schema 中被拒绝 +- **WHEN** Normalized schema 校验 RawValueExpectation 字段值为数字 `5000` +- **THEN** schema 校验 SHALL 失败,因为 Normalized Config 只接受 `ValueMatcher` 对象 -#### Scenario: null 原始值校验通过 -- **WHEN** YAML 配置中 RawValueExpectation 字段值为 `null` -- **THEN** 系统 SHALL 接受该配置,但 SHALL NOT 在语义校验阶段修改输入对象 +#### Scenario: null 原始值在 Normalized schema 中被拒绝 +- **WHEN** Normalized schema 校验 RawValueExpectation 字段值为 `null` +- **THEN** schema 校验 SHALL 失败,因为 Normalized Config 只接受 `ValueMatcher` 对象 #### Scenario: 数组原始值被拒绝 - **WHEN** YAML 配置中 RawValueExpectation 字段值为数组 `[1, 2]` @@ -142,7 +142,7 @@ - **THEN** 系统 SHALL 在启动期配置校验失败,提示正则存在 ReDoS 风险 ### 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 - **WHEN** 内容字段配置 `[{contains: "ready"}, {regex: "listening on \\d+"}]` 且原始内容同时满足两条 expectation @@ -156,9 +156,9 @@ - **WHEN** YAML 中内容字段配置为 `{contains: "ok"}` 而不是数组 - **THEN** 系统 SHALL 在启动期配置校验失败,提示该内容字段必须为 expectation 数组 -#### Scenario: Resolved content expectation 使用 kind -- **WHEN** Raw 内容字段包含直接 matcher、json extractor、css extractor 和 xpath extractor -- **THEN** resolve 阶段 SHALL 分别生成 `kind="value"`、`kind="json"`、`kind="css"` 和 `kind="xpath"` 的 Resolved `ContentExpectation` +#### Scenario: Normalized content expectation 使用 kind +- **WHEN** Authoring 内容字段包含直接 matcher、json extractor、css extractor 和 xpath extractor +- **THEN** Normalized 阶段 SHALL 分别生成 `kind="value"`、`kind="json"`、`kind="css"` 和 `kind="xpath"` 的 `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 报错。 @@ -190,7 +190,7 @@ - **THEN** 系统 SHALL 直接在结构化值上使用深度相等比较 ### 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 数字比较 - **WHEN** 原始内容为 JSON 字符串 `{"count": 2}` 且 expectation 为 `{json: {path: "$.count", gte: 1}}` @@ -198,22 +198,22 @@ #### Scenario: json extractor 存在性默认语义 - **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 存在性默认语义 - **WHEN** 原始内容包含 `` 且 expectation 为 `{css: {selector: "meta[name=status]", attr: "content"}}` -- **THEN** resolve 阶段 SHALL 将该 expectation 的 matcher 物化为 `{exists: true}` 并在属性存在时判定通过 +- **THEN** Normalized 阶段 SHALL 将该 expectation 的 matcher 物化为 `{exists: true}` 并在属性存在时判定通过 #### Scenario: xpath 无匹配节点失败 - **WHEN** XML 内容中不存在 XPath 指向的节点,且 expectation 为 `{xpath: {path: "/root/status"}}` - **THEN** 系统 SHALL 判定该 expectation 不通过并生成 phase 对应内容字段的 mismatch failure ### Requirement: KeyedExpectations 键控断言数组 -系统 SHALL 提供共享 `KeyedExpectations` 表达键值型观测值断言。Raw `KeyedExpectations` SHALL 为动态键对象,每个键对应的值 MUST 为 `RawValueExpectation`;数组和对象简写 MUST 被拒绝,数组或对象 equals 匹配 MUST 显式写为 `{equals: }`。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: }`。Normalized `KeyedExpectations` SHALL 为有序数组,每个元素包含原始 key 和已归一化的 `ValueExpectation` matcher。调用方 MAY 指定 key 规范化策略;HTTP 与 LLM headers MUST 使用大小写不敏感的 key 匹配,并 MUST 在启动期校验或 Normalized 阶段拒绝归一化后重复的 header key。DB rows 不做 key 归一化,按查询结果列名大小写敏感匹配。 #### Scenario: headers 字面量快捷写法 - **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 写法 - **WHEN** 响应 headers 中 `content-type` 为 `application/json; charset=utf-8`,且配置为 `headers: {Content-Type: {contains: "application/json"}}` diff --git a/openspec/specs/probe-config/spec.md b/openspec/specs/probe-config/spec.md index 29dfd77..473a371 100644 --- a/openspec/specs/probe-config/spec.md +++ b/openspec/specs/probe-config/spec.md @@ -98,15 +98,11 @@ - **THEN** 系统 SHALL 以错误退出并提示文件不存在 ### 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 保持一致,用于用户配置引用和编辑器提示。 - -除 `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 分支接受数组或对象简写。 +契约校验、normalizer 和语义 validator SHALL 统一产出 `ConfigValidationIssue`,最终由配置加载流程统一渲染为中文错误信息。除 `headers`、`env`、Authoring `variables` 等明确声明为动态键值表的对象外,配置中的未知字段 SHALL 导致启动期配置错误。系统 MUST NOT 静默忽略未知字段。 #### Scenario: target 缺少必填字段 - **WHEN** YAML 中某个 target 缺少 id 或 type 字段 @@ -145,7 +141,7 @@ - **THEN** 系统 SHALL 以错误退出并提示格式错误 #### Scenario: maxConcurrentChecks 非法 -- **WHEN** probes.execution.maxConcurrentChecks 不是正整数 +- **WHEN** Normalized Config 中 probes.execution.maxConcurrentChecks 不是正整数 - **THEN** 系统 SHALL 以错误退出并提示 probes.execution.maxConcurrentChecks 格式错误 #### Scenario: interval 或 timeout 解析结果非法 @@ -161,7 +157,7 @@ - **THEN** 系统 SHALL 以错误退出并提示必须为非负安全整数字节数 #### 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 不合法 #### Scenario: HTTP method 小写非法 @@ -177,7 +173,7 @@ - **THEN** 系统 SHALL 以错误退出,提示该 target 的 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 必须为非负整数 #### Scenario: ignoreSSL 类型非法 @@ -205,28 +201,28 @@ - **THEN** 系统 SHALL 以错误退出,提示该 target 的 status 数字不合法 #### Scenario: durationMs matcher 非法 -- **WHEN** YAML 中某个 target 的 `expect.durationMs` 不是合法 `RawValueExpectation` +- **WHEN** Normalized Config 中某个 target 的 `expect.durationMs` 不是合法 `ValueMatcher` 对象 - **THEN** 系统 SHALL 以错误退出,提示该 target 的 expect.durationMs 格式错误 -#### Scenario: durationMs 原始值简写合法 -- **WHEN** YAML 中某个 target 配置 `expect.durationMs: 5000` -- **THEN** 语义校验 SHALL 接受该 Raw primitive 简写且 MUST NOT 修改输入;checker resolve 阶段 SHALL 将其解析为 `{equals: 5000}` +#### Scenario: durationMs 原始值简写在 Authoring schema 合法 +- **WHEN** 使用 Authoring schema 校验配置文件中 `expect.durationMs: 5000` +- **THEN** JSON Schema 校验 SHALL 通过,因为 Authoring schema 接受 primitive 简写 -#### Scenario: ValueMatcher 字段字符串简写合法 -- **WHEN** YAML 中某个 target 配置 `expect.finishReason: "stop"` -- **THEN** 语义校验 SHALL 接受该 Raw primitive 简写且 MUST NOT 修改输入;checker resolve 阶段 SHALL 将其解析为 `{equals: "stop"}` +#### Scenario: durationMs 原始值简写在 Normalized schema 非法 +- **WHEN** 使用 Normalized schema 校验配置对象中 `expect.durationMs: 5000` +- **THEN** JSON Schema 校验 SHALL 失败,因为 Normalized schema 只接受 `ValueMatcher` 对象 -#### Scenario: ValueMatcher 字段 null 简写合法 -- **WHEN** YAML 中某个 target 配置 ValueMatcher 字段值为 `null` -- **THEN** 语义校验 SHALL 接受该 Raw primitive 简写且 MUST NOT 修改输入;checker resolve 阶段 SHALL 将其解析为 `{equals: null}` +#### Scenario: 变量引用在 Authoring schema 合法 +- **WHEN** 使用 Authoring schema 校验配置文件中 `server.listen.port: "${PORT|3000}"` +- **THEN** JSON Schema 校验 SHALL 通过,因为 Authoring schema 面向用户可书写 YAML -#### Scenario: ValueMatcher 字段数组简写非法 -- **WHEN** YAML 中某个 target 配置 ValueMatcher 字段值为数组 `[1, 2]` -- **THEN** 系统 SHALL 以错误退出,提示该字段必须为 primitive 原始值或 matcher 对象;如需数组 equals 匹配应写成 `{equals: [1, 2]}` +#### Scenario: 变量引用在 Normalized schema 非法 +- **WHEN** 使用 Normalized schema 校验配置对象中 `server.listen.port: "${PORT|3000}"` +- **THEN** JSON Schema 校验 SHALL 失败,因为 Normalized schema 只接受变量替换后的 integer -#### Scenario: ValueMatcher 字段对象简写非法 -- **WHEN** YAML 中某个 target 配置 ValueMatcher 字段值为对象 `{foo: "bar"}`,且 `foo` 不是合法 matcher 字段 -- **THEN** 系统 SHALL 以错误退出,提示 `foo` 是未知 matcher;如需对象 equals 匹配应写成 `{equals: {foo: "bar"}}` +#### Scenario: Authoring schema 对 integer/boolean/enum 字段接受变量引用 +- **WHEN** 使用 Authoring schema 校验配置文件中 `http.maxRedirects: "${MAX_REDIRECTS|5}"` 或 `http.ignoreSSL: "${IGNORE_SSL|false}"` 或 `llm.provider: "${PROVIDER|openai}"` +- **THEN** JSON Schema 校验 SHALL 通过,因为 Authoring schema 对支持变量替换的 integer/boolean/enum/pattern-string 字段使用 `anyOf: [originalType, {type: "string", pattern: "^\\$\\{[^}]+\\}$"}]` 额外接受完整变量引用字符串 #### Scenario: icmp target 缺少 host - **WHEN** YAML 中某个 target 配置 `type: icmp` 但缺少 `icmp.host` @@ -292,37 +288,25 @@ - **WHEN** 系统执行 JSON Schema 契约校验 - **THEN** 系统 MUST NOT 通过契约校验器强制转换类型、注入默认值或删除未知字段 +#### Scenario: 变量替换后字段超长由 Normalized schema 的 maxLength 校验拦截 +- **WHEN** Authoring Config 中 target 的 `description` 通过 `${...}` 变量替换后超过 500 个字符 +- **THEN** Normalized schema SHALL 在 AJV 校验阶段以错误退出,提示 description 字段长度错误 + #### Scenario: 配置生命周期分离 - **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 -- **WHEN** 契约校验、语义 validator 或变量替换阶段发现非法配置 +- **WHEN** 契约校验、normalizer、语义 validator 或变量替换阶段发现非法配置 - **THEN** 系统 SHALL 先生成包含 code、path、message 和可选 targetName 的结构化 `ConfigValidationIssue`,再统一渲染为中文错误信息 #### Scenario: 导出配置 JSON Schema - **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` - -#### 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: ...}` 下 +- **THEN** 根目录 SHALL 存在 draft-07 `probe-config.schema.json`,且其内容 SHALL 与当前 Authoring fragments 和已注册 checker Authoring fragments 组装出的完整 schema 一致(包含 variables 段和 target 的 id/name/description 字段,且不包含顶层 defaults) 系统 SHALL 支持使用单位字符串配置读取上限,单位包括 `B`、`KB`、`MB` 和 `GB`。 #### Scenario: 解析 MB @@ -352,21 +336,21 @@ - **THEN** 系统 SHALL 调用 `Bun.YAML.parse()` 将内容解析为配置对象 ### 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`,外层数组按行索引,内层每个元素表达该行的列值断言)。 +系统 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 配置 - **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 配置 - **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 配置 - **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 配置 - **WHEN** YAML 配置文件中 tcp target 的 expect 包含 connected、banner expectation 数组和 durationMs matcher @@ -386,11 +370,11 @@ #### Scenario: 解析有序 ContentExpectations 数组 - **WHEN** YAML 中任一内容类 expect 配置 contains、json、regex 三个数组项 -- **THEN** 系统 SHALL 在 Raw expect 中保留数组顺序,并在 Resolved expect 中保留执行顺序,供执行阶段按配置顺序快速失败 +- **THEN** 系统 SHALL 在 Normalized expect 中保留执行顺序,供执行阶段按配置顺序快速失败 #### Scenario: 不配置 HTTP 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 范围模式 - **WHEN** HTTP target 配置 `expect.status: ["2xx"]` @@ -398,15 +382,15 @@ #### Scenario: 不配置 cmd 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 - **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` -- **THEN** 语义校验 SHALL 接受该 Raw primitive 简写且 MUST NOT 将 Raw 输入原地修改为 `{equals: 1000}` +- **THEN** Normalized Config SHALL 包含 `expect.durationMs: {equals: 1000}`,ResolvedTarget MUST NOT 携带 `rawExpect` #### Scenario: 旧 maxDurationMs 字段不再支持 - **WHEN** YAML 中任一 target 配置 `expect.maxDurationMs` @@ -426,16 +410,12 @@ #### Scenario: ContentExpectations 字段必须为数组 - **WHEN** YAML 中任一内容类 expect 字段配置为非数组 -- **THEN** 系统 SHALL 在启动期配置校验失败,提示该字段必须为 expectation 数组 +- **THEN** 系统 SHALL 在启动期配置校验失败,提示该内容字段必须为 expectation 数组 #### Scenario: regex 字段非法 - **WHEN** YAML 中任一 `regex` 不是字符串、不是可编译正则或存在 ReDoS 风险 - **THEN** 系统 SHALL 在启动期配置校验失败,提示对应 regex 不合法 -#### Scenario: Store 持久化 Raw expect -- **WHEN** 系统同步已解析 target 到 targets 表 -- **THEN** `targets.expect` SHALL 存储变量替换后的 Raw expect JSON,而不是包含 `kind` 或 resolved matcher 的运行期执行计划 - ### Requirement: 数据保留配置字段 配置 schema 的 `server.storage` 段 SHALL 支持 `retention` 字段,类型为字符串,格式为 `<数字><单位>`(单位:`d` 天、`h` 小时、`m` 分钟),用于指定历史数据保留时长。 @@ -510,16 +490,20 @@ - **THEN** 系统 SHALL 在契约校验阶段以错误退出,提示 description 字段长度错误 ### 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 - **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 - **WHEN** 系统导出 `probe-config.schema.json` - **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 配置校验 系统 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 输入。 diff --git a/openspec/specs/probe-data-store/spec.md b/openspec/specs/probe-data-store/spec.md index bce5a23..01cb4ee 100644 --- a/openspec/specs/probe-data-store/spec.md +++ b/openspec/specs/probe-data-store/spec.md @@ -30,11 +30,11 @@ - **THEN** check_results 表的外键约束 SHALL 使用 `ON DELETE RESTRICT`,确保删除 target 时数据库层面阻止操作而非级联删除关联记录 ### 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: 首次同步目标 - **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: 配置变更后重新同步 - **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启 @@ -64,9 +64,9 @@ - **WHEN** YAML target 配置 `description: null` - **THEN** targets 表 SHALL 将该目标的 description 存储为 NULL -#### Scenario: expect 列保存 Raw expect +#### Scenario: expect 列不保存原始 Authoring 写法 - **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 - **WHEN** target 未配置任何 expect diff --git a/probe-config.schema.json b/probe-config.schema.json index 4fae3fd..d3131ea 100644 --- a/probe-config.schema.json +++ b/probe-config.schema.json @@ -14,8 +14,16 @@ "type": "object", "properties": { "maxConcurrentChecks": { - "minimum": 1, - "type": "integer" + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] } } } @@ -33,9 +41,17 @@ "type": "string" }, "port": { - "maximum": 65535, - "minimum": 0, - "type": "integer" + "anyOf": [ + { + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] } } }, @@ -50,27 +66,35 @@ "level": { "anyOf": [ { - "const": "trace", - "type": "string" + "anyOf": [ + { + "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", - "type": "string" - }, - { - "const": "info", - "type": "string" - }, - { - "const": "warn", - "type": "string" - }, - { - "const": "error", - "type": "string" - }, - { - "const": "fatal", + "pattern": "^\\$\\{[^}]+\\}$", "type": "string" } ] @@ -84,27 +108,35 @@ "level": { "anyOf": [ { - "const": "trace", - "type": "string" + "anyOf": [ + { + "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", - "type": "string" - }, - { - "const": "info", - "type": "string" - }, - { - "const": "warn", - "type": "string" - }, - { - "const": "error", - "type": "string" - }, - { - "const": "fatal", + "pattern": "^\\$\\{[^}]+\\}$", "type": "string" } ] @@ -120,22 +152,38 @@ "frequency": { "anyOf": [ { - "const": "hourly", - "type": "string" + "anyOf": [ + { + "const": "hourly", + "type": "string" + }, + { + "const": "daily", + "type": "string" + }, + { + "const": "weekly", + "type": "string" + } + ] }, { - "const": "daily", - "type": "string" - }, - { - "const": "weekly", + "pattern": "^\\$\\{[^}]+\\}$", "type": "string" } ] }, "maxFiles": { - "minimum": 1, - "type": "integer" + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] }, "size": { "anyOf": [ @@ -155,27 +203,35 @@ "level": { "anyOf": [ { - "const": "trace", - "type": "string" + "anyOf": [ + { + "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", - "type": "string" - }, - { - "const": "info", - "type": "string" - }, - { - "const": "warn", - "type": "string" - }, - { - "const": "error", - "type": "string" - }, - { - "const": "fatal", + "pattern": "^\\$\\{[^}]+\\}$", "type": "string" } ] @@ -216,8 +272,16 @@ "type": "null" }, { - "maxLength": 500, - "type": "string" + "anyOf": [ + { + "maxLength": 500, + "type": "string" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] } ] }, @@ -656,9 +720,17 @@ "type": "null" }, { - "maxLength": 30, - "minLength": 1, - "type": "string" + "anyOf": [ + { + "maxLength": 30, + "minLength": 1, + "type": "string" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] } ] }, @@ -681,12 +753,28 @@ }, "headers": { "additionalProperties": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] }, "type": "object" }, "ignoreSSL": { - "type": "boolean" + "anyOf": [ + { + "type": "boolean" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] }, "maxBodyBytes": { "anyOf": [ @@ -700,37 +788,53 @@ ] }, "maxRedirects": { - "minimum": 0, - "type": "integer" + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] }, "method": { "anyOf": [ { - "const": "DELETE", - "type": "string" + "anyOf": [ + { + "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", - "type": "string" - }, - { - "const": "HEAD", - "type": "string" - }, - { - "const": "OPTIONS", - "type": "string" - }, - { - "const": "PATCH", - "type": "string" - }, - { - "const": "POST", - "type": "string" - }, - { - "const": "PUT", + "pattern": "^\\$\\{[^}]+\\}$", "type": "string" } ] @@ -758,8 +862,16 @@ "type": "null" }, { - "maxLength": 500, - "type": "string" + "anyOf": [ + { + "maxLength": 500, + "type": "string" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] } ] }, @@ -1353,9 +1465,17 @@ "type": "null" }, { - "maxLength": 30, - "minLength": 1, - "type": "string" + "anyOf": [ + { + "maxLength": 30, + "minLength": 1, + "type": "string" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] } ] }, @@ -1422,8 +1542,16 @@ "type": "null" }, { - "maxLength": 500, - "type": "string" + "anyOf": [ + { + "maxLength": 500, + "type": "string" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] } ] }, @@ -1924,9 +2052,17 @@ "type": "null" }, { - "maxLength": 30, - "minLength": 1, - "type": "string" + "anyOf": [ + { + "maxLength": 30, + "minLength": 1, + "type": "string" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] } ] }, @@ -1971,8 +2107,16 @@ "type": "null" }, { - "maxLength": 500, - "type": "string" + "anyOf": [ + { + "maxLength": 500, + "type": "string" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] } ] }, @@ -2224,7 +2368,15 @@ } }, "connected": { - "type": "boolean" + "anyOf": [ + { + "type": "boolean" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] }, "durationMs": { "anyOf": [ @@ -2320,9 +2472,17 @@ "type": "null" }, { - "maxLength": 30, - "minLength": 1, - "type": "string" + "anyOf": [ + { + "maxLength": 30, + "minLength": 1, + "type": "string" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] } ] }, @@ -2361,12 +2521,28 @@ ] }, "port": { - "maximum": 65535, - "minimum": 1, - "type": "integer" + "anyOf": [ + { + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] }, "readBanner": { - "type": "boolean" + "anyOf": [ + { + "type": "boolean" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] } } } @@ -2387,8 +2563,16 @@ "type": "null" }, { - "maxLength": 500, - "type": "string" + "anyOf": [ + { + "maxLength": 500, + "type": "string" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] } ] }, @@ -2397,7 +2581,15 @@ "type": "object", "properties": { "alive": { - "type": "boolean" + "anyOf": [ + { + "type": "boolean" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] }, "avgLatencyMs": { "anyOf": [ @@ -2718,9 +2910,17 @@ "type": "null" }, { - "maxLength": 30, - "minLength": 1, - "type": "string" + "anyOf": [ + { + "maxLength": 30, + "minLength": 1, + "type": "string" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] } ] }, @@ -2739,18 +2939,34 @@ ], "properties": { "count": { - "maximum": 100, - "minimum": 1, - "type": "integer" + "anyOf": [ + { + "maximum": 100, + "minimum": 1, + "type": "integer" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] }, "host": { "minLength": 1, "type": "string" }, "packetSize": { - "maximum": 65500, - "minimum": 1, - "type": "integer" + "anyOf": [ + { + "maximum": 65500, + "minimum": 1, + "type": "integer" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] } } } @@ -2771,8 +2987,16 @@ "type": "null" }, { - "maxLength": 500, - "type": "string" + "anyOf": [ + { + "maxLength": 500, + "type": "string" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] } ] }, @@ -2856,7 +3080,15 @@ ] }, "responded": { - "type": "boolean" + "anyOf": [ + { + "type": "boolean" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] }, "response": { "type": "array", @@ -3345,9 +3577,17 @@ "type": "null" }, { - "maxLength": 30, - "minLength": 1, - "type": "string" + "anyOf": [ + { + "maxLength": 30, + "minLength": 1, + "type": "string" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] } ] }, @@ -3369,15 +3609,23 @@ "encoding": { "anyOf": [ { - "const": "text", - "type": "string" + "anyOf": [ + { + "const": "text", + "type": "string" + }, + { + "const": "hex", + "type": "string" + }, + { + "const": "base64", + "type": "string" + } + ] }, { - "const": "hex", - "type": "string" - }, - { - "const": "base64", + "pattern": "^\\$\\{[^}]+\\}$", "type": "string" } ] @@ -3401,22 +3649,38 @@ "type": "string" }, "port": { - "maximum": 65535, - "minimum": 1, - "type": "integer" + "anyOf": [ + { + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] }, "responseEncoding": { "anyOf": [ { - "const": "text", - "type": "string" + "anyOf": [ + { + "const": "text", + "type": "string" + }, + { + "const": "hex", + "type": "string" + }, + { + "const": "base64", + "type": "string" + } + ] }, { - "const": "hex", - "type": "string" - }, - { - "const": "base64", + "pattern": "^\\$\\{[^}]+\\}$", "type": "string" } ] @@ -3440,8 +3704,16 @@ "type": "null" }, { - "maxLength": 500, - "type": "string" + "anyOf": [ + { + "maxLength": 500, + "type": "string" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] } ] }, @@ -4016,7 +4288,15 @@ "type": "object", "properties": { "completed": { - "type": "boolean" + "anyOf": [ + { + "type": "boolean" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] }, "firstTokenMs": { "anyOf": [ @@ -4345,9 +4625,17 @@ "type": "null" }, { - "maxLength": 30, - "minLength": 1, - "type": "string" + "anyOf": [ + { + "maxLength": 30, + "minLength": 1, + "type": "string" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] } ] }, @@ -4373,12 +4661,28 @@ }, "headers": { "additionalProperties": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] }, "type": "object" }, "ignoreSSL": { - "type": "boolean" + "anyOf": [ + { + "type": "boolean" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] }, "key": { "type": "string" @@ -4386,11 +4690,19 @@ "mode": { "anyOf": [ { - "const": "http", - "type": "string" + "anyOf": [ + { + "const": "http", + "type": "string" + }, + { + "const": "stream", + "type": "string" + } + ] }, { - "const": "stream", + "pattern": "^\\$\\{[^}]+\\}$", "type": "string" } ] @@ -4407,8 +4719,16 @@ "type": "number" }, "maxOutputTokens": { - "minimum": 1, - "type": "integer" + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] }, "presencePenalty": { "type": "number" @@ -4440,15 +4760,23 @@ "provider": { "anyOf": [ { - "const": "openai", - "type": "string" + "anyOf": [ + { + "const": "openai", + "type": "string" + }, + { + "const": "openai-responses", + "type": "string" + }, + { + "const": "anthropic", + "type": "string" + } + ] }, { - "const": "openai-responses", - "type": "string" - }, - { - "const": "anthropic", + "pattern": "^\\$\\{[^}]+\\}$", "type": "string" } ] diff --git a/probes.example.yaml b/probes.example.yaml index c17f4c4..98019df 100644 --- a/probes.example.yaml +++ b/probes.example.yaml @@ -3,7 +3,7 @@ server: listen: host: "127.0.0.1" - port: 3000 + port: "${server_port|3000}" storage: dataDir: "/tmp/probes_data" # logging: @@ -20,12 +20,14 @@ server: probes: execution: - maxConcurrentChecks: 20 + maxConcurrentChecks: "${max_checks|20}" variables: env_name: "演示" httpbin_base: "https://httpbin.org" api_token: "Bearer demo-token" + max_checks: 20 + server_port: 3000 sqlite_url: "sqlite://:memory:" targets: diff --git a/src/server/checker/config-loader.ts b/src/server/checker/config-loader.ts index 931f0db..81c23aa 100644 --- a/src/server/checker/config-loader.ts +++ b/src/server/checker/config-loader.ts @@ -13,12 +13,12 @@ import type { ServerStorageConfig, } from "./types"; +import { normalizeAuthoringConfig } from "./normalizer"; import { checkerRegistry } from "./runner"; 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 { parseDuration, parseSize } from "./utils"; -import { resolveVariables } from "./variables"; const DEFAULT_HOST = "127.0.0.1"; const DEFAULT_PORT = 3000; @@ -60,17 +60,17 @@ export async function loadConfig(configPath: string): Promise { throw new Error("配置文件内容为空或格式无效"); } - const variableResult = resolveVariables(parsed); - if (variableResult.issues.length > 0) { - throwConfigIssues(dedupeIssues(variableResult.issues)); + const normalizeResult = normalizeAuthoringConfig(parsed); + if (normalizeResult.issues.length > 0) { + throwConfigIssues(dedupeIssues(normalizeResult.issues)); } - const resolvedVariablesConfig = variableResult.config; - const contractResult = validateProbeConfigContract(resolvedVariablesConfig, checkerRegistry); - if (contractResult.config === null && !canRunSemanticValidation(resolvedVariablesConfig)) { + const normalizedConfig = normalizeResult.config; + const contractResult = validateProbeConfigContract(normalizedConfig, checkerRegistry); + if (contractResult.config === null && !canRunSemanticValidation(normalizedConfig)) { throwConfigIssues(contractResult.issues); } - const semanticInput = (contractResult.config ?? resolvedVariablesConfig) as RawProbeConfig; + const semanticInput = (contractResult.config ?? normalizedConfig) as NormalizedProbeConfig; const validationIssues = validateConfig(semanticInput); const allIssues = [...contractResult.issues, ...validationIssues]; @@ -208,7 +208,7 @@ function resolveTarget( return result; } -function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] { +function validateConfig(config: NormalizedProbeConfig): ConfigValidationIssue[] { const issues: ConfigValidationIssue[] = []; if (!Array.isArray(config.targets) || config.targets.length === 0) { issues.push(issue("required", "targets", "配置文件必须包含至少一个 target")); diff --git a/src/server/checker/expect/content.ts b/src/server/checker/expect/content.ts index 9276cab..764e032 100644 --- a/src/server/checker/expect/content.ts +++ b/src/server/checker/expect/content.ts @@ -22,7 +22,7 @@ import type { } from "./types"; 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"; type ParsedJsonResult = { error: string; ok: false } | { ok: true; value: unknown }; @@ -238,7 +238,7 @@ function resolveContentExpectation(raw: RawContentExpectation): ContentExpectati } const record = raw as Record; - if (CONTENT_EXTRACTOR_KEY_SET.has("json") && isPlainObject(record["json"])) { + if (isPlainObject(record["json"])) { const json = record["json"] as RawContentJsonExpectation; return { kind: "json", diff --git a/src/server/checker/expect/validate.ts b/src/server/checker/expect/validate.ts index e8ab034..a498a15 100644 --- a/src/server/checker/expect/validate.ts +++ b/src/server/checker/expect/validate.ts @@ -58,6 +58,7 @@ export function validateRawKeyedExpectations( targetName?: string, options?: { caseInsensitive?: boolean }, ): ConfigValidationIssue[] { + if (Array.isArray(value)) return validateNormalizedKeyedExpectations(value, path, targetName, options); if (!isPlainRecord(value)) return [issue("invalid-type", path, "必须为对象", targetName)]; const issues: ConfigValidationIssue[] = []; @@ -196,12 +197,76 @@ function validateMatcherValue(key: string, value: unknown, path: string, targetN } } +function validateNormalizedContentExpectation( + expectation: Record, + 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(); + 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( expectation: unknown, path: string, targetName?: string, ): ConfigValidationIssue[] { if (!isPlainRecord(expectation)) return [issue("invalid-type", path, "必须为对象", targetName)]; + if (isString(expectation["kind"])) return validateNormalizedContentExpectation(expectation, path, targetName); const issues: ConfigValidationIssue[] = []; const extractors = Object.keys(expectation).filter((key) => CONTENT_EXTRACTOR_KEY_SET.has(key)); diff --git a/src/server/checker/normalizer.ts b/src/server/checker/normalizer.ts new file mode 100644 index 0000000..cffa841 --- /dev/null +++ b/src/server/checker/normalizer.ts @@ -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; + +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) }; + 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 }; diff --git a/src/server/checker/runner/cmd/execute.ts b/src/server/checker/runner/cmd/execute.ts index 7ca339f..8db9cbd 100644 --- a/src/server/checker/runner/cmd/execute.ts +++ b/src/server/checker/runner/cmd/execute.ts @@ -3,16 +3,11 @@ import { resolve } from "node:path"; import type { CheckResult, RawTargetConfig } from "../../types"; import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types"; -import type { - CommandTargetConfig, - RawCommandExpectConfig, - ResolvedCommandExpectConfig, - ResolvedCommandTarget, -} from "./types"; +import type { CommandTargetConfig, ResolvedCommandExpectConfig, ResolvedCommandTarget } from "./types"; -import { checkContentExpectations, resolveContentExpectations } from "../../expect/content"; +import { checkContentExpectations } from "../../expect/content"; import { errorFailure } from "../../expect/failure"; -import { checkValueExpectation, resolveValueExpectation } from "../../expect/value"; +import { checkValueExpectation } from "../../expect/value"; import { parseSize } from "../../utils"; import { checkExitCode } from "./expect"; import { commandCheckerSchemas } from "./schema"; @@ -217,13 +212,11 @@ export class CommandChecker implements CheckerDefinition const env = { ...process.env, ...(t.cmd.env ?? {}) } as Record; - const rawExpect = target.expect as RawCommandExpectConfig | undefined; - const resolvedExpect: ResolvedCommandExpectConfig = rawExpect + const expect = target.expect as ResolvedCommandExpectConfig | undefined; + const resolvedExpect: ResolvedCommandExpectConfig = expect ? { - durationMs: resolveValueExpectation(rawExpect.durationMs), - exitCode: rawExpect.exitCode ?? [0], - stderr: resolveContentExpectations(rawExpect.stderr), - stdout: resolveContentExpectations(rawExpect.stdout), + ...expect, + exitCode: expect.exitCode ?? [0], } : { exitCode: [0] }; @@ -241,7 +234,6 @@ export class CommandChecker implements CheckerDefinition id: t.id, intervalMs: context.defaultIntervalMs, name: t.name ?? null, - rawExpect, timeoutMs: context.defaultTimeoutMs, type: "cmd", } satisfies ResolvedCommandTarget; diff --git a/src/server/checker/runner/cmd/schema.ts b/src/server/checker/runner/cmd/schema.ts index cf5f550..4544715 100644 --- a/src/server/checker/runner/cmd/schema.ts +++ b/src/server/checker/runner/cmd/schema.ts @@ -3,14 +3,27 @@ import { Type } from "@sinclair/typebox"; import type { CheckerSchemas } from "../types"; import { - createRawContentExpectationsSchema, - createRawValueExpectationSchema, + createAuthoringContentExpectationsSchema, + createAuthoringValueExpectationSchema, + createNormalizedContentExpectationsSchema, + createNormalizedValueExpectationSchema, sizeSchema, stringMapSchema, } from "../../schema/fragments"; 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())), cwd: Type.Optional(Type.String()), @@ -19,14 +32,23 @@ export const commandCheckerSchemas: CheckerSchemas = { maxOutputBytes: Type.Optional(sizeSchema), }, { 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())), - stderr: Type.Optional(createRawContentExpectationsSchema()), - stdout: Type.Optional(createRawContentExpectationsSchema()), + stderr: Type.Optional( + kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(), + ), + stdout: Type.Optional( + kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(), + ), }, { additionalProperties: false }, - ), -}; + ); +} diff --git a/src/server/checker/runner/cmd/types.ts b/src/server/checker/runner/cmd/types.ts index 716e4e8..cf28273 100644 --- a/src/server/checker/runner/cmd/types.ts +++ b/src/server/checker/runner/cmd/types.ts @@ -42,7 +42,6 @@ export interface ResolvedCommandTarget extends ResolvedTargetBase { group: string; intervalMs: number; name: null | string; - rawExpect?: RawCommandExpectConfig; timeoutMs: number; type: "cmd"; } diff --git a/src/server/checker/runner/db/execute.ts b/src/server/checker/runner/db/execute.ts index 4bb9a21..10420cc 100644 --- a/src/server/checker/runner/db/execute.ts +++ b/src/server/checker/runner/db/execute.ts @@ -3,12 +3,11 @@ import { isError } from "es-toolkit"; import type { CheckResult, RawTargetConfig } 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 { resolveKeyedExpectations } from "../../expect/keyed"; -import { checkValueExpectation, resolveValueExpectation } from "../../expect/value"; +import { checkValueExpectation } from "../../expect/value"; import { checkRowCount, checkRows } from "./expect"; import { dbCheckerSchemas } from "./schema"; import { validateDbConfig } from "./validate"; @@ -227,15 +226,7 @@ export class DbChecker implements CheckerDefinition { resolve(target: RawTargetConfig, context: ResolveContext): ResolvedDbTarget { const t = target as RawTargetConfig & { db: DbTargetConfig; type: "db" }; - const rawExpect = target.expect as RawDbExpectConfig | undefined; - const resolvedExpect: ResolvedDbExpectConfig | undefined = rawExpect - ? { - durationMs: resolveValueExpectation(rawExpect.durationMs), - result: resolveContentExpectations(rawExpect.result), - rowCount: resolveValueExpectation(rawExpect.rowCount), - rows: rawExpect.rows?.map((r) => resolveKeyedExpectations(r)!), - } - : undefined; + const resolvedExpect = target.expect as ResolvedDbExpectConfig | undefined; return { db: { @@ -248,7 +239,6 @@ export class DbChecker implements CheckerDefinition { id: t.id, intervalMs: context.defaultIntervalMs, name: t.name ?? null, - rawExpect, timeoutMs: context.defaultTimeoutMs, type: "db", } satisfies ResolvedDbTarget; diff --git a/src/server/checker/runner/db/schema.ts b/src/server/checker/runner/db/schema.ts index 7e7b6f9..3adf33a 100644 --- a/src/server/checker/runner/db/schema.ts +++ b/src/server/checker/runner/db/schema.ts @@ -3,13 +3,27 @@ import { Type } from "@sinclair/typebox"; import type { CheckerSchemas } from "../types"; import { - createRawContentExpectationsSchema, - createRawKeyedExpectationsSchema, - createRawValueExpectationSchema, + createAuthoringContentExpectationsSchema, + createAuthoringKeyedExpectationsSchema, + createAuthoringValueExpectationSchema, + createNormalizedContentExpectationsSchema, + createNormalizedKeyedExpectationsSchema, + createNormalizedValueExpectationSchema, } from "../../schema/fragments"; 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( Type.String({ @@ -19,14 +33,27 @@ export const dbCheckerSchemas: CheckerSchemas = { url: Type.String({ minLength: 1 }), }, { additionalProperties: false }, - ), - expect: Type.Object( + ); +} + +function createDbExpectSchema(kind: "authoring" | "normalized") { + return Type.Object( { - durationMs: Type.Optional(createRawValueExpectationSchema()), - result: Type.Optional(createRawContentExpectationsSchema()), - rowCount: Type.Optional(createRawValueExpectationSchema()), - rows: Type.Optional(Type.Array(createRawKeyedExpectationsSchema())), + durationMs: Type.Optional( + kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(), + ), + 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 }, - ), -}; + ); +} diff --git a/src/server/checker/runner/db/types.ts b/src/server/checker/runner/db/types.ts index 43aa88e..43a4147 100644 --- a/src/server/checker/runner/db/types.ts +++ b/src/server/checker/runner/db/types.ts @@ -38,7 +38,6 @@ export interface ResolvedDbTarget extends ResolvedTargetBase { group: string; intervalMs: number; name: null | string; - rawExpect?: RawDbExpectConfig; timeoutMs: number; type: "db"; } diff --git a/src/server/checker/runner/db/validate.ts b/src/server/checker/runner/db/validate.ts index 1cdfadc..a0ab71c 100644 --- a/src/server/checker/runner/db/validate.ts +++ b/src/server/checker/runner/db/validate.ts @@ -27,12 +27,7 @@ export function validateDbConfig(input: CheckerValidationInput): ConfigValidatio function collectRowExpects(rows: unknown[], path: string, targetName?: string): ConfigValidationIssue[] { const issues: ConfigValidationIssue[] = []; for (let i = 0; i < rows.length; i++) { - const row = rows[i]!; - if (!isPlainRecord(row)) { - issues.push(issue("invalid-type", `${path}[${i}]`, "必须为对象", targetName)); - continue; - } - issues.push(...validateRawKeyedExpectations(row, `${path}[${i}]`, targetName)); + issues.push(...validateRawKeyedExpectations(rows[i], `${path}[${i}]`, targetName)); } return issues; } diff --git a/src/server/checker/runner/http/execute.ts b/src/server/checker/runner/http/execute.ts index eb691af..2c9b182 100644 --- a/src/server/checker/runner/http/execute.ts +++ b/src/server/checker/runner/http/execute.ts @@ -2,14 +2,13 @@ import { isError } from "es-toolkit"; import type { CheckResult, RawTargetConfig } 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 { checkHeaderExpectations } from "../../expect/headers"; -import { resolveKeyedExpectations } from "../../expect/keyed"; import { checkStatusCode } from "../../expect/status"; -import { checkValueExpectation, displayValueExpectation, resolveValueExpectation } from "../../expect/value"; +import { checkValueExpectation, displayValueExpectation } from "../../expect/value"; import { parseSize } from "../../utils"; import { httpCheckerSchemas } from "./schema"; import { validateHttpConfig } from "./validate"; @@ -179,13 +178,11 @@ export class HttpChecker implements CheckerDefinition { const method = t.http.method ?? "GET"; const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? "100MB"); - const rawExpect = target.expect as RawHttpExpectConfig | undefined; - const resolvedExpect: ResolvedHttpExpectConfig = rawExpect + const expect = target.expect as ResolvedHttpExpectConfig | undefined; + const resolvedExpect: ResolvedHttpExpectConfig = expect ? { - body: resolveContentExpectations(rawExpect.body), - durationMs: resolveValueExpectation(rawExpect.durationMs), - headers: resolveKeyedExpectations(rawExpect.headers), - status: rawExpect.status ?? [200], + ...expect, + status: expect.status ?? [200], } : { status: [200] }; @@ -205,7 +202,6 @@ export class HttpChecker implements CheckerDefinition { id: t.id, intervalMs: context.defaultIntervalMs, name: t.name ?? null, - rawExpect, timeoutMs: context.defaultTimeoutMs, type: "http", } satisfies ResolvedHttpTarget; diff --git a/src/server/checker/runner/http/schema.ts b/src/server/checker/runner/http/schema.ts index 081b25a..c399d04 100644 --- a/src/server/checker/runner/http/schema.ts +++ b/src/server/checker/runner/http/schema.ts @@ -3,9 +3,14 @@ import { Type } from "@sinclair/typebox"; import type { CheckerSchemas } from "../types"; import { - createRawContentExpectationsSchema, - createRawKeyedExpectationsSchema, - createRawValueExpectationSchema, + createAuthoringContentExpectationsSchema, + createAuthoringFieldSchema, + createAuthoringKeyedExpectationsSchema, + createAuthoringStringMapSchema, + createAuthoringValueExpectationSchema, + createNormalizedContentExpectationsSchema, + createNormalizedKeyedExpectationsSchema, + createNormalizedValueExpectationSchema, httpMethodSchema, sizeSchema, statusCodePatternSchema, @@ -13,25 +18,47 @@ import { } from "../../schema/fragments"; 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()), - headers: Type.Optional(stringMapSchema), - ignoreSSL: Type.Optional(Type.Boolean()), + headers: Type.Optional(kind === "authoring" ? createAuthoringStringMapSchema() : stringMapSchema), + ignoreSSL: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(bool) : bool), maxBodyBytes: Type.Optional(sizeSchema), - maxRedirects: Type.Optional(Type.Integer({ minimum: 0 })), - method: Type.Optional(httpMethodSchema), + maxRedirects: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(redirects) : redirects), + method: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(httpMethodSchema) : httpMethodSchema), url: Type.String({ minLength: 1 }), }, { additionalProperties: false }, - ), - expect: Type.Object( + ); +} + +function createHttpExpectSchema(kind: "authoring" | "normalized") { + return Type.Object( { - body: Type.Optional(createRawContentExpectationsSchema()), - durationMs: Type.Optional(createRawValueExpectationSchema()), - headers: Type.Optional(createRawKeyedExpectationsSchema()), + body: Type.Optional( + kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(), + ), + durationMs: Type.Optional( + kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(), + ), + headers: Type.Optional( + kind === "authoring" ? createAuthoringKeyedExpectationsSchema() : createNormalizedKeyedExpectationsSchema(), + ), status: Type.Optional(Type.Array(statusCodePatternSchema)), }, { additionalProperties: false }, - ), -}; + ); +} diff --git a/src/server/checker/runner/http/types.ts b/src/server/checker/runner/http/types.ts index 040f464..52d3e1a 100644 --- a/src/server/checker/runner/http/types.ts +++ b/src/server/checker/runner/http/types.ts @@ -48,7 +48,6 @@ export interface ResolvedHttpTarget extends ResolvedTargetBase { http: ResolvedHttpConfig; intervalMs: number; name: null | string; - rawExpect?: RawHttpExpectConfig; timeoutMs: number; type: "http"; } diff --git a/src/server/checker/runner/http/validate.ts b/src/server/checker/runner/http/validate.ts index df4e834..227a559 100644 --- a/src/server/checker/runner/http/validate.ts +++ b/src/server/checker/runner/http/validate.ts @@ -43,7 +43,7 @@ function validateHttpExpect(target: Record, path: string): Conf const issues: ConfigValidationIssue[] = []; const expectPath = joinPath(path, "expect"); - if (isPlainRecord(expect["headers"])) { + if (expect["headers"] !== undefined) { issues.push( ...validateRawKeyedExpectations(expect["headers"], joinPath(expectPath, "headers"), targetName, { caseInsensitive: true, diff --git a/src/server/checker/runner/icmp/execute.ts b/src/server/checker/runner/icmp/execute.ts index 94cfa91..cdacb64 100644 --- a/src/server/checker/runner/icmp/execute.ts +++ b/src/server/checker/runner/icmp/execute.ts @@ -2,16 +2,10 @@ import { isError } from "es-toolkit"; import type { CheckResult, RawTargetConfig } from "../../types"; import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types"; -import type { - PingStats, - PingTargetConfig, - RawIcmpExpectConfig, - ResolvedIcmpExpectConfig, - ResolvedPingTarget, -} from "./types"; +import type { PingStats, PingTargetConfig, ResolvedIcmpExpectConfig, ResolvedPingTarget } from "./types"; import { errorFailure } from "../../expect/failure"; -import { checkValueExpectation, resolveValueExpectation } from "../../expect/value"; +import { checkValueExpectation } from "../../expect/value"; import { buildPingCommand } from "./command"; import { checkAlive, checkAvgLatency, checkMaxLatency, checkPacketLoss } from "./expect"; import { parsePingOutput } from "./parse"; @@ -162,14 +156,11 @@ export class IcmpChecker implements CheckerDefinition { resolve(target: RawTargetConfig, context: ResolveContext): ResolvedPingTarget { const t = target as RawTargetConfig & { icmp: PingTargetConfig; type: "icmp" }; - const rawExpect = target.expect as RawIcmpExpectConfig | undefined; - const resolvedExpect: ResolvedIcmpExpectConfig = rawExpect + const expect = target.expect as ResolvedIcmpExpectConfig | undefined; + const resolvedExpect: ResolvedIcmpExpectConfig = expect ? { - alive: rawExpect.alive ?? true, - avgLatencyMs: resolveValueExpectation(rawExpect.avgLatencyMs), - durationMs: resolveValueExpectation(rawExpect.durationMs), - maxLatencyMs: resolveValueExpectation(rawExpect.maxLatencyMs), - packetLossPercent: resolveValueExpectation(rawExpect.packetLossPercent), + ...expect, + alive: expect.alive ?? true, } : { alive: true }; @@ -185,7 +176,6 @@ export class IcmpChecker implements CheckerDefinition { id: t.id, intervalMs: context.defaultIntervalMs, name: t.name ?? null, - rawExpect, timeoutMs: context.defaultTimeoutMs, type: "icmp", } satisfies ResolvedPingTarget; diff --git a/src/server/checker/runner/icmp/schema.ts b/src/server/checker/runner/icmp/schema.ts index b0aa631..6f2c3db 100644 --- a/src/server/checker/runner/icmp/schema.ts +++ b/src/server/checker/runner/icmp/schema.ts @@ -2,25 +2,54 @@ import { Type } from "@sinclair/typebox"; import type { CheckerSchemas } from "../types"; -import { createRawValueExpectationSchema } from "../../schema/fragments"; +import { + createAuthoringFieldSchema, + createAuthoringValueExpectationSchema, + createNormalizedValueExpectationSchema, +} from "../../schema/fragments"; export const icmpCheckerSchemas: CheckerSchemas = { - config: Type.Object( - { - count: Type.Optional(Type.Integer({ maximum: 100, minimum: 1 })), - host: Type.String({ minLength: 1 }), - packetSize: Type.Optional(Type.Integer({ maximum: 65500, minimum: 1 })), - }, - { additionalProperties: false }, - ), - 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 }, - ), + authoring: { + config: createIcmpConfigSchema("authoring"), + expect: createIcmpExpectSchema("authoring"), + }, + normalized: { + config: createIcmpConfigSchema("normalized"), + expect: createIcmpExpectSchema("normalized"), + }, }; + +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 }, + ); +} diff --git a/src/server/checker/runner/icmp/types.ts b/src/server/checker/runner/icmp/types.ts index 489f54c..e908973 100644 --- a/src/server/checker/runner/icmp/types.ts +++ b/src/server/checker/runner/icmp/types.ts @@ -45,7 +45,6 @@ export interface ResolvedPingTarget extends ResolvedTargetBase { icmp: ResolvedPingConfig; intervalMs: number; name: null | string; - rawExpect?: RawIcmpExpectConfig; timeoutMs: number; type: "icmp"; } diff --git a/src/server/checker/runner/llm/execute.ts b/src/server/checker/runner/llm/execute.ts index 448c1f2..6acbc84 100644 --- a/src/server/checker/runner/llm/execute.ts +++ b/src/server/checker/runner/llm/execute.ts @@ -3,12 +3,10 @@ import { isError } from "es-toolkit"; import type { CheckResult, RawTargetConfig } 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 { resolveKeyedExpectations } from "../../expect/keyed"; -import { checkValueExpectation, resolveValueExpectation } from "../../expect/value"; +import { checkValueExpectation } from "../../expect/value"; import { runExpects } from "./expect"; import { buildObservationFromApiCallError, @@ -155,26 +153,15 @@ export class LlmChecker implements CheckerDefinition { url: t.llm.url, }; - const rawExpect = target.expect as RawLlmExpectConfig | undefined; - const resolvedExpect: ResolvedLlmExpectConfig = rawExpect + const expect = target.expect as ResolvedLlmExpectConfig | undefined; + const resolvedExpect: ResolvedLlmExpectConfig = expect ? { - durationMs: resolveValueExpectation(rawExpect.durationMs), - finishReason: resolveValueExpectation(rawExpect.finishReason), - headers: resolveKeyedExpectations(rawExpect.headers), - output: resolveContentExpectations(rawExpect.output), - rawFinishReason: resolveValueExpectation(rawExpect.rawFinishReason), - status: rawExpect.status ?? [200], - stream: rawExpect.stream + ...expect, + status: expect.status ?? [200], + stream: expect.stream ? { - completed: rawExpect.stream.completed ?? true, - firstTokenMs: resolveValueExpectation(rawExpect.stream.firstTokenMs), - } - : undefined, - usage: rawExpect.usage - ? { - inputTokens: resolveValueExpectation(rawExpect.usage.inputTokens), - outputTokens: resolveValueExpectation(rawExpect.usage.outputTokens), - totalTokens: resolveValueExpectation(rawExpect.usage.totalTokens), + ...expect.stream, + completed: expect.stream.completed ?? true, } : undefined, } @@ -188,7 +175,6 @@ export class LlmChecker implements CheckerDefinition { intervalMs: context.defaultIntervalMs, llm: resolvedConfig, name: (target.name as null | string) ?? null, - rawExpect, timeoutMs: context.defaultTimeoutMs, type: "llm", } satisfies ResolvedLlmTarget; diff --git a/src/server/checker/runner/llm/schema.ts b/src/server/checker/runner/llm/schema.ts index 6e7c27c..c54bf9c 100644 --- a/src/server/checker/runner/llm/schema.ts +++ b/src/server/checker/runner/llm/schema.ts @@ -3,18 +3,26 @@ import { Type } from "@sinclair/typebox"; import type { CheckerSchemas } from "../types"; import { - createRawContentExpectationsSchema, - createRawKeyedExpectationsSchema, - createRawValueExpectationSchema, + createAuthoringContentExpectationsSchema, + createAuthoringFieldSchema, + createAuthoringKeyedExpectationsSchema, + createAuthoringStringMapSchema, + createAuthoringValueExpectationSchema, + createNormalizedContentExpectationsSchema, + createNormalizedKeyedExpectationsSchema, + createNormalizedValueExpectationSchema, statusCodePatternSchema, stringMapSchema, } from "../../schema/fragments"; -function createLlmOptionsSchema() { +function createLlmOptionsSchema(kind: "authoring" | "normalized") { + const maxOutputTokens = Type.Integer({ minimum: 1 }); return Type.Object( { 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()), seed: Type.Optional(Type.Number()), stopSequences: Type.Optional(Type.Array(Type.String())), @@ -27,35 +35,59 @@ function createLlmOptionsSchema() { } 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()), - headers: Type.Optional(stringMapSchema), - ignoreSSL: Type.Optional(Type.Boolean()), + headers: Type.Optional(kind === "authoring" ? createAuthoringStringMapSchema() : stringMapSchema), + ignoreSSL: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(bool) : bool), 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 }), - options: Type.Optional(createLlmOptionsSchema()), + options: Type.Optional(createLlmOptionsSchema(kind)), 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 }))), url: Type.String({ minLength: 1 }), }, { 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()), - finishReason: Type.Optional(createRawValueExpectationSchema()), - headers: Type.Optional(createRawKeyedExpectationsSchema()), - output: Type.Optional(createRawContentExpectationsSchema()), - rawFinishReason: Type.Optional(createRawValueExpectationSchema()), + durationMs: Type.Optional(valueExpectation), + finishReason: Type.Optional(valueExpectation), + headers: Type.Optional( + kind === "authoring" ? createAuthoringKeyedExpectationsSchema() : createNormalizedKeyedExpectationsSchema(), + ), + output: Type.Optional( + kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(), + ), + rawFinishReason: Type.Optional(valueExpectation), status: Type.Optional(Type.Array(statusCodePatternSchema)), stream: Type.Optional( Type.Object( { - completed: Type.Optional(Type.Boolean()), - firstTokenMs: Type.Optional(createRawValueExpectationSchema()), + completed: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(bool) : bool), + firstTokenMs: Type.Optional(valueExpectation), }, { additionalProperties: false }, ), @@ -63,14 +95,22 @@ export const llmCheckerSchemas: CheckerSchemas = { usage: Type.Optional( Type.Object( { - inputTokens: Type.Optional(createRawValueExpectationSchema()), - outputTokens: Type.Optional(createRawValueExpectationSchema()), - totalTokens: Type.Optional(createRawValueExpectationSchema()), + inputTokens: Type.Optional(valueExpectation), + outputTokens: Type.Optional(valueExpectation), + totalTokens: Type.Optional(valueExpectation), }, { 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")]); +} diff --git a/src/server/checker/runner/llm/types.ts b/src/server/checker/runner/llm/types.ts index eb83112..eb477a5 100644 --- a/src/server/checker/runner/llm/types.ts +++ b/src/server/checker/runner/llm/types.ts @@ -152,7 +152,6 @@ export interface ResolvedLlmTarget extends ResolvedTargetBase { intervalMs: number; llm: ResolvedLlmConfig; name: null | string; - rawExpect?: RawLlmExpectConfig; timeoutMs: number; type: "llm"; } diff --git a/src/server/checker/runner/tcp/execute.ts b/src/server/checker/runner/tcp/execute.ts index cb534b9..b6f2a09 100644 --- a/src/server/checker/runner/tcp/execute.ts +++ b/src/server/checker/runner/tcp/execute.ts @@ -2,11 +2,10 @@ import { isError } from "es-toolkit"; import type { CheckResult, RawTargetConfig } 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 { checkValueExpectation, resolveValueExpectation } from "../../expect/value"; +import { checkValueExpectation } from "../../expect/value"; import { parseSize } from "../../utils"; import { checkBanner, checkConnected } from "./expect"; import { tcpCheckerSchemas } from "./schema"; @@ -210,12 +209,11 @@ export class TcpChecker implements CheckerDefinition { const maxBannerBytes = parseSize(t.tcp.maxBannerBytes ?? DEFAULT_MAX_BANNER_BYTES); const bannerReadTimeout = t.tcp.bannerReadTimeout ?? DEFAULT_BANNER_READ_TIMEOUT; - const rawExpect = target.expect as RawTcpExpectConfig | undefined; - const resolvedExpect: ResolvedTcpExpectConfig = rawExpect + const expect = target.expect as ResolvedTcpExpectConfig | undefined; + const resolvedExpect: ResolvedTcpExpectConfig = expect ? { - banner: resolveContentExpectations(rawExpect.banner), - connected: rawExpect.connected ?? true, - durationMs: resolveValueExpectation(rawExpect.durationMs), + ...expect, + connected: expect.connected ?? true, } : { connected: true }; @@ -226,7 +224,6 @@ export class TcpChecker implements CheckerDefinition { id: t.id, intervalMs: context.defaultIntervalMs, name: t.name ?? null, - rawExpect, tcp: { bannerReadTimeout, host: t.tcp.host, diff --git a/src/server/checker/runner/tcp/schema.ts b/src/server/checker/runner/tcp/schema.ts index d1832ce..ae71ae1 100644 --- a/src/server/checker/runner/tcp/schema.ts +++ b/src/server/checker/runner/tcp/schema.ts @@ -3,28 +3,52 @@ import { Type } from "@sinclair/typebox"; import type { CheckerSchemas } from "../types"; import { - createRawContentExpectationsSchema, - createRawValueExpectationSchema, + createAuthoringContentExpectationsSchema, + createAuthoringFieldSchema, + createAuthoringValueExpectationSchema, + createNormalizedContentExpectationsSchema, + createNormalizedValueExpectationSchema, sizeSchema, } from "../../schema/fragments"; 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 })), host: Type.String({ minLength: 1 }), maxBannerBytes: Type.Optional(sizeSchema), - port: Type.Integer({ maximum: 65535, minimum: 1 }), - readBanner: Type.Optional(Type.Boolean()), + port: kind === "authoring" ? createAuthoringFieldSchema(port) : port, + readBanner: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(readBanner) : readBanner), }, { additionalProperties: false }, - ), - expect: Type.Object( + ); +} + +function createTcpExpectSchema(kind: "authoring" | "normalized") { + const connected = Type.Boolean(); + return Type.Object( { - banner: Type.Optional(createRawContentExpectationsSchema()), - connected: Type.Optional(Type.Boolean()), - durationMs: Type.Optional(createRawValueExpectationSchema()), + banner: Type.Optional( + kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(), + ), + connected: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(connected) : connected), + durationMs: Type.Optional( + kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(), + ), }, { additionalProperties: false }, - ), -}; + ); +} diff --git a/src/server/checker/runner/tcp/types.ts b/src/server/checker/runner/tcp/types.ts index e0a4f2d..0b55fcb 100644 --- a/src/server/checker/runner/tcp/types.ts +++ b/src/server/checker/runner/tcp/types.ts @@ -31,7 +31,6 @@ export interface ResolvedTcpTarget extends ResolvedTargetBase { group: string; intervalMs: number; name: null | string; - rawExpect?: RawTcpExpectConfig; tcp: ResolvedTcpConfig; timeoutMs: number; type: "tcp"; diff --git a/src/server/checker/runner/types.ts b/src/server/checker/runner/types.ts index 6dd04d3..c76ff8c 100644 --- a/src/server/checker/runner/types.ts +++ b/src/server/checker/runner/types.ts @@ -20,11 +20,16 @@ export interface CheckerDefinition { const responseEncoding = t.udp.responseEncoding ?? "text"; const maxResponseBytes = parseSize(t.udp.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES); - const rawExpect = target.expect as RawUdpExpectConfig | undefined; - const resolvedExpect: ResolvedUdpExpectConfig = rawExpect + const expect = target.expect as ResolvedUdpExpectConfig | undefined; + const resolvedExpect: ResolvedUdpExpectConfig = expect ? { - durationMs: resolveValueExpectation(rawExpect.durationMs), - responded: rawExpect.responded ?? true, - response: resolveContentExpectations(rawExpect.response), - responseSize: resolveValueExpectation(rawExpect.responseSize), - sourceHost: resolveValueExpectation(rawExpect.sourceHost), - sourcePort: resolveValueExpectation(rawExpect.sourcePort), + ...expect, + responded: expect.responded ?? true, } : { responded: true }; @@ -322,7 +317,6 @@ export class UdpChecker implements CheckerDefinition { id: t.id, intervalMs: context.defaultIntervalMs, name: t.name ?? null, - rawExpect, timeoutMs: context.defaultTimeoutMs, type: "udp", udp: { diff --git a/src/server/checker/runner/udp/schema.ts b/src/server/checker/runner/udp/schema.ts index 6f7b999..3580c08 100644 --- a/src/server/checker/runner/udp/schema.ts +++ b/src/server/checker/runner/udp/schema.ts @@ -3,32 +3,69 @@ import { Type } from "@sinclair/typebox"; import type { CheckerSchemas } from "../types"; import { - createRawContentExpectationsSchema, - createRawValueExpectationSchema, + createAuthoringContentExpectationsSchema, + createAuthoringFieldSchema, + createAuthoringValueExpectationSchema, + createNormalizedContentExpectationsSchema, + createNormalizedValueExpectationSchema, sizeSchema, } from "../../schema/fragments"; 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 }), maxResponseBytes: Type.Optional(sizeSchema), payload: Type.Optional(Type.String()), - port: Type.Integer({ maximum: 65535, minimum: 1 }), - responseEncoding: Type.Optional(Type.Union([Type.Literal("text"), Type.Literal("hex"), Type.Literal("base64")])), + port: kind === "authoring" ? createAuthoringFieldSchema(port) : port, + responseEncoding: Type.Optional( + kind === "authoring" ? createAuthoringFieldSchema(responseEncoding) : responseEncoding, + ), }, { additionalProperties: false }, - ), - expect: Type.Object( + ); +} + +function createUdpExpectSchema(kind: "authoring" | "normalized") { + const responded = Type.Boolean(); + return Type.Object( { - durationMs: Type.Optional(createRawValueExpectationSchema()), - responded: Type.Optional(Type.Boolean()), - response: Type.Optional(createRawContentExpectationsSchema()), - responseSize: Type.Optional(createRawValueExpectationSchema()), - sourceHost: Type.Optional(createRawValueExpectationSchema()), - sourcePort: Type.Optional(createRawValueExpectationSchema()), + durationMs: Type.Optional( + kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(), + ), + responded: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(responded) : responded), + response: Type.Optional( + 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 }, - ), -}; + ); +} diff --git a/src/server/checker/runner/udp/types.ts b/src/server/checker/runner/udp/types.ts index 1cff281..95c0e4d 100644 --- a/src/server/checker/runner/udp/types.ts +++ b/src/server/checker/runner/udp/types.ts @@ -38,7 +38,6 @@ export interface ResolvedUdpTarget extends ResolvedTargetBase { group: string; intervalMs: number; name: null | string; - rawExpect?: RawUdpExpectConfig; timeoutMs: number; type: "udp"; udp: ResolvedUdpConfig; diff --git a/src/server/checker/schema/builder.ts b/src/server/checker/schema/builder.ts index cef9a9a..0e239ad 100644 --- a/src/server/checker/schema/builder.ts +++ b/src/server/checker/schema/builder.ts @@ -5,9 +5,10 @@ import { Type } from "@sinclair/typebox"; import type { CheckerDefinition } from "../runner/types"; import { - createRawContentExpectationsSchema, - createRawKeyedExpectationsSchema, - createRawValueExpectationSchema, + createAuthoringContentExpectationsSchema, + createAuthoringFieldSchema, + createAuthoringKeyedExpectationsSchema, + createAuthoringValueExpectationSchema, createValueMatcherObjectSchema, durationSchema, sizeSchema, @@ -16,62 +17,58 @@ import { const LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "fatal"] 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 { return { - ...cloneSchema(createProbeConfigSchema(checkers, true)), + ...cloneSchema(createAuthoringProbeConfigSchema(checkers, true)), $id: "https://dial.local/probe-config.schema.json", $schema: "http://json-schema.org/draft-07/schema#", definitions: { - ContentExpectations: cloneSchema(createRawContentExpectationsSchema()), - KeyedExpectations: cloneSchema(createRawKeyedExpectationsSchema()), - ValueExpectation: cloneSchema(createRawValueExpectationSchema()), + ContentExpectations: cloneSchema(createAuthoringContentExpectationsSchema()), + KeyedExpectations: cloneSchema(createAuthoringKeyedExpectationsSchema()), + ValueExpectation: cloneSchema(createAuthoringValueExpectationSchema()), 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 { - return Type.Object( - { - 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 }, - ); + return createNormalizedProbeConfigSchema(checkers, external); } export function createTargetSchema(checker: CheckerDefinition): TSchema { - const properties: Record = { - 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 }); + return createNormalizedTargetSchema(checker); } function cloneSchema(schema: TSchema): Record { return JSON.parse(JSON.stringify(schema)) as Record; } -function createBaseTargetSchema(checkers: CheckerDefinition[]): TSchema { +function createBaseTargetSchema(checkers: CheckerDefinition[], kind: SchemaKind): TSchema { 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()), id: Type.String({ maxLength: 30, minLength: 1 }), 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), 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 { - return Type.Union(checkers.map((checker) => createTargetSchema(checker)) as [TSchema, ...TSchema[]]); +function createExternalTargetSchema(checkers: CheckerDefinition[], kind: SchemaKind): 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 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( { - 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( Type.Object( { - level: Type.Optional(logLevelSchema), + level: Type.Optional(logLevel), path: Type.Optional(Type.String({ minLength: 1 })), rotation: Type.Optional( Type.Object( { - frequency: Type.Optional( - Type.Union(ROTATION_FREQUENCIES.map((f) => Type.Literal(f)) as unknown as [TSchema, ...TSchema[]]), - ), - maxFiles: Type.Optional(Type.Integer({ minimum: 1 })), + frequency: Type.Optional(frequency), + maxFiles: Type.Optional(integerForKind(kind, { minimum: 1 })), size: Type.Optional(sizeSchema), }, { additionalProperties: false }, @@ -109,19 +109,38 @@ function createLoggingSchema(): TSchema { { additionalProperties: false }, ), ), - level: Type.Optional(logLevelSchema), + level: Type.Optional(logLevel), }, { additionalProperties: false }, ); } -function createProbesSchema(): TSchema { +function createProbeConfigSchemaForKind(checkers: CheckerDefinition[], kind: SchemaKind, external: boolean): TSchema { + const properties: Record = { + 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( { execution: Type.Optional( Type.Object( { - maxConcurrentChecks: Type.Optional(Type.Integer({ minimum: 1 })), + maxConcurrentChecks: Type.Optional(integerForKind(kind, { minimum: 1 })), }, { additionalProperties: false }, ), @@ -131,19 +150,19 @@ function createProbesSchema(): TSchema { ); } -function createServerSchema(): TSchema { +function createServerSchema(kind: SchemaKind): TSchema { return Type.Object( { listen: Type.Optional( Type.Object( { 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 }, ), ), - logging: Type.Optional(createLoggingSchema()), + logging: Type.Optional(createLoggingSchema(kind)), storage: Type.Optional( Type.Object( { @@ -157,3 +176,32 @@ function createServerSchema(): TSchema { { additionalProperties: false }, ); } + +function createTargetSchemaForKind(checker: CheckerDefinition, kind: SchemaKind): TSchema { + const properties: Record = { + 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[0]): TSchema { + const schema = Type.Integer(options); + return kind === "authoring" ? createAuthoringFieldSchema(schema) : schema; +} + +function stringForKind(kind: SchemaKind, options?: Parameters[0]): TSchema { + const schema = Type.String(options); + return kind === "authoring" ? createAuthoringFieldSchema(schema) : schema; +} diff --git a/src/server/checker/schema/fragments.ts b/src/server/checker/schema/fragments.ts index d1a5c90..c4a67ae 100644 --- a/src/server/checker/schema/fragments.ts +++ b/src/server/checker/schema/fragments.ts @@ -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 variableReferenceSchema = Type.String({ pattern: "^\\$\\{[^}]+\\}$" }); + export const statusCodePatternSchema = Type.Union([ Type.Integer({ maximum: 599, minimum: 100 }), Type.String({ pattern: "^[1-5]xx$" }), @@ -41,6 +43,81 @@ export const stringMapSchema = Type.Unsafe>({ 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>({ + 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 { return Type.Array( Type.Object( diff --git a/src/server/checker/schema/types.ts b/src/server/checker/schema/types.ts index 1f027d3..211e2a4 100644 --- a/src/server/checker/schema/types.ts +++ b/src/server/checker/schema/types.ts @@ -2,12 +2,16 @@ import type { ProbeConfig } from "../types"; declare const validatedConfigBrand: unique symbol; +export type AuthoringProbeConfig = ProbeConfig; + export type JsonValue = boolean | JsonValue[] | null | number | string | { [key: string]: JsonValue }; -export type RawProbeConfig = ProbeConfig; +export type NormalizedProbeConfig = Omit; -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; } diff --git a/src/server/checker/schema/validate.ts b/src/server/checker/schema/validate.ts index 33fc18c..b295fd2 100644 --- a/src/server/checker/schema/validate.ts +++ b/src/server/checker/schema/validate.ts @@ -5,9 +5,9 @@ import { isPlainObject, isString } from "es-toolkit"; import type { CheckerRegistry } from "../runner/registry"; 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"; export function createConfigAjv(): Ajv { @@ -21,11 +21,11 @@ export function issuesFromAjvErrors(errors: ErrorObject[], root: unknown, basePa export function validateProbeConfigContract( config: unknown, registry: CheckerRegistry, -): { config: null; issues: ConfigValidationIssue[] } | { config: RawProbeConfig; issues: [] } { +): { config: NormalizedProbeConfig; issues: [] } | { config: null; issues: ConfigValidationIssue[] } { const ajv = createConfigAjv(); const checkers = registry.definitions; const issues: ConfigValidationIssue[] = []; - const rootValidate = ajv.compile(createProbeConfigSchema(checkers)); + const rootValidate = ajv.compile(createNormalizedProbeConfigSchema(checkers)); if (!rootValidate(config)) { issues.push(...issuesFromAjvErrors(rootValidate.errors ?? [], config)); } @@ -34,7 +34,7 @@ export function validateProbeConfigContract( const configRecord = config as Record; const targetsValue: unknown = configRecord["targets"]; 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; for (let i = 0; i < targets.length; i++) { const target: unknown = targets[i]; @@ -44,14 +44,14 @@ export function validateProbeConfigContract( if (!isString(targetType)) continue; const checker = registry.tryGet(targetType); if (!checker) continue; - const targetValidate = ajv.compile(createTargetSchema(checker)); + const targetValidate = ajv.compile(createNormalizedTargetSchema(checker)); if (!targetValidate(target)) { 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 { diff --git a/src/server/checker/store.ts b/src/server/checker/store.ts index 5a4b919..7cf7b5f 100644 --- a/src/server/checker/store.ts +++ b/src/server/checker/store.ts @@ -352,7 +352,7 @@ export class ProbeStore { const serialized = checkerRegistry.get(t.type).serialize(t); const target = serialized.target; const config = serialized.config; - const expect = t.rawExpect ? JSON.stringify(t.rawExpect) : null; + const expect = null; if (existingIds.has(t.id)) { updateStmt.run(t.name, t.description, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group, t.id); diff --git a/src/server/checker/types.ts b/src/server/checker/types.ts index b340fc7..2eed675 100644 --- a/src/server/checker/types.ts +++ b/src/server/checker/types.ts @@ -75,7 +75,6 @@ export interface ResolvedTargetBase { id: string; intervalMs: number; name: null | string; - rawExpect?: unknown; timeoutMs: number; type: string; } diff --git a/tests/server/checker/config-contract/validate.test.ts b/tests/server/checker/config-contract/validate.test.ts index 2872de6..06f889d 100644 --- a/tests/server/checker/config-contract/validate.test.ts +++ b/tests/server/checker/config-contract/validate.test.ts @@ -84,6 +84,69 @@ describe("config contract", () => { 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 的数组和对象简写", () => { const ajv = new Ajv({ allErrors: true, @@ -144,4 +207,71 @@ describe("config contract", () => { 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); + }); }); diff --git a/tests/server/checker/config-loader.test.ts b/tests/server/checker/config-loader.test.ts index e774442..0810469 100644 --- a/tests/server/checker/config-loader.test.ts +++ b/tests/server/checker/config-loader.test.ts @@ -911,11 +911,6 @@ targets: const config = await loadConfig(configPath); const t = config.targets[0]!; 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({ body: [ { kind: "value", matcher: { contains: "ok" } }, @@ -952,12 +947,6 @@ targets: const config = await loadConfig(configPath); const t = config.targets[0]!; 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({ durationMs: { lte: 5000 }, exitCode: [0, 2], @@ -1291,7 +1280,7 @@ targets: equals: "ok" `, ); - await expectConfigLoadError(configPath, "json.path"); + await expectConfigLoadError(configPath, "path 必须为以"); }); test("body css selector 为空抛出错误", async () => { @@ -1310,7 +1299,7 @@ targets: selector: "" `, ); - await expectConfigLoadError(configPath, "css.selector 必须为非空字符串"); + await expectConfigLoadError(configPath, "selector 必须为非空字符串"); }); test("旧 match matcher 抛出错误", async () => { @@ -1329,7 +1318,7 @@ targets: match: "[invalid" `, ); - await expectConfigLoadError(configPath, "match 是未知 matcher"); + await expectConfigLoadError(configPath, "match 是未知字段"); }); test("operator gte 非数字抛出错误", async () => { @@ -1410,7 +1399,7 @@ targets: path: "" `, ); - await expectConfigLoadError(configPath, "xpath.path 必须为非空字符串"); + await expectConfigLoadError(configPath, "path 必须为非空字符串"); }); test("expect headers 非对象抛出错误", async () => { diff --git a/tests/server/checker/runner/cmd/runner.test.ts b/tests/server/checker/runner/cmd/runner.test.ts index 920d56e..d17c5d0 100644 --- a/tests/server/checker/runner/cmd/runner.test.ts +++ b/tests/server/checker/runner/cmd/runner.test.ts @@ -183,7 +183,7 @@ describe("CommandChecker", () => { test("resolve 未配置 expect 时物化默认 exitCode", () => { 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] }); }); }); diff --git a/tests/server/checker/runner/http/runner.test.ts b/tests/server/checker/runner/http/runner.test.ts index 1afecbd..e574001 100644 --- a/tests/server/checker/runner/http/runner.test.ts +++ b/tests/server/checker/runner/http/runner.test.ts @@ -975,7 +975,7 @@ describe("HttpChecker.resolve", () => { makeResolveContext(), ); - expect(result.rawExpect).toBeUndefined(); + expect("rawExpect" in result).toBe(false); expect(result.expect).toEqual({ status: [200] }); }); diff --git a/tests/server/checker/runner/icmp/execute.test.ts b/tests/server/checker/runner/icmp/execute.test.ts index d4dc420..297a917 100644 --- a/tests/server/checker/runner/icmp/execute.test.ts +++ b/tests/server/checker/runner/icmp/execute.test.ts @@ -128,7 +128,7 @@ describe("IcmpChecker resolve", () => { ); expect(target.icmp).toEqual({ count: 3, host: "10.0.0.1", packetSize: 56 }); expect(target.group).toBe("default"); - expect(target.rawExpect).toBeUndefined(); + expect("rawExpect" in target).toBe(false); expect(target.expect).toEqual({ alive: true }); }); diff --git a/tests/server/checker/runner/llm/registry.test.ts b/tests/server/checker/runner/llm/registry.test.ts index 348cf59..b8303a1 100644 --- a/tests/server/checker/runner/llm/registry.test.ts +++ b/tests/server/checker/runner/llm/registry.test.ts @@ -16,8 +16,10 @@ describe("LLM registry integration", () => { test("llm checker schemas 有效", () => { const checker = checkerRegistry.get("llm"); - expect(checker.schemas.config).toBeDefined(); - expect(checker.schemas.expect).toBeDefined(); + expect(checker.schemas.authoring.config).toBeDefined(); + expect(checker.schemas.authoring.expect).toBeDefined(); + expect(checker.schemas.normalized.config).toBeDefined(); + expect(checker.schemas.normalized.expect).toBeDefined(); }); test("llm checker validate 方法可用", () => { diff --git a/tests/server/checker/runner/llm/schema-validate-resolve.test.ts b/tests/server/checker/runner/llm/schema-validate-resolve.test.ts index 33510bf..93b3308 100644 --- a/tests/server/checker/runner/llm/schema-validate-resolve.test.ts +++ b/tests/server/checker/runner/llm/schema-validate-resolve.test.ts @@ -60,9 +60,11 @@ describe("LlmChecker schema", () => { expect(checker?.configKey).toBe("llm"); }); - test("schemas 包含 config、expect", () => { + test("schemas 包含 authoring 和 normalized config/expect", () => { 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.intervalMs).toBe(30000); expect(resolved.timeoutMs).toBe(10000); - expect(resolved.rawExpect).toBeUndefined(); + expect("rawExpect" in resolved).toBe(false); expect(resolved.expect).toEqual({ status: [200] }); }); test("stream mode 未配置 expect.stream 时不物化 completed", () => { const raw = makeRawTarget({ - expect: { output: [{ contains: "OK" }] }, + expect: { output: [{ kind: "value", matcher: { contains: "OK" } }] }, llm: { mode: "stream", model: "gpt-4o-mini", @@ -267,13 +269,13 @@ describe("LlmChecker resolve", () => { 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(); }); test("配置 expect.stream 但省略 completed 时默认 true", () => { const raw = makeRawTarget({ - expect: { stream: { firstTokenMs: 100 } }, + expect: { stream: { firstTokenMs: { equals: 100 } } }, llm: { mode: "stream", model: "gpt-4o-mini", @@ -285,7 +287,7 @@ describe("LlmChecker resolve", () => { 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 } }); }); diff --git a/tests/server/checker/runner/registry.test.ts b/tests/server/checker/runner/registry.test.ts index 3818ea4..8381908 100644 --- a/tests/server/checker/runner/registry.test.ts +++ b/tests/server/checker/runner/registry.test.ts @@ -14,8 +14,14 @@ function createChecker(type: string): Checker { execute: () => Promise.resolve({} as unknown as CheckResult), resolve: () => ({}) as unknown as ResolvedTargetBase, schemas: { - config: Type.Object({}, { additionalProperties: false }), - expect: Type.Object({}, { additionalProperties: false }), + authoring: { + config: Type.Object({}, { additionalProperties: false }), + expect: Type.Object({}, { additionalProperties: false }), + }, + normalized: { + config: Type.Object({}, { additionalProperties: false }), + expect: Type.Object({}, { additionalProperties: false }), + }, }, serialize: () => ({ config: "", target: "" }), type, @@ -68,7 +74,9 @@ describe("CheckerRegistry", () => { expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "custom"]); 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", () => { diff --git a/tests/server/checker/runner/shared/duplicate-header-key.test.ts b/tests/server/checker/runner/shared/duplicate-header-key.test.ts index 35161d4..205f1dd 100644 --- a/tests/server/checker/runner/shared/duplicate-header-key.test.ts +++ b/tests/server/checker/runner/shared/duplicate-header-key.test.ts @@ -1,7 +1,9 @@ import { describe, expect, test } from "bun:test"; 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 { validateHttpConfig } from "../../../../../src/server/checker/runner/http/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); }); - test("DB rows 保留大小写敏感不触发 duplicate-key", () => { - const target = { + test("HTTP normalized headers 大小写不同的重复 key 报错", () => { + 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:" }, - expect: { rows: [{ Name: "a", name: "b" }] }, - id: "dup-rows", + expect: { rows: [{ Name: "Alice" }] }, + id: "db-rows", 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); }); }); diff --git a/tests/server/checker/runner/tcp/execute.test.ts b/tests/server/checker/runner/tcp/execute.test.ts index c2e3a33..ad054b1 100644 --- a/tests/server/checker/runner/tcp/execute.test.ts +++ b/tests/server/checker/runner/tcp/execute.test.ts @@ -314,7 +314,7 @@ describe("TcpChecker resolve", () => { expect(target.name).toBeNull(); expect(target.intervalMs).toBe(30000); expect(target.timeoutMs).toBe(10000); - expect(target.rawExpect).toBeUndefined(); + expect("rawExpect" in target).toBe(false); expect(target.expect).toEqual({ connected: true }); }); @@ -343,7 +343,11 @@ describe("TcpChecker resolve", () => { test("expect 配置解析", () => { 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", tcp: { host: "127.0.0.1", port: 80, readBanner: true }, type: "tcp", @@ -355,7 +359,7 @@ describe("TcpChecker resolve", () => { connected: false, durationMs: { lte: 5000 }, }); - expect(target.rawExpect).toEqual({ banner: [{ contains: "ESMTP" }], connected: false, durationMs: { lte: 5000 } }); + expect("rawExpect" in target).toBe(false); }); test("name 和 group 解析", () => { diff --git a/tests/server/checker/runner/udp/execute.test.ts b/tests/server/checker/runner/udp/execute.test.ts index 065dc58..3859eb9 100644 --- a/tests/server/checker/runner/udp/execute.test.ts +++ b/tests/server/checker/runner/udp/execute.test.ts @@ -340,7 +340,7 @@ describe("UdpChecker resolve", () => { expect(target.udp.encoding).toBe("text"); expect(target.udp.responseEncoding).toBe("text"); expect(target.udp.maxResponseBytes).toBe(4096); - expect(target.rawExpect).toBeUndefined(); + expect("rawExpect" in target).toBe(false); expect(target.expect).toEqual({ responded: true }); }); diff --git a/tests/server/checker/store.test.ts b/tests/server/checker/store.test.ts index a298727..7c2a82f 100644 --- a/tests/server/checker/store.test.ts +++ b/tests/server/checker/store.test.ts @@ -39,7 +39,6 @@ const httpTarget: ResolvedHttpTarget = { id: "test-http", intervalMs: 30000, name: "test-http", - rawExpect: { body: [{ contains: "ok" }], durationMs: 3000 }, timeoutMs: 10000, type: "http", }; @@ -107,7 +106,7 @@ describe("ProbeStore", () => { expect(config.maxRedirects).toBe(0); expect(t.interval_ms).toBe(30000); expect(t.timeout_ms).toBe(10000); - expect(JSON.parse(t.expect!)).toEqual({ body: [{ contains: "ok" }], durationMs: 3000 }); + expect(t.expect).toBeNull(); }); test("cmd target 字段正确", () => { diff --git a/tests/server/checker/variables.test.ts b/tests/server/checker/variables.test.ts index 585a1ac..e86de7c 100644 --- a/tests/server/checker/variables.test.ts +++ b/tests/server/checker/variables.test.ts @@ -1,8 +1,35 @@ import { describe, expect, test } from "bun:test"; +import { normalizeAuthoringConfig } from "../../../src/server/checker/normalizer"; import { extractVariables, resolveVariables } from "../../../src/server/checker/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 类型", () => { const result = extractVariables({ variables: { enabled: true, host: "example.com", port: 5432 } });