From 60a54b483f5895cad1c5a30db6265db39a3c73e6 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Wed, 20 May 2026 16:12:48 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20expect=20=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E9=87=8D=E6=9E=84=EF=BC=8CRaw/Resolved=20?= =?UTF-8?q?=E5=8F=8C=E5=B1=82=E5=88=86=E7=A6=BB=E4=B8=8E=E6=96=AD=E8=A8=80?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E8=AE=BE=E6=96=BD=E5=86=85=E8=81=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重命名 ContentRules→ContentExpectations, KeyValueExpect→KeyedExpectations - 新增 Raw/Resolved 双层模型:resolve 阶段物化为执行计划,store 持久化 Raw 快照 - HTTP body 按需读取:status/headers 失败或无 body expectation 时不读取 body - 新增 displayValueExpectation() 解包 failure.expected 用户可读展示 - 修复 checkEarlyTimeout 独立 lte/lt 检查,修复 KeyedExpectations JSON Schema - 新增 expect/value.ts(resolve/check/display)、keyed.ts、content.ts、headers.ts、status.ts - 删除旧 normalize.ts/matcher.ts/validate-matcher.ts/key-value.ts - 更新 DEVELOPMENT.md:expect 五层管线表、displayValueExpectation、1.7↔1.10 交叉引用 - 同步 13 个 main specs,归档 refactor-expect-type-model 变更(62/62 tasks) --- DEVELOPMENT.md | 178 ++++--- README.md | 64 +-- .../specs/checker-cohesion-structure/spec.md | 34 +- .../specs/checker-runner-abstraction/spec.md | 41 +- openspec/specs/cmd-checker/spec.md | 28 +- openspec/specs/db-checker/spec.md | 16 +- openspec/specs/expect-body-checkers/spec.md | 54 +- openspec/specs/expect-rule-system/spec.md | 128 ++--- openspec/specs/icmp-checker/spec.md | 14 +- openspec/specs/llm-checker/spec.md | 26 +- openspec/specs/probe-config/spec.md | 134 ++--- openspec/specs/probe-data-store/spec.md | 12 +- openspec/specs/probe-engine/spec.md | 40 +- openspec/specs/tcp-checker/spec.md | 12 +- openspec/specs/udp-checker/spec.md | 12 +- probe-config.schema.json | 469 ++++++++---------- src/server/checker/expect/content.ts | 217 ++++++-- src/server/checker/expect/headers.ts | 14 + src/server/checker/expect/key-value.ts | 32 -- src/server/checker/expect/keyed.ts | 46 ++ src/server/checker/expect/keys.ts | 7 + src/server/checker/expect/normalize.ts | 22 - src/server/checker/expect/status.ts | 27 + src/server/checker/expect/types.ts | 69 ++- .../{validate-matcher.ts => validate.ts} | 184 ++++--- .../checker/expect/{matcher.ts => value.ts} | 49 +- src/server/checker/runner/cmd/execute.ts | 36 +- src/server/checker/runner/cmd/expect.ts | 4 +- src/server/checker/runner/cmd/schema.ts | 10 +- src/server/checker/runner/cmd/types.ts | 31 +- src/server/checker/runner/cmd/validate.ts | 21 +- src/server/checker/runner/db/execute.ts | 29 +- src/server/checker/runner/db/expect.ts | 14 +- src/server/checker/runner/db/schema.ts | 14 +- src/server/checker/runner/db/types.ts | 33 +- src/server/checker/runner/db/validate.ts | 32 +- src/server/checker/runner/http/execute.ts | 135 ++--- src/server/checker/runner/http/expect.ts | 36 -- src/server/checker/runner/http/schema.ts | 12 +- src/server/checker/runner/http/types.ts | 33 +- src/server/checker/runner/http/validate.ts | 39 +- src/server/checker/runner/icmp/execute.ts | 29 +- src/server/checker/runner/icmp/expect.ts | 18 +- src/server/checker/runner/icmp/schema.ts | 10 +- src/server/checker/runner/icmp/types.ts | 29 +- src/server/checker/runner/icmp/validate.ts | 25 +- src/server/checker/runner/llm/execute.ts | 44 +- src/server/checker/runner/llm/expect.ts | 50 +- src/server/checker/runner/llm/schema.ts | 24 +- src/server/checker/runner/llm/types.ts | 77 ++- src/server/checker/runner/llm/validate.ts | 35 +- src/server/checker/runner/tcp/execute.ts | 19 +- src/server/checker/runner/tcp/expect.ts | 10 +- src/server/checker/runner/tcp/schema.ts | 10 +- src/server/checker/runner/tcp/types.ts | 28 +- src/server/checker/runner/tcp/validate.ts | 19 +- src/server/checker/runner/udp/execute.ts | 30 +- src/server/checker/runner/udp/expect.ts | 24 +- src/server/checker/runner/udp/schema.ts | 16 +- src/server/checker/runner/udp/types.ts | 37 +- src/server/checker/runner/udp/validate.ts | 27 +- src/server/checker/schema/builder.ts | 14 +- src/server/checker/schema/fragments.ts | 16 +- src/server/checker/store.ts | 2 +- src/server/checker/types.ts | 1 + .../checker/config-contract/validate.test.ts | 27 + tests/server/checker/config-loader.test.ts | 30 +- tests/server/checker/expect/normalize.test.ts | 31 -- .../server/checker/runner/cmd/runner.test.ts | 26 +- .../server/checker/runner/db/execute.test.ts | 24 +- tests/server/checker/runner/db/expect.test.ts | 38 +- .../server/checker/runner/http/expect.test.ts | 42 +- .../server/checker/runner/http/runner.test.ts | 152 +++++- .../checker/runner/icmp/execute.test.ts | 7 +- .../server/checker/runner/llm/execute.test.ts | 41 +- .../checker/runner/llm/output-expect.test.ts | 137 +++-- .../llm/schema-validate-resolve.test.ts | 40 +- .../server/checker/runner/shared/body.test.ts | 27 +- .../shared/duplicate-header-key.test.ts | 65 +++ .../checker/runner/shared/duration.test.ts | 4 +- .../checker/runner/shared/keyed.test.ts | 34 ++ .../checker/runner/shared/operator.test.ts | 138 +++--- .../server/checker/runner/shared/text.test.ts | 9 +- .../shared/value-matcher-shorthand.test.ts | 5 +- .../server/checker/runner/tcp/execute.test.ts | 34 +- .../server/checker/runner/tcp/expect.test.ts | 18 +- .../server/checker/runner/udp/execute.test.ts | 26 +- .../server/checker/runner/udp/expect.test.ts | 14 +- tests/server/checker/store.test.ts | 5 +- .../constants/target-table-columns.test.ts | 4 +- 90 files changed, 2487 insertions(+), 1493 deletions(-) create mode 100644 src/server/checker/expect/headers.ts delete mode 100644 src/server/checker/expect/key-value.ts create mode 100644 src/server/checker/expect/keyed.ts create mode 100644 src/server/checker/expect/keys.ts delete mode 100644 src/server/checker/expect/normalize.ts create mode 100644 src/server/checker/expect/status.ts rename src/server/checker/expect/{validate-matcher.ts => validate.ts} (53%) rename src/server/checker/expect/{matcher.ts => value.ts} (71%) delete mode 100644 src/server/checker/runner/http/expect.ts delete mode 100644 tests/server/checker/expect/normalize.test.ts create mode 100644 tests/server/checker/runner/shared/duplicate-header-key.test.ts create mode 100644 tests/server/checker/runner/shared/keyed.test.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 87d0119..e85f035 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -39,7 +39,7 @@ src/ variables.ts 配置 variables 提取、target 字符串变量替换和 unresolved-variable issue 生成 schema/ TypeBox + Ajv 配置契约、schema fragments、issue 渲染和 schema 导出入口 builder.ts 全量 JSON Schema 组装(遍历 registry 生成) - fragments.ts 共享 TypeBox schema 片段(duration、size、ValueMatcher、ContentRules、KeyValueExpect 等) + fragments.ts 共享 TypeBox schema 片段(duration、size、ValueMatcher、ContentExpectations、KeyedExpectations 等) validate.ts Ajv 契约校验入口 issues.ts 校验问题类型与渲染 types.ts schema 层类型 @@ -48,12 +48,14 @@ src/ engine.ts 调度引擎(按 interval 分组的 es-toolkit groupBy + Semaphore 并发控制 + 数据清理) utils.ts 共享工具函数(parseSize、parseDuration) expect/ 共享 expect 断言基础设施(跨 checker 复用) - types.ts ExpectResult、ValueMatcher、ContentRules、KeyValueExpect 类型 + types.ts Raw/Resolved ValueExpectation、ContentExpectations、KeyedExpectations、ExpectationResult 类型 failure.ts 失败信息构造(errorFailure、mismatchFailure、truncateActual) - matcher.ts ValueMatcher 执行、JSONPath 提取、字面量 equals 快捷语义 - content.ts ContentRules 执行(direct/json/css/xpath) - key-value.ts KeyValueExpect 执行(动态键与 key 规范化) - validate-matcher.ts matcher/content/key-value 语义校验 + value.ts ValueExpectation resolve(primitive→equals)和执行、JSONPath 提取 + content.ts Resolved ContentExpectations 执行(kind=value/json/css/xpath)和 Raw resolve + keyed.ts Resolved KeyedExpectations 执行(顺序 + key 规范化)和 Raw resolve + headers.ts HTTP/LLM 共享 header keyed expectation 包装(大小写不敏感) + status.ts HTTP/LLM 共享 status code 断言(精确数值与 1xx-5xx 范围) + validate.ts Raw value/content/keyed expectation 语义校验(不修改输入) redos.ts regex ReDoS 风险检测 runner/ Checker 统一抽象与注册机制 types.ts CheckerDefinition、CheckerContext、CheckerSchemas、ResolveContext @@ -265,22 +267,22 @@ checkerRegistry(单例) 每个 checker 目录的标准文件结构: -| 文件 | 职责 | -| ------------- | ------------------------------------------------------------------------------------- | -| `index.ts` | 模块入口,re-export Checker 类 | -| `types.ts` | Checker 专属类型(ResolvedXxxTarget、XxxTargetConfig、XxxExpectConfig 等) | -| `schema.ts` | TypeBox 契约 schema(config / defaults / expect 三部分) | -| `validate.ts` | 启动期语义校验(JSON Schema 无法表达的规则) | -| `execute.ts` | Checker 类:resolve(默认值合并 + 解析)、execute(执行检查)、serialize(DB 持久化) | -| `expect.ts` | Checker 专用断言函数 | -| `*.ts` | 其他 checker 专属逻辑(如协议解析、编码、provider 适配、平台命令封装) | +| 文件 | 职责 | +| ------------- | ------------------------------------------------------------------------------------------ | +| `index.ts` | 模块入口,re-export Checker 类 | +| `types.ts` | Checker 专属类型(RawXxxTargetConfig、Raw/Resolved XxxExpectConfig、ResolvedXxxTarget 等) | +| `schema.ts` | TypeBox 契约 schema(config / defaults / expect 三部分) | +| `validate.ts` | 启动期语义校验(JSON Schema 无法表达的规则) | +| `execute.ts` | Checker 类:resolve(默认值合并 + 解析)、execute(执行检查)、serialize(DB 持久化) | +| `expect.ts` | Checker 专用断言函数 | +| `*.ts` | 其他 checker 专属逻辑(如协议解析、编码、provider 适配、平台命令封装) | #### 1.7.2 步骤一:创建 Checker 目录与类型 在 `src/server/checker/runner/tcp/types.ts` 中定义 checker 专属类型(参考 `http/types.ts`、`cmd/types.ts`): -- `XxxTargetConfig` — YAML 原始配置类型 -- `XxxExpectConfig` — expect 字段类型 +- `RawXxxTargetConfig` — YAML 原始配置类型 +- `RawXxxExpectConfig` / `ResolvedXxxExpectConfig` — Raw expect 字段类型与运行期 Resolved expect 执行计划类型 - `XxxDefaultsConfig` — defaults 专属字段类型 - `ResolvedXxxTarget extends ResolvedTargetBase` — resolve 后的完整类型,含 `type: "xxx"` 字面量 @@ -292,16 +294,16 @@ checkerRegistry(单例) **可复用的共享 fragments**(来自 `schema/fragments.ts`): -| Fragment | 用途 | -| ------------------------------ | -------------------------------------------------------- | -| `durationSchema` | 时长字符串(`"30s"`、`"5m"`、`"2h"`、`"7d"`、`"500ms"`) | -| `sizeSchema` | 大小单位(字符串如 `"10MB"` 或整数) | -| `statusCodePatternSchema` | 状态码(`100`-`599` 或 `"2xx"`) | -| `stringMapSchema` | `Record`(用于 headers / env) | -| `createValueMatcherSchema()` | `ValueMatcher` 对象(equals/contains/regex/数值比较等) | -| `createContentRulesSchema()` | `ContentRules` 数组(direct/json/css/xpath 内容规则) | -| `createKeyValueExpectSchema()` | 动态键 `KeyValueExpect`(headers、DB rows 列值) | -| `matcherProperties()` | matcher 字段 Record,供 extractor schema 复用 | +| Fragment | 用途 | +| ----------------------------------- | ----------------------------------------------------------- | +| `durationSchema` | 时长字符串(`"30s"`、`"5m"`、`"2h"`、`"7d"`、`"500ms"`) | +| `sizeSchema` | 大小单位(字符串如 `"10MB"` 或整数) | +| `statusCodePatternSchema` | 状态码(`100`-`599` 或 `"2xx"`) | +| `stringMapSchema` | `Record`(用于 headers / env) | +| `createValueMatcherSchema()` | `ValueMatcher` 对象(equals/contains/regex/数值比较等) | +| `createContentExpectationsSchema()` | `ContentExpectations` 数组(value/json/css/xpath 内容断言) | +| `createKeyedExpectationsSchema()` | 动态键 `KeyedExpectations`(headers、DB rows 列值) | +| `matcherProperties()` | matcher 字段 Record,供 extractor schema 复用 | **注意**:默认对象策略为 `additionalProperties: false`。只有明确的动态键值表(如 `http.headers`、`cmd.env`)可以开放任意键名。 @@ -313,15 +315,15 @@ checkerRegistry(单例) export function validateTcpConfig(input: CheckerValidationInput): ConfigValidationIssue[]; ``` -**共享校验工具**(`expect/validate-matcher.ts`): +**共享校验工具**(`expect/validate.ts`): -| 函数 | 用途 | -| ------------------------------------------------------ | ----------------------------------------- | -| `validateValueMatcher(value, path, targetName, opts?)` | 校验 matcher 字段、类型、regex 和组合语义 | -| `validateContentRules(rules, path, targetName)` | 校验 ContentRules 数组、extractor 互斥性 | -| `validateKeyValueExpect(value, path, targetName)` | 校验动态键值断言 | -| `validateJsonPath(path, rulePath, targetName)` | 校验项目支持的 JSONPath 子集 | -| `isJsonValue(value)` | 判断是否为合法 JSON value | +| 函数 | 用途 | +| -------------------------------------------------------------- | --------------------------------------------------- | +| `validateRawValueExpectation(value, path, targetName, opts?)` | 校验 Raw `ValueExpectation`(primitive 或 matcher) | +| `validateRawContentExpectations(value, path, targetName)` | 校验 Raw `ContentExpectations` 数组、extractor 互斥 | +| `validateRawKeyedExpectations(value, path, targetName, opts?)` | 校验 Raw `KeyedExpectations`,可选大小写不敏感重复 | +| `validateJsonPath(path, rulePath, targetName)` | 校验项目支持的 JSONPath 子集 | +| `isJsonValue(value)` | 判断是否为合法 JSON value | #### 1.7.5 步骤四:实现 Checker 类 @@ -342,9 +344,47 @@ TcpChecker implements Checker **`resolve()` 规范**: - 只做默认值合并、路径解析、单位转换,**不执行校验** +- 若 checker 支持 expect,必须保留 `rawExpect` 为变量替换后的 Raw 配置快照,并生成只供 `execute()` 使用的 Resolved `expect` - 返回 `satisfies ResolvedXxxTarget` 确保类型正确 - 通过 `context.defaults[this.configKey]` 访问 checker 专属默认值(需 `as` 断言为具体类型) +**expect 五层管线**:每种断言模型从定义到执行经过五个阶段,每层使用对应的共享函数: + +| 断言模型 | 类型层(Raw) | Schema 层 | Validate 层 | Resolve 层 | Execute 层 | +| --------------------- | ----------------------------------- | ----------------------------------- | ---------------------------------- | ------------------------------ | ---------------------------- | +| `ValueExpectation` | `number \| ValueMatcher` | `createValueMatcherSchema()` | `validateRawValueExpectation()` | `resolveValueExpectation()` | `checkValueExpectation()` | +| `ContentExpectations` | `(ValueMatcher \| ExtractorRule)[]` | `createContentExpectationsSchema()` | `validateRawContentExpectations()` | `resolveContentExpectations()` | `checkContentExpectations()` | +| `KeyedExpectations` | `Record` | `createKeyedExpectationsSchema()` | `validateRawKeyedExpectations()` | `resolveKeyedExpectations()` | `checkKeyedExpectations()` | + +选择哪种模型参考 [1.10 expect 字段选择规范](#110-expect-断言系统)的决策树。 + +**resolve 中的标准模式**: + +```typescript +// resolve() 内:逐字段调用对应的 resolve 函数,未配置的字段保持 undefined +const rawExpect = raw.expect ?? {}; +expect: { + durationMs: rawExpect.durationMs != null ? resolveValueExpectation(rawExpect.durationMs) : undefined, + body: rawExpect.body != null ? resolveContentExpectations(rawExpect.body) : undefined, + headers: rawExpect.headers != null ? resolveKeyedExpectations(rawExpect.headers) : undefined, +} +``` + +**execute 中的标准模式**: + +```typescript +// execute() 内:按快速失败顺序依次检查,首个失败即返回 +const r = resolved.expect; +if (r.durationMs) { + const result = checkValueExpectation(elapsed, r.durationMs, { phase: "duration", path: "durationMs" }); + if (!result.matched) return { ..., failure: result.failure, matched: false }; +} +if (r.body) { + const result = checkContentExpectations(bodyText, r.body, { phase: "body", path: "body" }); + if (!result.matched) return { ..., failure: result.failure, matched: false }; +} +``` + **`execute()` 规范**: - 始终记录 `timestamp`(ISO 字符串)和 `start = performance.now()` @@ -353,23 +393,29 @@ TcpChecker implements Checker - 成功时 `failure: null, matched: true` - 异常时使用 `errorFailure(phase, path, message)` 构造 failure - 不匹配时使用 `mismatchFailure(phase, path, expected, actual, message)` 构造 failure +- `mismatchFailure` 的 `expected` 参数应传用户可读值,使用 `displayValueExpectation(matcher)` 解包单字段 `{ equals: x }` 为 `x` **可用的共享断言工具**(`checker/expect/`): -| 模块 | 函数 | 用途 | -| --------------------- | ----------------------------------------------------- | ------------------------------------- | -| `failure.ts` | `errorFailure(phase, path, msg)` | 构造错误类型 failure | -| `failure.ts` | `mismatchFailure(phase, path, expected, actual, msg)` | 构造不匹配类型 failure | -| `failure.ts` | `truncateActual(value, maxLen?)` | 截断过长的 actual 值(默认 200 字符) | -| `matcher.ts` | `applyMatcher(actual, matcher, options?)` | 执行 ValueMatcher AND 匹配 | -| `matcher.ts` | `checkValueMatcher(actual, matcher, options)` | 执行 matcher 并返回 `ExpectResult` | -| `matcher.ts` | `checkExpectValue(actual, expected)` | 执行字面量 equals 或 matcher | -| `matcher.ts` | `evaluateJsonPath(json, path)` | JSONPath 提取 | -| `content.ts` | `checkContentRules(source, rules, options)` | 执行 ContentRules 数组 | -| `key-value.ts` | `checkKeyValueExpect(actual, expected, options)` | 执行动态键值断言 | -| `validate-matcher.ts` | `validateValueMatcher/ContentRules/KeyValueExpect` | matcher/content/key-value 语义校验 | +| 模块 | 函数 | 用途 | +| ------------- | ------------------------------------------------------------------- | ---------------------------------------------------- | +| `failure.ts` | `errorFailure(phase, path, msg)` | 构造错误类型 failure | +| `failure.ts` | `mismatchFailure(phase, path, expected, actual, msg)` | 构造不匹配类型 failure | +| `failure.ts` | `truncateActual(value, maxLen?)` | 截断过长的 actual 值(默认 200 字符) | +| `value.ts` | `applyValueMatcher(actual, matcher, options?)` | 执行 Resolved `ValueMatcher` AND 匹配 | +| `value.ts` | `checkValueExpectation(actual, matcher, options)` | 执行 matcher 并返回 `ExpectationResult` | +| `value.ts` | `resolveValueExpectation(raw)` | Raw `ValueExpectation` → Resolved `ValueExpectation` | +| `value.ts` | `displayValueExpectation(matcher)` | 解包单字段 `{ equals: x }` 为 `x`,用于 failure 展示 | +| `value.ts` | `evaluateJsonPath(json, path)` | JSONPath 提取 | +| `content.ts` | `checkContentExpectations(source, expectations, options)` | 执行 Resolved `ContentExpectations` | +| `content.ts` | `resolveContentExpectations(raw)` | Raw → Resolved `ContentExpectations` | +| `keyed.ts` | `checkKeyedExpectations(actual, expectations, options)` | 执行 Resolved `KeyedExpectations` | +| `keyed.ts` | `resolveKeyedExpectations(raw)` | Raw Record → Resolved 有序数组 | +| `headers.ts` | `checkHeaderExpectations(headers, expectations, options?)` | HTTP/LLM headers 大小写不敏感包装 | +| `status.ts` | `checkStatusCode(actual, expected, phase, path)` | HTTP/LLM status code(精确数值与 1xx-5xx 范围) | +| `validate.ts` | `validateRawValueExpectation/ContentExpectations/KeyedExpectations` | Raw expectation 语义校验(不修改输入) | -**Checker 专属断言**(如需要)放在同目录的 `expect.ts` 中,参考 `http/expect.ts`(checkStatus、checkHeaders)和 `cmd/expect.ts`(checkExitCode)。 +**Checker 专属断言**(如需要)放在同目录的 `expect.ts` 中,参考 `cmd/expect.ts`(checkExitCode)、`tcp/expect.ts`(checkConnected)、`udp/expect.ts`(checkResponded)和 `icmp/expect.ts`(checkAlive)。HTTP/LLM 复用的 status 与 headers 断言放在共享 expect 模块。 #### 1.7.6 步骤五:创建模块入口并注册 @@ -496,19 +542,21 @@ TcpChecker implements Checker 两层模型:**观测值收集** → **规则校验**。共享断言基础设施位于 `checker/expect/`,checker 专属状态断言位于各自目录。 +**Raw vs Resolved**:用户 YAML 写的是 Raw 形态(primitive 简写、`{ json: { path, equals } }` 提取器对象、`Record` 键值表),`config-loader` 的 resolve 阶段将其转换为 Resolved 形态供运行期执行(`{ equals: primitive }`、`{ kind, matcher, ... }` content 联合、`{ key, matcher }[]` 有序数组)。Store 持久化 Raw 快照(`rawExpect`),checker.execute 消费 Resolved `expect`。 + **共享模型**: -| 模型 | 用途 | 典型字段 | -| ---------------- | ---------------------------------------------- | -------------------------------------------------------------------- | -| `ValueMatcher` | 单个值、数字指标和字符串元数据断言 | `durationMs`、`rowCount`、`usage.totalTokens`、`finishReason` | -| `ContentRules` | 返回内容或半结构化内容断言,必须是数组 | `body`、`stdout`、`stderr`、`banner`、`response`、`output`、`result` | -| `KeyValueExpect` | 动态键值断言,字面量等价于 `{ equals: value }` | `headers`、DB `rows[]` 中的列值 | +| 模型 | 用途 | 典型字段 | +| --------------------- | ---------------------------------------------- | -------------------------------------------------------------------- | +| `ValueExpectation` | 单个值、数字指标和字符串元数据断言 | `durationMs`、`rowCount`、`usage.totalTokens`、`finishReason` | +| `ContentExpectations` | 返回内容或半结构化内容断言,必须是数组 | `body`、`stdout`、`stderr`、`banner`、`response`、`output`、`result` | +| `KeyedExpectations` | 动态键值断言,字面量等价于 `{ equals: value }` | `headers`、DB `rows[]` 中的列值 | -`ValueMatcher` 支持 `equals`、`contains`、`regex`、`empty`、`exists`、`gte`、`lte`、`gt`、`lt`。一个 matcher 对象内多个字段为 AND 语义;`exists: false` 不能和其他 matcher 组合;`equals` 使用 `es-toolkit/isEqual` 做 JSON 深度相等;`regex` 固定为无 flags 的 `new RegExp(pattern).test(String(actual))`。ValueMatcher expect 字段输入可使用 string、number、boolean 或 null 简写,语义校验入口会归一化为 `{ equals: value }`;数组和对象简写不支持,必须显式写成 `{ equals: ... }`。 +`ValueMatcher` 支持 `equals`、`contains`、`regex`、`empty`、`exists`、`gte`、`lte`、`gt`、`lt`。一个 matcher 对象内多个字段为 AND 语义;`exists: false` 不能和其他 matcher 组合;`equals` 使用 `es-toolkit/isEqual` 做 JSON 深度相等;`regex` 固定为无 flags 的 `new RegExp(pattern).test(String(actual))`。ValueExpectation Raw 输入可使用 string、number、boolean 或 null 简写,resolve 阶段归一化为 `{ equals: value }`;数组和对象简写不支持,必须显式写成 `{ equals: ... }`。 -`ContentRules` 数组按顺序快速失败。数组项可以是直接 matcher,也可以是 `{ json: {...} }`、`{ css: {...} }`、`{ xpath: {...} }` 提取器规则;一条规则不能混用直接 matcher 和 extractor,多个 extractor 也不能共存。Extractor 未配置 matcher 时等价于 `exists: true`。对对象或数组源执行直接 `contains`/`regex` 时会先 JSON 序列化,`equals` 仍对原始结构做深度相等。 +`ContentExpectations` 数组按顺序快速失败。数组项可以是直接 matcher,也可以是 `{ json: {...} }`、`{ css: {...} }`、`{ xpath: {...} }` 提取器规则;一条规则不能混用直接 matcher 和 extractor,多个 extractor 也不能共存。Extractor 未配置 matcher 时等价于 `exists: true`。对对象或数组源执行直接 `contains`/`regex` 时会先 JSON 序列化,`equals` 仍对原始结构做深度相等。 -启动期语义校验统一由 `expect/validate-matcher.ts` 负责,会校验空 matcher、未知字段、字段类型、`exists:false` 组合、ContentRules 互斥性、JSONPath 子集、XPath 可编译性、regex 可编译性和 ReDoS 风险。旧字段 `match`、`maxDurationMs`、ICMP 的 `max*` 阈值字段不再支持。 +启动期语义校验统一由 `expect/validate.ts` 负责,会校验空 matcher、未知字段、字段类型、`exists:false` 组合、ContentExpectations 互斥性、JSONPath 子集、XPath 可编译性、regex 可编译性、ReDoS 风险以及 HTTP/LLM headers 大小写归一化后重复 key。语义校验不修改 Raw 输入。旧字段 `match`、`maxDurationMs`、ICMP 的 `max*` 阈值字段不再支持。 **快速失败顺序**: @@ -523,11 +571,11 @@ TcpChecker implements Checker | LLM http | `status → headers → output → finishReason → rawFinishReason → usage → durationMs` | | LLM stream | `status → headers → stream.completed → stream.firstTokenMs → output → finishReason → rawFinishReason → usage → durationMs` | -HTTP checker 的 `durationMs` 覆盖完整执行(含重定向、响应体读取、解码和 expect 校验)。status 或 headers 失败时不读取 body;有 body 规则时,在读取 body 前可先检查是否已超过 `durationMs` 阈值,避免无意义读取。 +HTTP checker 的 `durationMs` 覆盖完整执行(含重定向、按需响应体读取、解码和 expect 校验)。未配置 body expectation、status 失败或 headers 失败时不读取 body;有 body expectation 时,在读取 body 前可先检查 `durationMs` 上界 matcher 是否已不可能通过,避免无意义读取。 **expect 字段选择规范**: -新增或修改 checker 的 expect 字段时,按以下决策树选择合适的断言模型: +新增或修改 checker 的 expect 字段时,按以下决策树选择合适的断言模型(选定后,各层的具体函数映射参考 [1.7.5 的五层管线表](#175-步骤四实现-checker-类)): ``` expect 字段 @@ -544,11 +592,11 @@ expect 字段 │ finishReason、rawFinishReason、usage.*、stream.firstTokenMs │ └─ 返回内容 / 半结构化内容 / 不完全确定的值 - ├─ 内容断言 → ContentRules(数组) + ├─ 内容断言 → ContentExpectations(数组) │ HTTP body、Cmd stdout/stderr、TCP banner、 │ UDP response、LLM output、DB result │ - └─ 键值断言 → KeyValueExpect(动态键对象) + └─ 键值断言 → KeyedExpectations(动态键对象) HTTP/LLM headers、DB rows[] 中的列值 ``` @@ -558,14 +606,16 @@ expect 字段 2. **单值数字指标和字符串元数据使用 ValueMatcher**。观测值是一个明确的标量(耗时、行数、丢包率、finish reason),但阈值不确定时,使用 `{ lte: 100 }` 或 `{ regex: "^(stop|end)$" }` 等 matcher 表达;精确匹配 primitive 可直接写 `100` 或 `"stop"`。 -3. **返回内容使用 ContentRules 数组**。观测值是文本、JSON、HTML 或 XML 内容,且可能需要多步提取或多条规则时,使用 ContentRules。即使只有一条规则也必须写成数组形式(`[{ contains: "ok" }]`),不支持对象快捷写法。 +3. **返回内容使用 ContentExpectations 数组**。观测值是文本、JSON、HTML 或 XML 内容,且可能需要多步提取或多条规则时,使用 ContentExpectations。即使只有一条规则也必须写成数组形式(`[{ contains: "ok" }]`),不支持对象快捷写法。 -4. **键值对使用 KeyValueExpect**。观测值是动态键值表(如 headers),且需要对每个键独立断言时使用。字面量值自动等价于 `{ equals: value }`。 +4. **键值对使用 KeyedExpectations**。观测值是动态键值表(如 headers),且需要对每个键独立断言时使用。字面量值自动等价于 `{ equals: value }`。 -5. **不要混用模型**。一个 expect 字段只能对应一种断言模型。例如 `finishReason` 是单值字符串元数据,用 ValueMatcher 而非 ContentRules(ContentRules 的 json/css/xpath 提取器对单字符串无意义,且会增加数组包装的配置冗余)。 +5. **不要混用模型**。一个 expect 字段只能对应一种断言模型。例如 `finishReason` 是单值字符串元数据,用 ValueMatcher 而非 ContentExpectations(ContentExpectations 的 json/css/xpath 提取器对单字符串无意义,且会增加数组包装的配置冗余)。 6. **failure phase 命名遵循去单位后缀规则**。数字指标字段的 phase 去掉单位后缀(`durationMs` → `duration`、`packetLossPercent` → `packetLoss`、`avgLatencyMs` → `avgLatency`),不带单位后缀的字段直接使用字段名(`rowCount` → `rowCount`、`finishReason` → `finishReason`)。 +7. **实现时参考 [1.7.5 五层管线](#175-步骤四实现-checker-类) 中的对应表**。决策树解决"选哪种模型",五层管线表解决"每种模型从类型定义到执行分别调哪个函数"。 + ### 1.11 错误模式 - **API 错误**:`{ error: "描述", status: }`,状态码 400/404/503 @@ -575,7 +625,7 @@ expect 字段 ### 1.12 测试规范 -- 测试目录 `tests/` 镜像 `src/` 目录结构,但共享 expect 模块的测试集中放在 `tests/server/checker/runner/shared/` 下,覆盖 `failure.ts`、`matcher.ts`、`content.ts`、`key-value.ts`、`validate-matcher.ts` 和 `redos.ts` +- 测试目录 `tests/` 镜像 `src/` 目录结构,但共享 expect 模块的测试集中放在 `tests/server/checker/runner/shared/` 下,覆盖 `failure.ts`、`value.ts`(operator)、`content.ts`(body/text)、`keyed.ts`(headers/duplicate-key)、`validate.ts`(shorthand)和 `redos.ts` - 使用 `bun:test` 框架(`describe`/`test`/`expect`),测试数据库用临时目录 + `tmpdir()` - 新增 store 方法必须编写单元测试;新增 API 端点必须在 `app.test.ts` 中添加集成测试 - 测试后清理:`afterAll` 中 `store.close()` + `rm(tempDir, { recursive: true })` diff --git a/README.md b/README.md index c597163..311edcf 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行** - 多种拨测类型:HTTP(GET/POST/PUT 等)、Cmd(命令行执行)、DB(PostgreSQL/MySQL/SQLite)、TCP(端口可达性 + Banner 探测)、UDP(自定义 payload 请求-响应)、ICMP(存活检测、延迟、丢包率)、LLM(大模型服务应用层健康检查) - 丰富的校验规则:状态码、响应头、JSONPath、CSS 选择器、XPath、正则匹配、数值比较等 -- 结构化观测数据:检查结果保留 HTTP body 预览、TCP/UDP 响应摘要、ICMP 丢包率、CMD 输出预览、LLM token 用量等 observation,便于排障和后续分析 +- 结构化观测数据:检查结果保留按需读取的 HTTP body 预览、TCP/UDP 响应摘要、ICMP 丢包率、CMD 输出预览、LLM token 用量等 observation,便于排障和后续分析 - 响应式 Dashboard:实时状态、可用率统计、耗时趋势图、手动/自动刷新 - 多主题支持:系统、明亮、黑暗三种主题模式 - 零外部依赖:数据存储使用 SQLite,无需额外数据库服务 @@ -185,8 +185,8 @@ targets: # 拨测目标列表(必填) | 字段 | 说明 | 必填 | 默认值 | | ------------ | -------------------------------------------------- | ---- | ------- | | `status` | 可接受的状态码列表,支持精确码和范围(如 `"2xx"`) | 否 | `[200]` | -| `headers` | 响应头校验,使用动态键名和 `KeyValueExpect` | 否 | | -| `body` | 响应体校验,使用 `ContentRules` 数组 | 否 | | +| `headers` | 响应头校验,使用动态键名和 `KeyedExpectations` | 否 | | +| `body` | 响应体校验,使用 `ContentExpectations` 数组 | 否 | | | `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | | **配置示例** @@ -234,12 +234,12 @@ targets: # 拨测目标列表(必填) **expect 校验项** -| 字段 | 说明 | 必填 | 默认值 | -| ------------ | -------------------------------------- | ---- | ------ | -| `exitCode` | 可接受的退出码列表 | 否 | `[0]` | -| `stdout` | 标准输出校验,使用 `ContentRules` 数组 | 否 | | -| `stderr` | 标准错误校验,使用 `ContentRules` 数组 | 否 | | -| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | | +| 字段 | 说明 | 必填 | 默认值 | +| ------------ | --------------------------------------------- | ---- | ------ | +| `exitCode` | 可接受的退出码列表 | 否 | `[0]` | +| `stdout` | 标准输出校验,使用 `ContentExpectations` 数组 | 否 | | +| `stderr` | 标准错误校验,使用 `ContentExpectations` 数组 | 否 | | +| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | | **配置示例** @@ -269,12 +269,12 @@ targets: # 拨测目标列表(必填) **expect 校验项** -| 字段 | 说明 | 必填 | 默认值 | -| ------------ | ---------------------------------------------------------------- | ---- | ------ | -| `rowCount` | 查询返回行数校验,使用 `ValueMatcher` | 否 | | -| `rows` | 查询结果逐行校验,数组内每行为列名到 `KeyValueExpect` 的映射 | 否 | | -| `result` | 完整查询结果 `{ rows, rowCount }` 校验,使用 `ContentRules` 数组 | 否 | | -| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | | +| 字段 | 说明 | 必填 | 默认值 | +| ------------ | ----------------------------------------------------------------------- | ---- | ------ | +| `rowCount` | 查询返回行数校验,使用 `ValueMatcher` | 否 | | +| `rows` | 查询结果逐行校验,数组内每行为列名到 `KeyedExpectations` 的映射 | 否 | | +| `result` | 完整查询结果 `{ rows, rowCount }` 校验,使用 `ContentExpectations` 数组 | 否 | | +| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | | **配置示例** @@ -309,11 +309,11 @@ targets: # 拨测目标列表(必填) **expect 校验项** -| 字段 | 说明 | 必填 | 默认值 | -| ------------ | ------------------------------------------------------------------ | ---- | ------ | -| `connected` | 期望连接结果,`true` 可达或 `false` 期望不可达 | 否 | `true` | -| `banner` | Banner 内容校验,使用 `ContentRules` 数组,需开启 `tcp.readBanner` | 否 | | -| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | | +| 字段 | 说明 | 必填 | 默认值 | +| ------------ | ------------------------------------------------------------------------- | ---- | ------ | +| `connected` | 期望连接结果,`true` 可达或 `false` 期望不可达 | 否 | `true` | +| `banner` | Banner 内容校验,使用 `ContentExpectations` 数组,需开启 `tcp.readBanner` | 否 | | +| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | | **配置示例** @@ -354,14 +354,14 @@ targets: # 拨测目标列表(必填) **expect 校验项** -| 字段 | 说明 | 必填 | 默认值 | -| -------------- | -------------------------------------- | ---- | ------ | -| `responded` | 期望是否收到响应 | 否 | `true` | -| `response` | 响应内容校验,使用 `ContentRules` 数组 | 否 | | -| `responseSize` | 响应字节数校验,使用 `ValueMatcher` | 否 | | -| `sourceHost` | 响应来源地址校验,使用 `ValueMatcher` | 否 | | -| `sourcePort` | 响应来源端口校验,使用 `ValueMatcher` | 否 | | -| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | | +| 字段 | 说明 | 必填 | 默认值 | +| -------------- | --------------------------------------------- | ---- | ------ | +| `responded` | 期望是否收到响应 | 否 | `true` | +| `response` | 响应内容校验,使用 `ContentExpectations` 数组 | 否 | | +| `responseSize` | 响应字节数校验,使用 `ValueMatcher` | 否 | | +| `sourceHost` | 响应来源地址校验,使用 `ValueMatcher` | 否 | | +| `sourcePort` | 响应来源端口校验,使用 `ValueMatcher` | 否 | | +| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | | **配置示例** @@ -466,8 +466,8 @@ ICMP checker 通过系统 `ping` 命令执行 ICMP 探测,支持 Linux、macOS | 字段 | 说明 | 必填 | 默认值 | | ----------------- | ---------------------------------------------------------------------- | ---- | ------- | | `status` | 可接受的状态码列表,支持精确码和范围(如 `"2xx"`) | 否 | `[200]` | -| `headers` | 响应头校验,使用动态键名和 `KeyValueExpect` | 否 | | -| `output` | 模型输出校验,使用 `ContentRules` 数组 | 否 | | +| `headers` | 响应头校验,使用动态键名和 `KeyedExpectations` | 否 | | +| `output` | 模型输出校验,使用 `ContentExpectations` 数组 | 否 | | | `finishReason` | finish reason 校验,使用 `ValueMatcher` | 否 | | | `rawFinishReason` | 原始 finish reason 校验,使用 `ValueMatcher` | 否 | | | `usage` | Token usage 校验(`inputTokens`/`outputTokens`/`totalTokens` matcher) | 否 | | @@ -497,7 +497,7 @@ ICMP checker 通过系统 `ping` 命令执行 ICMP 探测,支持 Linux、macOS ### 通用校验规则 -#### ContentRules 校验项 +#### ContentExpectations 校验项 `body`、`stdout`、`stderr`、`banner`、`response`、`output`、`result` 均使用数组: @@ -511,7 +511,7 @@ ICMP checker 通过系统 `ping` 命令执行 ICMP 探测,支持 Linux、macOS `equals`、`contains`、`regex`、`empty`、`exists`、`gte`、`lte`、`gt`、`lt`。`equals` 支持 JSON 深度相等;`regex` 固定使用无 flags 正则;提取器未配置 matcher 时等价于 `exists: true`。ValueMatcher expect 字段也可直接写 string、number、boolean 或 null,等价于 `{ equals: value }`;数组和对象必须显式写成 `{ equals: ... }`。 -#### KeyValueExpect +#### KeyedExpectations `headers`、`rows` 中每行使用的校验结构,支持 ValueMatcher 的全部字段。 diff --git a/openspec/specs/checker-cohesion-structure/spec.md b/openspec/specs/checker-cohesion-structure/spec.md index 882f536..fbfa406 100644 --- a/openspec/specs/checker-cohesion-structure/spec.md +++ b/openspec/specs/checker-cohesion-structure/spec.md @@ -47,30 +47,30 @@ - **THEN** 该 checker 专属的断言函数 SHALL 定义在 `expect.ts` 中 ### Requirement: 断言基础设施目录 -系统 SHALL 在 `src/server/checker/expect/` 目录中提供所有 checker 共享的断言基础设施。 +系统 SHALL 在 `src/server/checker/expect/` 目录中提供所有 checker 共享的断言基础设施。共享 expect 目录 SHALL 使用 Raw/Resolved expectation 术语和 value/content/keyed/status/headers 模块边界。 #### Scenario: expect 共享类型位置 -- **WHEN** 任何 checker 需要使用断言相关的共享类型(如 `ExpectResult`) +- **WHEN** 任何 checker 需要使用断言相关的共享类型(如 `ExpectationResult`、`ValueExpectation`、`ContentExpectations` 或 `KeyedExpectations`) - **THEN** 这些类型 SHALL 从 `src/server/checker/expect/types.ts` 导入 -#### Scenario: operator 断言引擎位置 -- **WHEN** 任何 checker 需要使用 `applyOperator`、`evaluateJsonPath` 或 `checkExpectValue` -- **THEN** 这些函数 SHALL 从 `src/server/checker/expect/operator.ts` 导入 +#### Scenario: value 断言引擎位置 +- **WHEN** 任何 checker 需要使用 `applyValueMatcher`、`evaluateJsonPath`、`resolveValueExpectation` 或 `checkValueExpectation` +- **THEN** 这些函数 SHALL 从 `src/server/checker/expect/value.ts` 导入 -#### Scenario: duration 断言位置 -- **WHEN** 任何 checker 需要使用 `checkDuration` -- **THEN** 该函数 SHALL 从 `src/server/checker/expect/duration.ts` 导入 +#### Scenario: content 和 keyed 断言位置 +- **WHEN** 任何 checker 需要执行内容数组或键值表 expectation +- **THEN** SHALL 分别从 `src/server/checker/expect/content.ts` 和 `src/server/checker/expect/keyed.ts` 导入共享函数 #### Scenario: failure 构造器位置 - **WHEN** 任何 checker 需要使用 `errorFailure` 或 `mismatchFailure` - **THEN** 这些函数 SHALL 从 `src/server/checker/expect/failure.ts` 导入 -#### Scenario: operator 校验位置 -- **WHEN** 任何 checker 的 validate 需要使用 `validateOperatorObject` -- **THEN** 该函数 SHALL 从 `src/server/checker/expect/validate-operator.ts` 导入 +#### Scenario: expectation 校验位置 +- **WHEN** 任何 checker 的 validate 需要校验 Raw value、Raw content 或 Raw keyed expectation +- **THEN** 对应函数 SHALL 从 `src/server/checker/expect/validate.ts` 导入 -#### Scenario: ExpectResult 类型位置 -- **WHEN** 任何 checker 需要使用 `ExpectResult` 类型 +#### Scenario: ExpectationResult 类型位置 +- **WHEN** 任何 checker 需要使用 `ExpectationResult` 类型 - **THEN** 该类型 SHALL 从 `src/server/checker/expect/types.ts` 导入 ### Requirement: Schema 目录结构 @@ -130,15 +130,15 @@ checker 系统内的模块依赖 SHALL 遵循严格的分层方向。 - **THEN** 开发者 SHALL 在 `runner/index.ts` 中添加一行 import 和一行数组项,无需修改其他文件 ### Requirement: 公共类型文件瘦身 -顶层 `src/server/checker/types.ts` SHALL 仅保留跨 checker 共享的 base 类型和存储相关类型。 +顶层 `src/server/checker/types.ts` SHALL 仅保留跨 checker 共享的 base 类型和存储相关类型;expect 专属类型 SHALL 放在 `src/server/checker/expect/types.ts`。 -#### Scenario: types.ts 不包含 checker 专属类型 +#### Scenario: types.ts 不包含 checker 或 expect 专属类型 - **WHEN** 开发者查看顶层 `types.ts` -- **THEN** 该文件 SHALL NOT 包含 `HttpTargetConfig`、`ResolvedHttpTarget`、`CommandExpectConfig`、`BodyRule`、`TextRule` 等 checker 专属类型 +- **THEN** 该文件 SHALL NOT 包含 `HttpTargetConfig`、`ResolvedHttpTarget`、`RawCommandExpectConfig`、`ContentExpectation` 等 checker 专属或 expect 专属类型 #### Scenario: types.ts 保留 base 类型 - **WHEN** 开发者查看顶层 `types.ts` -- **THEN** 该文件 SHALL 包含 `ResolvedTargetBase`、`RawTargetConfig`、`DefaultsConfig`、`CheckResult`、`ExpectOperator`、`CheckFailure`、`StoredTarget`、`StoredCheckResult`、`JsonValue` 等公共类型 +- **THEN** 该文件 SHALL 包含 `ResolvedTargetBase`、`RawTargetConfig`、`DefaultsConfig`、`CheckResult`、`CheckFailure`、`StoredTarget`、`StoredCheckResult`、`JsonValue` 等公共类型 #### Scenario: ResolvedTargetBase 替代联合类型 - **WHEN** engine、store、config-loader 需要引用 resolved target 类型 diff --git a/openspec/specs/checker-runner-abstraction/spec.md b/openspec/specs/checker-runner-abstraction/spec.md index c637173..16050e0 100644 --- a/openspec/specs/checker-runner-abstraction/spec.md +++ b/openspec/specs/checker-runner-abstraction/spec.md @@ -146,38 +146,57 @@ - **THEN** HttpChecker 的语义校验 SHALL 抛出校验错误,提示 URL 格式不合法 ### Requirement: 存储序列化通过 registry 获取展示格式 -系统 SHALL 在 `ProbeStore.syncTargets()` 中通过 `checkerRegistry.get(t.type).serialize(t)` 获取每个 target 的展示摘要(`target` 列)和配置 JSON(`config` 列),替代 `buildTargetDisplay()` / `buildTargetConfig()` 中的类型分支。 +系统 SHALL 在 `ProbeStore.syncTargets()` 中通过 `checkerRegistry.get(t.type).serialize(t)` 获取每个 target 的展示摘要(`target` 列)和配置 JSON(`config` 列),替代 `buildTargetDisplay()` / `buildTargetConfig()` 中的类型分支。系统 SHALL 将 `targets.expect` 持久化为 resolved target 上的 Raw expect 快照,而不是运行期 Resolved expect 执行计划。 #### Scenario: 序列化委托 checker - **WHEN** store 同步 targets 表 - **THEN** store SHALL 对每个 target 调用对应 checker 的 `serialize()` 方法获取 `{ target, config }` +#### Scenario: expect 持久化使用 rawExpect +- **WHEN** store 同步带 expect 的 target 到 targets 表 +- **THEN** store SHALL 将 `rawExpect` 序列化写入 `targets.expect`,MUST NOT 将包含 `kind` 的 Resolved content expectation 写入该列 + +### Requirement: Checker resolve 生成 Raw 与 Resolved expect +每个 checker 的 `resolve()` SHALL 在解析 checker 专属 target 配置时,同时保留变量替换后的 Raw expect 快照并生成运行期 Resolved expect 执行计划。Raw expect SHALL 用于配置快照持久化;Resolved expect SHALL 用于 checker `execute()`。`config-loader` SHALL 继续通过 registry 委托 checker 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: 中间层不感知 checker expect 字段 +- **WHEN** 新增 checker 定义自己的 Raw/Resolved expect 字段 +- **THEN** config-loader SHALL 只调用该 checker 的 `validate()` 和 `resolve()`,不新增 checker 类型分支 + ### Requirement: 共享 expect 断言函数 -系统 SHALL 在 `src/server/checker/expect/` 中提供可被多个 checker 复用的 expect 基础设施。共享基础设施 SHALL 包含 matcher、content rules、key-value expect、failure 构造和 ReDoS 校验。checker 专用的状态类断言 SHALL 保留在对应 checker 目录,或在多个 checker 复用时移动到共享模块。仅被单个 checker 使用且不属于通用 matcher/content/key-value 模型的断言模块 SHALL 位于该 checker 目录内。 +系统 SHALL 在 `src/server/checker/expect/` 中提供可被多个 checker 复用的 expect 基础设施。共享基础设施 SHALL 包含 value expectation、content expectations、keyed expectations、status code 断言、headers keyed 断言、failure 构造和 ReDoS 校验。checker 专用的状态类断言 SHALL 保留在对应 checker 目录,或在多个 checker 复用时移动到共享模块。仅被单个 checker 使用且不属于通用 value/content/keyed/status/header 模型的断言模块 SHALL 位于该 checker 目录内。 -#### Scenario: 共享 ValueMatcher 断言 +#### Scenario: 共享 ValueExpectation 断言 - **WHEN** 任何 checker 需要对数字、字符串、布尔或 JSON value 执行 matcher 匹配 -- **THEN** SHALL 调用共享 matcher 工具执行 `equals`、`contains`、`regex`、`exists`、`empty`、`gt`、`gte`、`lt` 和 `lte` 语义 +- **THEN** SHALL 调用共享 value expectation 工具执行 `equals`、`contains`、`regex`、`exists`、`empty`、`gt`、`gte`、`lt` 和 `lte` 语义 -#### Scenario: 共享 ContentRules 断言 +#### Scenario: 共享 ContentExpectations 断言 - **WHEN** HTTP body、LLM output、Cmd stdout/stderr、UDP response 或 TCP banner 需要执行返回内容校验 -- **THEN** SHALL 调用共享 content rules 工具,而不是在 checker 目录内复制 contains/regex/json/css/xpath 逻辑 +- **THEN** SHALL 调用共享 content expectations 工具,而不是在 checker 目录内复制 contains/regex/json/css/xpath 逻辑 -#### Scenario: 共享 KeyValueExpect 断言 +#### Scenario: 共享 KeyedExpectations 断言 - **WHEN** HTTP 或 LLM checker 需要校验响应 headers,或 DB checker 需要校验 rows 中的列值 -- **THEN** SHALL 调用共享 key-value expect 工具,并按调用方规则决定 key 是否大小写敏感 +- **THEN** SHALL 调用共享 keyed expectations 工具,并按调用方规则决定 key 是否大小写敏感 + +#### Scenario: 共享 headers 断言 +- **WHEN** HTTP 或 LLM checker 需要校验响应 headers +- **THEN** SHALL 调用共享 header expectation 包装函数,确保 header key 大小写不敏感 #### Scenario: 共享 regex ReDoS 校验 -- **WHEN** 任一 matcher 或 content rule 配置 `regex` +- **WHEN** 任一 matcher 或 content expectation 配置 `regex` - **THEN** SHALL 调用共享 ReDoS 校验工具在启动期拒绝危险正则 #### Scenario: 共享 failure 构造 - **WHEN** 任何 checker 需要构造 CheckFailure 对象 - **THEN** SHALL 调用 `expect/failure.ts` 中的 `errorFailure()` 或 `mismatchFailure()`,并保留 actual 截断策略 -#### Scenario: HTTP 专用 status 断言 +#### Scenario: 共享 status 断言 - **WHEN** HTTP 或 LLM checker 需要校验响应状态码 -- **THEN** SHALL 复用同一 status 断言函数,支持精确状态码和 `1xx` 到 `5xx` 范围模式 +- **THEN** SHALL 复用共享 status code 断言函数,支持精确状态码和 `1xx` 到 `5xx` 范围模式 ### Requirement: 超时控制由引擎注入 signal Checker 实现的 `execute()` MUST 使用 `ctx.signal` 感知超时,不得自行创建 `AbortController` 或 `setTimeout` 用于超时控制。Cmd checker 和 ping checker 可在 signal abort 时 `proc.kill()` 以确保子进程被终止。 diff --git a/openspec/specs/cmd-checker/spec.md b/openspec/specs/cmd-checker/spec.md index 11a1081..32e39f3 100644 --- a/openspec/specs/cmd-checker/spec.md +++ b/openspec/specs/cmd-checker/spec.md @@ -55,11 +55,11 @@ - **THEN** 系统 MUST 停止收集输出并终止该检查,记录 `matched=false`,并在 failure 中写入输出超限信息;observation SHALL 包含已截断输出预览和 error ### Requirement: cmd expect 校验 -系统 SHALL 支持 cmd 专用 expect,包括 `exitCode`、`durationMs`、`stdout` 和 `stderr`,并按 exitCode、durationMs、stdout、stderr 的阶段顺序快速失败。`exitCode` SHALL 保持有限整数数组语义,未配置时默认 `[0]`。`durationMs` SHALL 使用共享 `ValueMatcher` 校验完整命令执行耗时。`stdout` 和 `stderr` MUST 使用共享 `ContentRules` 数组,直接 matcher 作用于对应输出文本,`json` extractor SHALL 支持对 JSON CLI 输出执行 JSONPath 断言。 +系统 SHALL 支持 cmd 专用 expect,包括 `exitCode`、`durationMs`、`stdout` 和 `stderr`,并按 exitCode、durationMs、stdout、stderr 的阶段顺序快速失败。`exitCode` SHALL 保持有限整数数组语义,未配置时在 Resolved expect 中默认 `[0]`。`durationMs` SHALL 使用共享 `RawValueExpectation` 输入并在运行期使用 `ValueExpectation` 校验完整命令执行耗时。`stdout` 和 `stderr` MUST 使用共享 `RawContentExpectations` 数组输入并在运行期使用 `ContentExpectations`,直接 matcher 作用于对应输出文本,`json` extractor SHALL 支持对 JSON CLI 输出执行 JSONPath 断言。 #### Scenario: 默认 exitCode 成功语义 - **WHEN** cmd target 未显式配置 `expect.exitCode` -- **THEN** 系统 SHALL 使用默认 `expect.exitCode: [0]` 进行校验 +- **THEN** 系统 SHALL 在 Resolved cmd expect 中使用默认 `exitCode: [0]` 进行校验 #### Scenario: 显式 exitCode 校验 - **WHEN** cmd target 配置 `expect.exitCode: [0, 2]` 且实际 exit code 为 2 @@ -74,8 +74,8 @@ - **THEN** 系统 SHALL 返回 `matched=false`,failure 的 phase 为 `duration` #### Scenario: stdout 按配置顺序校验 -- **WHEN** cmd target 配置 `expect.stdout` 为两个 ContentRules,第一条通过且第二条失败 -- **THEN** 系统 SHALL 先执行第一条 stdout 规则,再执行第二条,并将 failure.path 指向失败的 `stdout[1]` +- **WHEN** cmd target 配置 `expect.stdout` 为两个 ContentExpectations,第一条通过且第二条失败 +- **THEN** 系统 SHALL 先执行第一条 stdout expectation,再执行第二条,并将 failure.path 指向失败的 `stdout[1]` #### Scenario: stderr 校验为空 - **WHEN** cmd target 配置 `expect.stderr: [{empty: true}]` 且实际 stderr 为空字符串 @@ -86,11 +86,11 @@ - **THEN** 系统 SHALL 判定 stdout 阶段通过 #### Scenario: stdout 失败后不检查 stderr -- **WHEN** cmd target 同时配置 stdout 和 stderr 规则,且 stdout 规则失败 -- **THEN** 系统 SHALL 快速失败并 MUST NOT 继续执行 stderr 规则 +- **WHEN** cmd target 同时配置 stdout 和 stderr expectation,且 stdout expectation 失败 +- **THEN** 系统 SHALL 快速失败并 MUST NOT 继续执行 stderr expectation ### Requirement: cmd checker 启动期配置校验 -系统 SHALL 在启动期对 cmd checker 的配置契约和语义执行严格校验。Cmd target 的 `cmd` 分组 SHALL 只允许 `exec`、`args`、`cwd`、`env`、`maxOutputBytes` 字段。Cmd expect SHALL 只允许 `exitCode`、`durationMs`、`stdout`、`stderr` 字段。未知字段、非法类型、不可编译正则和 ReDoS 风险正则 MUST 导致启动期配置错误。`expect.exitCode` SHALL 保留原有有限整数数组语义,不限制到特定平台范围。 +系统 SHALL 在启动期对 cmd checker 的配置契约和语义执行严格校验。Cmd target 的 `cmd` 分组 SHALL 只允许 `exec`、`args`、`cwd`、`env`、`maxOutputBytes` 字段。Cmd expect SHALL 只允许 `exitCode`、`durationMs`、`stdout`、`stderr` 字段。未知字段、非法类型、不可编译正则和 ReDoS 风险正则 MUST 导致启动期配置错误。`expect.exitCode` SHALL 保留原有有限整数数组语义,不限制到特定平台范围。语义校验 MUST NOT 修改 Raw cmd expect 输入。 #### Scenario: cmd args 类型非法 - **WHEN** YAML 中 cmd target 配置 `cmd.args` 不是字符串数组 @@ -105,24 +105,24 @@ - **THEN** 系统 SHALL 以配置错误退出,提示 expect.exitCode 必须为整数数组 #### Scenario: cmd expect durationMs 非法 -- **WHEN** YAML 中 cmd target 配置 `expect.durationMs` 不是合法 `ValueMatcher` +- **WHEN** YAML 中 cmd target 配置 `expect.durationMs` 不是合法 `RawValueExpectation` - **THEN** 系统 SHALL 以配置错误退出,提示 expect.durationMs 格式错误 -#### Scenario: stdout 必须为 ContentRules 数组 +#### Scenario: stdout 必须为 ContentExpectations 数组 - **WHEN** YAML 中 cmd target 配置 `expect.stdout` 但其值不是数组 - **THEN** 系统 SHALL 以配置错误退出,提示 expect.stdout 必须为数组 -#### Scenario: stderr 必须为 ContentRules 数组 +#### Scenario: stderr 必须为 ContentExpectations 数组 - **WHEN** YAML 中 cmd target 配置 `expect.stderr` 但其值不是数组 - **THEN** 系统 SHALL 以配置错误退出,提示 expect.stderr 必须为数组 -#### Scenario: stdout text rule 空对象非法 +#### Scenario: stdout text expectation 空对象非法 - **WHEN** YAML 中 cmd target 配置 `expect.stdout: [{}]` -- **THEN** 系统 SHALL 以配置错误退出,提示 stdout 规则必须包含至少一个合法 matcher 或 extractor +- **THEN** 系统 SHALL 以配置错误退出,提示 stdout expectation 必须包含至少一个合法 matcher 或 extractor -#### Scenario: stderr text rule 未知字段非法 +#### Scenario: stderr text expectation 未知字段非法 - **WHEN** YAML 中 cmd target 配置 `expect.stderr: [{foo: "bar"}]` -- **THEN** 系统 SHALL 以配置错误退出,提示 stderr 规则包含未知 matcher 或未知 extractor +- **THEN** 系统 SHALL 以配置错误退出,提示 stderr expectation 包含未知 matcher 或未知 extractor #### Scenario: stdout regex 正则非法 - **WHEN** YAML 中 cmd target 配置 `expect.stdout: [{regex: "[invalid"}]` diff --git a/openspec/specs/db-checker/spec.md b/openspec/specs/db-checker/spec.md index 1af0788..ad5a810 100644 --- a/openspec/specs/db-checker/spec.md +++ b/openspec/specs/db-checker/spec.md @@ -75,7 +75,7 @@ - **THEN** 系统 SHALL 立即关闭数据库连接 ### Requirement: db expect 校验 -系统 SHALL 支持 db 专用 expect,包括 `durationMs`、`rowCount`、`rows` 和 `result`,按 durationMs、rowCount、rows、result 的阶段顺序快速失败。`durationMs` 和 `rowCount` SHALL 使用共享 `ValueMatcher`。`rows` SHALL 保留按行索引匹配列值的语义,类型为 `Array`(外层数组按行索引,内层每个元素为一个 `KeyValueExpect` 表达该行的列值断言),每个行规则中列值字面量等价于 `{equals: }`。`result` MUST 使用共享 `ContentRules` 数组,对查询结果对象 `{ rows, rowCount }` 执行断言。 +系统 SHALL 支持 db 专用 expect,包括 `durationMs`、`rowCount`、`rows` 和 `result`,按 durationMs、rowCount、rows、result 的阶段顺序快速失败。`durationMs` 和 `rowCount` SHALL 使用共享 `RawValueExpectation` 输入,并在 resolve 阶段转换为运行期 `ValueExpectation`。`rows` SHALL 保留按行索引匹配列值的语义,Raw 类型为 `Array`(外层数组按行索引,内层每个元素表达该行的列值断言),Resolved 类型为 `Array`。每个行规则中列值 primitive 字面量等价于 `{equals: }`。`result` MUST 使用共享 `RawContentExpectations` 数组输入,并在运行期以 `ContentExpectations` 对查询结果对象 `{ rows, rowCount }` 执行断言。 #### Scenario: durationMs 校验 - **WHEN** db target 配置 `expect.durationMs: {lte: 3000}` 且实际执行耗时 4000ms @@ -95,7 +95,7 @@ #### Scenario: rows 按索引匹配列值字面量形式 - **WHEN** db target 配置 `expect.rows: [{ status: "active" }]` 且查询首行 status 列值为 `"active"` -- **THEN** 系统 SHALL 判定该行该列通过(字面量等价于 `{ equals: "active" }`) +- **THEN** 系统 SHALL 在 resolve 阶段将该列值解析为 `{equals: "active"}` 并判定该行该列通过 #### Scenario: rows 只检查声明的列 - **WHEN** db target 配置 `expect.rows: [{ cnt: { gte: 1 } }]` 且查询首行包含 cnt、name、age 三列 @@ -122,14 +122,14 @@ - **THEN** 系统 SHALL 按 durationMs → rowCount → rows → result 顺序校验,任一阶段失败立即返回 ### Requirement: db checker 启动期配置校验 -系统 SHALL 在启动期对 db checker 的配置契约和语义执行严格校验。Db target 的 `db` 分组 SHALL 只允许 `url` 和 `query` 字段。Db expect SHALL 只允许 `durationMs`、`rowCount`、`rows` 和 `result` 字段。未知字段、非法 matcher、非法 ContentRules、非法 regex 和 ReDoS 风险正则 MUST 导致启动期配置错误。 +系统 SHALL 在启动期对 db checker 的配置契约和语义执行严格校验。Db target 的 `db` 分组 SHALL 只允许 `url` 和 `query` 字段。Db expect SHALL 只允许 `durationMs`、`rowCount`、`rows` 和 `result` 字段。未知字段、非法 ValueExpectation、非法 ContentExpectations、非法 regex 和 ReDoS 风险正则 MUST 导致启动期配置错误。语义校验 MUST NOT 修改 Raw db expect 输入。 #### Scenario: db expect durationMs 非法 -- **WHEN** YAML 中 db target 配置 `expect.durationMs` 不是合法 `ValueMatcher` +- **WHEN** YAML 中 db target 配置 `expect.durationMs` 不是合法 `RawValueExpectation` - **THEN** 系统 SHALL 以配置错误退出,提示 expect.durationMs 格式错误 #### Scenario: db expect rowCount 非法 -- **WHEN** YAML 中 db target 配置 `expect.rowCount` 不是合法 `ValueMatcher` +- **WHEN** YAML 中 db target 配置 `expect.rowCount` 不是合法 `RawValueExpectation` - **THEN** 系统 SHALL 以配置错误退出,提示 expect.rowCount 格式错误 #### Scenario: db expect rows 非法 @@ -140,8 +140,12 @@ - **WHEN** YAML 中 db target 配置 `expect.rows: [{ cnt: { foo: 1 } }]`,其中 foo 不是合法 matcher - **THEN** 系统 SHALL 以配置错误退出,提示 rows 中包含未知 matcher +#### Scenario: db expect rows 对象值必须显式 equals +- **WHEN** YAML 中 db target 配置 `expect.rows: [{ payload: { status: "ok" } }]` 且 payload 值不是合法 matcher 对象 +- **THEN** 系统 SHALL 以配置错误退出,提示对象值必须显式写成 `{equals: {status: "ok"}}` + #### Scenario: db expect result 非法 -- **WHEN** YAML 中 db target 配置 `expect.result` 不是合法 ContentRules 数组 +- **WHEN** YAML 中 db target 配置 `expect.result` 不是合法 ContentExpectations 数组 - **THEN** 系统 SHALL 以配置错误退出,提示 expect.result 格式错误 #### Scenario: db expect 未知字段失败 diff --git a/openspec/specs/expect-body-checkers/spec.md b/openspec/specs/expect-body-checkers/spec.md index 9762a52..38b26c1 100644 --- a/openspec/specs/expect-body-checkers/spec.md +++ b/openspec/specs/expect-body-checkers/spec.md @@ -5,54 +5,54 @@ ## Requirements ### Requirement: 响应体多种校验方法 -系统 SHALL 支持通过共享 `ContentRules` 对 HTTP 响应体进行有序内容校验。`expect.body` MUST 为规则数组。每个规则 SHALL 为直接 `ValueMatcher`,或 `json`、`css`、`xpath` extractor 规则之一。直接 matcher SHALL 作用于完整响应体文本。`json` SHALL 解析响应体为 JSON 并用 JSONPath 子集提取值。`css` SHALL 使用 CSS selector 从 HTML 中提取元素文本或属性。`xpath` SHALL 使用 XPath 从 XML/HTML 中提取节点值。Extractor 未配置 matcher 时 SHALL 等价于 `exists: true`。 +系统 SHALL 支持通过共享 `ContentExpectations` 对 HTTP 响应体进行有序内容校验。`expect.body` MUST 为 expectation 数组。每个 expectation SHALL 为直接 `ValueMatcher`,或 `json`、`css`、`xpath` extractor expectation 之一。直接 matcher SHALL 作用于完整响应体文本。`json` SHALL 解析响应体为 JSON 并用 JSONPath 子集提取值。`css` SHALL 使用 CSS selector 从 HTML 中提取元素文本或属性。`xpath` SHALL 使用 XPath 从 XML/HTML 中提取节点值。Extractor 未配置 matcher 时,resolve 阶段 SHALL 将其 matcher 物化为 `{exists: true}`。 #### Scenario: contains 子串匹配 - **WHEN** HTTP target 配置 `expect.body: [{contains: "healthy"}]`,且响应体包含 `"healthy"` -- **THEN** 系统 SHALL 判定该 body 规则通过 +- **THEN** 系统 SHALL 判定该 body expectation 通过 #### Scenario: regex 正则匹配 - **WHEN** HTTP target 配置 `expect.body: [{regex: '"status"\\s*:\\s*"ok"'}]`,且响应体匹配该正则 -- **THEN** 系统 SHALL 判定该 body 规则通过 +- **THEN** 系统 SHALL 判定该 body expectation 通过 #### Scenario: json JSONPath 等值匹配 - **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.status", equals: "ok"}}]`,且响应 JSON 中 `$.status` 值为 `"ok"` -- **THEN** 系统 SHALL 判定该 body 规则通过 +- **THEN** 系统 SHALL 判定该 body expectation 通过 #### Scenario: json JSONPath 存在性匹配 - **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.status"}}]`,且响应 JSON 中存在 `$.status` -- **THEN** 系统 SHALL 将该规则按 `exists: true` 语义判定通过 +- **THEN** resolve 阶段 SHALL 将该 expectation 按 `exists: true` 语义物化并在运行期判定通过 #### Scenario: css 选择器匹配 - **WHEN** HTTP target 配置 `expect.body: [{css: {selector: "div#health", equals: "OK"}}]`,且 HTML 中存在 `div#health` 元素文本为 `"OK"` -- **THEN** 系统 SHALL 判定该 body 规则通过 +- **THEN** 系统 SHALL 判定该 body expectation 通过 #### Scenario: css 选择器匹配属性值 -- **WHEN** HTTP target 配置 css 规则带 `attr: "content"` 用于提取属性,且属性值匹配期望 -- **THEN** 系统 SHALL 判定该 body 规则通过 +- **WHEN** HTTP target 配置 css expectation 带 `attr: "content"` 用于提取属性,且属性值匹配期望 +- **THEN** 系统 SHALL 判定该 body expectation 通过 #### Scenario: xpath 表达式匹配 - **WHEN** HTTP target 配置 `expect.body: [{xpath: {path: "/root/status/text()", equals: "ok"}}]`,且 XML 中 `/root/status` 节点文本为 `"ok"` -- **THEN** 系统 SHALL 判定该 body 规则通过 +- **THEN** 系统 SHALL 判定该 body expectation 通过 #### Scenario: 提取器无匹配目标失败 -- **WHEN** HTTP target 配置了 json、css 或 xpath 规则且对应路径、元素或节点不存在,并且规则未配置 `exists: false` +- **WHEN** HTTP target 配置了 json、css 或 xpath expectation 且对应路径、元素或节点不存在,并且 expectation 未配置 `exists: false` - **THEN** 系统 SHALL 判定 matched 为 false ### Requirement: 多种 body 校验方法 AND 组合 -系统 SHALL 支持在 `expect.body` 数组中同时配置多条内容规则,所有规则均通过时 matched 方为 true。系统 SHALL 按数组顺序执行规则,任一规则失败后 MUST NOT 继续执行后续规则。 +系统 SHALL 支持在 `expect.body` 数组中同时配置多条内容 expectation,所有 expectation 均通过时 matched 方为 true。系统 SHALL 按数组顺序执行 expectation,任一 expectation 失败后 MUST NOT 继续执行后续 expectation。 #### Scenario: 多种方法全部通过 - **WHEN** HTTP target 的 `expect.body` 数组依次配置 contains、json、regex,且全部通过 - **THEN** 系统 SHALL 判定 matched 为 true #### Scenario: 多种方法任一失败 -- **WHEN** HTTP target 的 `expect.body` 数组第一条 contains 不通过,后续还有 json 规则 -- **THEN** 系统 SHALL 判定 matched 为 false,且不再检查后续 json 规则 +- **WHEN** HTTP target 的 `expect.body` 数组第一条 contains 不通过,后续还有 json expectation +- **THEN** 系统 SHALL 判定 matched 为 false,且不再检查后续 json expectation #### Scenario: 直接 matcher 多字段组合 - **WHEN** HTTP target 配置 `expect.body: [{contains: "ok", regex: "status"}]`,且响应体同时满足 contains 和 regex -- **THEN** 系统 SHALL 判定该规则通过 +- **THEN** 系统 SHALL 判定该 expectation 通过 ### Requirement: 操作符系统 系统 SHALL 支持通过共享 `ValueMatcher` 对提取值和文本值进行比较:`equals`(深度等值)、`contains`(子串包含)、`regex`(正则匹配)、`empty`(空值判断)、`exists`(存在性判断)、`gte`/`lte`/`gt`/`lt`(数值比较)。系统 MUST NOT 支持旧 `match` 字段。 @@ -82,7 +82,7 @@ - **THEN** 系统 SHALL 对同一字段进行多 matcher 复合比较,全部通过则该规则通过 ### Requirement: 响应头校验 -系统 SHALL 支持通过共享 `KeyValueExpect` 配置 `expect.headers` 对 HTTP 响应头进行键值规则校验,header 名称匹配 MUST 不区分大小写。header 期望值 MAY 为字符串字面量或 `ValueMatcher`。字符串字面量 SHALL 等价于 `{equals: }`。 +系统 SHALL 支持通过共享 `KeyedExpectations` 配置 `expect.headers` 对 HTTP 响应头进行键值断言,header 名称匹配 MUST 不区分大小写。header 期望值 MUST 为 `RawValueExpectation`,primitive 字面量 SHALL 在 resolve 阶段等价为 `{equals: }`。 #### Scenario: 响应头字面量匹配 - **WHEN** HTTP target 配置 `expect.headers: {"Content-Type": "application/json"}`,且响应包含该 header 且值精确匹配 @@ -155,18 +155,18 @@ - **THEN** 系统 SHALL 在启动期配置校验失败 ### Requirement: HTTP expect 规则启动期校验 -系统 SHALL 在启动期校验 HTTP expect 中已支持字段的类型、格式、未知字段和可编译表达式。HTTP expect SHALL 只允许 `status`、`headers`、`body` 和 `durationMs` 字段。`expect.body` MUST 为 `ContentRules` 数组。直接 `ValueMatcher` 对象 MUST 至少包含一个合法 matcher。Extractor 规则 MUST 只包含 `json`、`css`、`xpath` 中的一种 extractor。Extractor 内部可以不配置 matcher,并 SHALL 在运行期以存在性作为通过语义。`equals` matcher SHALL 支持任意 JSON value,包括数组和对象。系统 SHALL 在启动期对所有 `regex` 执行静态 ReDoS 检测。 +系统 SHALL 在启动期校验 HTTP expect 中已支持字段的类型、格式、未知字段和可编译表达式。HTTP expect SHALL 只允许 `status`、`headers`、`body` 和 `durationMs` 字段。`expect.body` MUST 为 `RawContentExpectations` 数组。直接 `ValueMatcher` 对象 MUST 至少包含一个合法 matcher。Extractor expectation MUST 只包含 `json`、`css`、`xpath` 中的一种 extractor。Extractor 内部可以不配置 matcher,并 SHALL 在 resolve 阶段以存在性 matcher 作为通过语义。`equals` matcher SHALL 支持任意 JSON value,包括数组和对象。系统 SHALL 在启动期对所有 `regex` 执行静态 ReDoS 检测。语义校验 MUST NOT 修改 Raw HTTP expect 输入。 #### Scenario: body rule 使用 regex 字段 - **WHEN** HTTP target 配置 `expect.body: [{regex: "ok|healthy"}]` 且 regex 可编译且无 ReDoS 风险 -- **THEN** 系统 SHALL 接受该配置,并在运行期按 regex 规则匹配响应体 +- **THEN** 系统 SHALL 接受该配置,并在运行期按 regex expectation 匹配响应体 #### Scenario: body rule 不支持 match 字段 - **WHEN** HTTP target 配置 `expect.body: [{match: "ok"}]` - **THEN** 系统 SHALL 在启动期配置校验失败,提示 `match` 是未知字段或不支持字段 #### Scenario: body rule 多 extractor 非法 -- **WHEN** HTTP target 的同一条 body rule 同时配置 `json` 和 `css` +- **WHEN** HTTP target 的同一条 body expectation 同时配置 `json` 和 `css` - **THEN** 系统 SHALL 在启动期配置校验失败 #### Scenario: matcher regex 正则非法 @@ -182,7 +182,7 @@ - **THEN** 系统 SHALL 在启动期配置校验失败 #### Scenario: JSONPath 子集非法 -- **WHEN** HTTP target 的 json body rule path 不符合系统支持的 JSONPath 子集 +- **WHEN** HTTP target 的 json body expectation path 不符合系统支持的 JSONPath 子集 - **THEN** 系统 SHALL 在启动期配置校验失败 #### Scenario: matcher 未知字段非法 @@ -190,20 +190,20 @@ - **THEN** 系统 SHALL 在启动期配置校验失败 #### Scenario: durationMs matcher 非法 -- **WHEN** HTTP target 配置 `expect.durationMs` 不是合法 `ValueMatcher` 或其中数值 matcher 不是有限数字 +- **WHEN** HTTP target 配置 `expect.durationMs` 不是合法 `RawValueExpectation` 或其中数值 matcher 不是有限数字 - **THEN** 系统 SHALL 在启动期配置校验失败 ### Requirement: HTTP body 运行期失败结构化 -系统 SHALL 将 HTTP body 运行期失败记录为结构化 CheckFailure,并保留与具体规则相关的 phase 和 path。响应内容不符合配置 SHALL 记录为 mismatch;响应内容无法按配置解析或解码 SHALL 记录为 error。 +系统 SHALL 将 HTTP body 运行期失败记录为结构化 CheckFailure,并保留与具体 expectation 相关的 phase 和 path。响应内容不符合配置 SHALL 记录为 mismatch;响应内容无法按配置解析或解码 SHALL 记录为 error。 #### Scenario: JSON 响应不是合法 JSON -- **WHEN** HTTP target 配置 json body rule,但响应体不是合法 JSON -- **THEN** 系统 SHALL 记录 `failure.kind="error"`、`failure.phase="body"`,且 failure.path SHALL 指向对应 json 规则 +- **WHEN** HTTP target 配置 json body expectation,但响应体不是合法 JSON +- **THEN** 系统 SHALL 记录 `failure.kind="error"`、`failure.phase="body"`,且 failure.path SHALL 指向对应 json expectation #### Scenario: CSS selector 无匹配元素 -- **WHEN** HTTP target 配置 css body rule,但响应 HTML 中无匹配元素 -- **THEN** 系统 SHALL 记录 `failure.kind="mismatch"`、`failure.phase="body"`,且 failure.path SHALL 指向对应 css 规则 +- **WHEN** HTTP target 配置 css body expectation,但响应 HTML 中无匹配元素 +- **THEN** 系统 SHALL 记录 `failure.kind="mismatch"`、`failure.phase="body"`,且 failure.path SHALL 指向对应 css expectation #### Scenario: XPath 无匹配节点 -- **WHEN** HTTP target 配置 xpath body rule,但响应 XML/HTML 中无匹配节点 -- **THEN** 系统 SHALL 记录 `failure.kind="mismatch"`、`failure.phase="body"`,且 failure.path SHALL 指向对应 xpath 规则 +- **WHEN** HTTP target 配置 xpath body expectation,但响应 XML/HTML 中无匹配节点 +- **THEN** 系统 SHALL 记录 `failure.kind="mismatch"`、`failure.phase="body"`,且 failure.path SHALL 指向对应 xpath expectation diff --git a/openspec/specs/expect-rule-system/spec.md b/openspec/specs/expect-rule-system/spec.md index 7ae5fd6..73e1fca 100644 --- a/openspec/specs/expect-rule-system/spec.md +++ b/openspec/specs/expect-rule-system/spec.md @@ -1,13 +1,13 @@ ## Purpose -定义共享 expect 断言规则系统的核心概念和基础设施:ValueMatcher 统一匹配器、ContentRules 内容规则数组、KeyValueExpect 键值规则、以及相关的启动期校验和失败路径规范。 +定义共享 expect 断言规则系统的核心概念和基础设施:ValueMatcher 统一匹配器、ContentExpectations 内容断言数组、KeyedExpectations 键控断言数组、以及相关的启动期校验和失败路径规范。 ## Requirements ### Requirement: ValueMatcher 统一匹配器 -系统 SHALL 提供共享 `ValueMatcher` 作为所有非状态类 expect 的基础匹配结构。`ValueMatcher` SHALL 支持 `equals`、`contains`、`regex`、`exists`、`empty`、`gt`、`gte`、`lt` 和 `lte` 字段。`equals` MUST 支持任意 JSON value,并使用深度相等比较。`contains` 和 `regex` SHALL 将实际值转换为字符串后匹配。`gt`、`gte`、`lt` 和 `lte` SHALL 将实际值转换为有限数字后比较,无法转换为有限数字时 SHALL 判定不匹配。一个 `ValueMatcher` 对象包含多个 matcher 字段时,系统 SHALL 要求全部 matcher 均通过。 +系统 SHALL 提供共享 `ValueMatcher` 作为所有非状态类 value expectation 的基础匹配结构。`ValueMatcher` SHALL 支持 `equals`、`contains`、`regex`、`exists`、`empty`、`gt`、`gte`、`lt` 和 `lte` 字段。`equals` MUST 支持任意 JSON value,并使用深度相等比较。`contains` 和 `regex` SHALL 将实际值转换为字符串后匹配。`gt`、`gte`、`lt` 和 `lte` SHALL 将实际值转换为有限数字后比较,无法转换为有限数字时 SHALL 判定不匹配。一个 `ValueMatcher` 对象包含多个 matcher 字段时,系统 SHALL 要求全部 matcher 均通过。 -所有类型为 `ValueMatcher` 的 expect 字段 SHALL 同时接受 primitive 原始值(string / number / boolean / null)作为简写形式。原始值简写 SHALL 等价于 `{ equals: value }`。系统 SHALL 在语义校验入口将 primitive 原始值归一化为 `{ equals: value }` 对象形式,后续 resolve 和运行期逻辑 SHALL 仅处理 ValueMatcher 对象形式。数组和对象 MUST NOT 作为原始值简写;需要对数组或对象执行 equals 匹配时,配置 MUST 显式写成 `{ equals: value }`。 +所有类型为 `RawValueExpectation` 的 expect 字段 SHALL 同时接受 primitive 原始值(string / number / boolean / null)作为简写形式。原始值简写 SHALL 等价于 `{ equals: value }`。系统 SHALL 在 resolve 阶段将 primitive 原始值归一化为 `{ equals: value }` 对象形式,运行期逻辑 SHALL 仅处理 `ValueMatcher` 对象形式。数组和对象 MUST NOT 作为原始值简写;需要对数组或对象执行 equals 匹配时,配置 MUST 显式写成 `{ equals: value }`。 #### Scenario: equals 匹配对象 - **WHEN** 实际值为 `{status: "ok", count: 1}` 且 matcher 为 `{equals: {status: "ok", count: 1}}` @@ -27,26 +27,26 @@ #### Scenario: 字符串原始值简写等价 equals - **WHEN** expect 字段配置为 `finishReason: "stop"` 且实际值为 `"stop"` -- **THEN** 系统 SHALL 将 `"stop"` 归一化为 `{equals: "stop"}` 并判定通过 +- **THEN** 系统 SHALL 在 resolve 阶段将 `"stop"` 归一化为 `{equals: "stop"}` 并判定通过 #### Scenario: 数字原始值简写等价 equals - **WHEN** expect 字段配置为 `rowCount: 1` 且实际值为 `1` -- **THEN** 系统 SHALL 将 `1` 归一化为 `{equals: 1}` 并判定通过 +- **THEN** 系统 SHALL 在 resolve 阶段将 `1` 归一化为 `{equals: 1}` 并判定通过 #### Scenario: 布尔原始值简写等价 equals -- **WHEN** expect 字段配置为 ValueMatcher 类型且值为 `true`,实际值为 `true` -- **THEN** 系统 SHALL 将 `true` 归一化为 `{equals: true}` 并判定通过 +- **WHEN** expect 字段配置为 RawValueExpectation 类型且值为 `true`,实际值为 `true` +- **THEN** 系统 SHALL 在 resolve 阶段将 `true` 归一化为 `{equals: true}` 并判定通过 #### Scenario: null 原始值简写等价 equals -- **WHEN** expect 字段配置为 ValueMatcher 类型且值为 `null`,实际值为 `null` -- **THEN** 系统 SHALL 将 `null` 归一化为 `{equals: null}` 并判定通过 +- **WHEN** expect 字段配置为 RawValueExpectation 类型且值为 `null`,实际值为 `null` +- **THEN** 系统 SHALL 在 resolve 阶段将 `null` 归一化为 `{equals: null}` 并判定通过 #### Scenario: 原始值简写不匹配 - **WHEN** expect 字段配置为 `finishReason: "stop"` 且实际值为 `"error"` - **THEN** 系统 SHALL 判定不通过并生成 mismatch failure ### Requirement: ValueMatcher 启动期校验 -系统 SHALL 在启动期对所有 `ValueMatcher` 字段执行严格的类型和语义校验。校验 SHALL 同时接受 primitive 原始值和 ValueMatcher 对象两种形式。当输入为 primitive 原始值时,系统 SHALL 视为合法配置(等价于 `{equals: value}`),无需进一步校验 matcher 字段。当输入为 ValueMatcher 对象时,`contains` MUST 为 string。`equals` MAY 为任意 JSON value。`exists` 和 `empty` MUST 为 boolean。`gt`、`gte`、`lt` 和 `lte` MUST 为有限数字(`Number.isFinite`)。`regex` MUST 为可编译的 string pattern,并通过 ReDoS 风险校验。ValueMatcher 对象 MUST 至少包含一个合法 matcher 字段,空对象 `{}` SHALL 导致启动期配置错误。ValueMatcher 对象 MUST NOT 包含未知字段,任何不属于 `equals`、`contains`、`regex`、`exists`、`empty`、`gt`、`gte`、`lt`、`lte` 的字段 SHALL 导致启动期配置错误。 +系统 SHALL 在启动期对所有 `RawValueExpectation` 字段执行严格的类型和语义校验。校验 SHALL 同时接受 primitive 原始值和 `ValueMatcher` 对象两种形式,但 MUST NOT 修改输入对象。当输入为 primitive 原始值时,系统 SHALL 视为合法配置(等价于 `{equals: value}`),无需进一步校验 matcher 字段。当输入为 `ValueMatcher` 对象时,`contains` MUST 为 string。`equals` MAY 为任意 JSON value。`exists` 和 `empty` MUST 为 boolean。`gt`、`gte`、`lt` 和 `lte` MUST 为有限数字(`Number.isFinite`)。`regex` MUST 为可编译的 string pattern,并通过 ReDoS 风险校验。`ValueMatcher` 对象 MUST 至少包含一个合法 matcher 字段,空对象 `{}` SHALL 导致启动期配置错误。`ValueMatcher` 对象 MUST NOT 包含未知字段,任何不属于 `equals`、`contains`、`regex`、`exists`、`empty`、`gt`、`gte`、`lt`、`lte` 的字段 SHALL 导致启动期配置错误。 #### Scenario: 空 matcher 对象被拒绝 - **WHEN** YAML 配置中任一 matcher 对象为空 `{}` @@ -65,23 +65,23 @@ - **THEN** 系统 SHALL 在启动期配置校验失败,提示该字段必须为布尔值 #### Scenario: 字符串原始值校验通过 -- **WHEN** YAML 配置中 ValueMatcher 字段值为字符串 `"stop"` -- **THEN** 系统 SHALL 接受该配置,视为 `{equals: "stop"}` +- **WHEN** YAML 配置中 RawValueExpectation 字段值为字符串 `"stop"` +- **THEN** 系统 SHALL 接受该配置,但 SHALL NOT 在语义校验阶段修改输入对象 #### Scenario: 数字原始值校验通过 -- **WHEN** YAML 配置中 ValueMatcher 字段值为数字 `5000` -- **THEN** 系统 SHALL 接受该配置,视为 `{equals: 5000}` +- **WHEN** YAML 配置中 RawValueExpectation 字段值为数字 `5000` +- **THEN** 系统 SHALL 接受该配置,但 SHALL NOT 在语义校验阶段修改输入对象 #### Scenario: null 原始值校验通过 -- **WHEN** YAML 配置中 ValueMatcher 字段值为 `null` -- **THEN** 系统 SHALL 接受该配置,视为 `{equals: null}` +- **WHEN** YAML 配置中 RawValueExpectation 字段值为 `null` +- **THEN** 系统 SHALL 接受该配置,但 SHALL NOT 在语义校验阶段修改输入对象 #### Scenario: 数组原始值被拒绝 -- **WHEN** YAML 配置中 ValueMatcher 字段值为数组 `[1, 2]` +- **WHEN** YAML 配置中 RawValueExpectation 字段值为数组 `[1, 2]` - **THEN** 系统 SHALL 在启动期配置校验失败,提示必须为 primitive 原始值或 matcher 对象;如需数组 equals 匹配应写成 `{equals: [1, 2]}` #### Scenario: 对象原始值必须显式 equals -- **WHEN** YAML 配置中 ValueMatcher 字段值为对象 `{foo: "bar"}`,且 `foo` 不是合法 matcher 字段 +- **WHEN** YAML 配置中 RawValueExpectation 字段值为对象 `{foo: "bar"}`,且 `foo` 不是合法 matcher 字段 - **THEN** 系统 SHALL 在启动期配置校验失败,提示 `foo` 是未知 matcher;如需对象 equals 匹配应写成 `{equals: {foo: "bar"}}` ### Requirement: empty matcher 语义 @@ -141,92 +141,108 @@ - **WHEN** YAML 配置中任一 `regex` 为 `"(a+)+$"` - **THEN** 系统 SHALL 在启动期配置校验失败,提示正则存在 ReDoS 风险 -### Requirement: ContentRules 内容规则数组 -系统 SHALL 提供共享 `ContentRules` 表达返回内容断言。`ContentRules` MUST 为有序数组,数组项 SHALL 为直接 `ValueMatcher`,或 `json`、`css`、`xpath` 三类 extractor 规则之一。系统 SHALL 按数组顺序执行全部规则,任一规则失败时 SHALL 立即停止并返回该规则的 failure。系统 MUST NOT 支持内容字段的非数组对象快捷写法。 +### Requirement: ContentExpectations 内容断言数组 +系统 SHALL 提供共享 `ContentExpectations` 表达返回内容断言。`ContentExpectations` MUST 为有序数组,数组项 SHALL 为直接 `ValueMatcher`,或 `json`、`css`、`xpath` 三类 extractor expectation 之一。系统 SHALL 在 resolve 阶段将 Raw content DSL 解析为带 `kind` 字段的 Resolved `ContentExpectation` 执行计划,并按数组顺序执行全部 expectation,任一 expectation 失败时 SHALL 立即停止并返回该 expectation 的 failure。系统 MUST NOT 支持内容字段的非数组对象快捷写法。 -#### Scenario: 直接 matcher 内容规则 -- **WHEN** 内容字段配置 `[{contains: "ready"}, {regex: "listening on \\d+"}]` 且原始内容同时满足两条规则 +#### Scenario: 直接 matcher 内容 expectation +- **WHEN** 内容字段配置 `[{contains: "ready"}, {regex: "listening on \\d+"}]` 且原始内容同时满足两条 expectation - **THEN** 系统 SHALL 判定该内容字段通过 -#### Scenario: 内容规则数组快速失败 -- **WHEN** 内容字段配置三条规则且第二条规则失败 -- **THEN** 系统 SHALL 返回第二条规则的 failure,并 MUST NOT 执行第三条规则 +#### Scenario: 内容 expectation 数组快速失败 +- **WHEN** 内容字段配置三条 expectation 且第二条 expectation 失败 +- **THEN** 系统 SHALL 返回第二条 expectation 的 failure,并 MUST NOT 执行第三条 expectation #### Scenario: 内容字段必须为数组 - **WHEN** YAML 中内容字段配置为 `{contains: "ok"}` 而不是数组 -- **THEN** 系统 SHALL 在启动期配置校验失败,提示该内容字段必须为规则数组 +- **THEN** 系统 SHALL 在启动期配置校验失败,提示该内容字段必须为 expectation 数组 -### Requirement: ContentRule 互斥性约束 -一条 `ContentRule` MUST 为直接 `ValueMatcher` 或恰好一个 extractor(`json`、`css`、`xpath` 之一)。系统 MUST NOT 允许同一条规则同时包含多个 extractor。直接 `ValueMatcher` 规则 MUST NOT 包含 `json`、`css`、`xpath` 字段。系统 SHALL 在启动期对违反互斥性的规则报错。 +#### Scenario: Resolved content expectation 使用 kind +- **WHEN** Raw 内容字段包含直接 matcher、json extractor、css extractor 和 xpath extractor +- **THEN** resolve 阶段 SHALL 分别生成 `kind="value"`、`kind="json"`、`kind="css"` 和 `kind="xpath"` 的 Resolved `ContentExpectation` + +### Requirement: ContentExpectation 互斥性约束 +一条 Raw `ContentExpectation` MUST 为直接 `ValueMatcher` 或恰好一个 extractor(`json`、`css`、`xpath` 之一)。系统 MUST NOT 允许同一条 Raw expectation 同时包含多个 extractor。直接 `ValueMatcher` expectation MUST NOT 包含 `json`、`css`、`xpath` 字段。系统 SHALL 在启动期对违反互斥性的 Raw expectation 报错。 #### Scenario: 多 extractor 被拒绝 -- **WHEN** YAML 中内容规则为 `{json: {path: "$.a"}, css: {selector: "div"}}` -- **THEN** 系统 SHALL 在启动期配置校验失败,提示一条规则不能同时包含多个 extractor +- **WHEN** YAML 中内容 expectation 为 `{json: {path: "$.a"}, css: {selector: "div"}}` +- **THEN** 系统 SHALL 在启动期配置校验失败,提示一条 expectation 不能同时包含多个 extractor #### Scenario: 直接 matcher 混入 extractor 被拒绝 -- **WHEN** YAML 中内容规则为 `{contains: "ok", json: {path: "$.a"}}` +- **WHEN** YAML 中内容 expectation 为 `{contains: "ok", json: {path: "$.a"}}` - **THEN** 系统 SHALL 在启动期配置校验失败,提示直接 matcher 不能与 extractor 混用 -### Requirement: 空 ContentRules 数组语义 -`ContentRules` 空数组 `[]` SHALL 被系统接受为合法配置。运行期空数组 SHALL 等价于无规则,即该内容字段的断言直接通过。 +### Requirement: 空 ContentExpectations 数组语义 +`ContentExpectations` 空数组 `[]` SHALL 被系统接受为合法配置。运行期空数组 SHALL 等价于无内容 expectation,即该内容字段的断言直接通过。 #### Scenario: 空 body 数组通过 - **WHEN** HTTP target 配置 `expect.body: []` 且响应体为任意内容 - **THEN** 系统 SHALL 判定 body 阶段通过 -### Requirement: ContentRules 非字符串值序列化 -当 `ContentRules` 的观测源为非字符串值(如对象或数组)时,直接 `ValueMatcher` 的 `contains` 和 `regex` SHALL 先将值 JSON 序列化为字符串后匹配。`equals` SHALL 直接在原始结构化值上使用深度相等比较,不进行序列化。 +### Requirement: ContentExpectations 非字符串值序列化 +当 `ContentExpectations` 的观测源为非字符串值(如对象或数组)时,直接 `ValueMatcher` 的 `contains` 和 `regex` SHALL 先将值 JSON 序列化为字符串后匹配。`equals` SHALL 直接在原始结构化值上使用深度相等比较,不进行序列化。 #### Scenario: 对象序列化后 contains 匹配 -- **WHEN** ContentRules 观测源为 `{status: "ok"}` 且规则为 `{contains: "ok"}` +- **WHEN** ContentExpectations 观测源为 `{status: "ok"}` 且 expectation 为 `{contains: "ok"}` - **THEN** 系统 SHALL 将对象 JSON 序列化后执行 contains 匹配 #### Scenario: 对象 equals 不序列化 -- **WHEN** ContentRules 观测源为 `{status: "ok"}` 且规则为 `{equals: {status: "ok"}}` +- **WHEN** ContentExpectations 观测源为 `{status: "ok"}` 且 expectation 为 `{equals: {status: "ok"}}` - **THEN** 系统 SHALL 直接在结构化值上使用深度相等比较 -### Requirement: ContentRules 提取器 -系统 SHALL 支持在 `ContentRules` 中使用 `json`、`css` 和 `xpath` extractor。`json.path` MUST 使用现有 JSONPath 子集。`css.selector` MUST 为非空字符串,并 MAY 配置 `attr` 提取属性值。`xpath.path` MUST 为非空字符串,并 SHALL 在启动期进行可编译校验。Extractor 内部 MAY 包含任意 `ValueMatcher` 字段。Extractor 规则未配置任何 matcher 时 SHALL 等价于 `exists: true`。 +### Requirement: ContentExpectations 提取器 +系统 SHALL 支持在 `ContentExpectations` 中使用 `json`、`css` 和 `xpath` extractor。`json.path` MUST 使用现有 JSONPath 子集。`css.selector` MUST 为非空字符串,并 MAY 配置 `attr` 提取属性值。`xpath.path` MUST 为非空字符串,并 SHALL 在启动期进行可编译校验。Extractor 内部 MAY 包含任意 `ValueMatcher` 字段。Extractor expectation 未配置任何 matcher 时,resolve 阶段 SHALL 将其 Resolved matcher 物化为 `{ exists: true }`。 #### Scenario: json extractor 数字比较 -- **WHEN** 原始内容为 JSON 字符串 `{"count": 2}` 且规则为 `{json: {path: "$.count", gte: 1}}` -- **THEN** 系统 SHALL 解析 JSON、提取 `$.count` 并判定该规则通过 +- **WHEN** 原始内容为 JSON 字符串 `{"count": 2}` 且 expectation 为 `{json: {path: "$.count", gte: 1}}` +- **THEN** 系统 SHALL 解析 JSON、提取 `$.count` 并判定该 expectation 通过 #### Scenario: json extractor 存在性默认语义 -- **WHEN** 原始内容为 JSON 字符串 `{"user": {"id": null}}` 且规则为 `{json: {path: "$.user.id"}}` -- **THEN** 系统 SHALL 将该规则视为 `{json: {path: "$.user.id", exists: true}}` 并判定通过 +- **WHEN** 原始内容为 JSON 字符串 `{"user": {"id": null}}` 且 expectation 为 `{json: {path: "$.user.id"}}` +- **THEN** resolve 阶段 SHALL 将该 expectation 的 matcher 物化为 `{exists: true}` 并在运行期判定通过 #### Scenario: css attr 存在性默认语义 -- **WHEN** 原始内容包含 `` 且规则为 `{css: {selector: "meta[name=status]", attr: "content"}}` -- **THEN** 系统 SHALL 在属性存在时判定该规则通过 +- **WHEN** 原始内容包含 `` 且 expectation 为 `{css: {selector: "meta[name=status]", attr: "content"}}` +- **THEN** resolve 阶段 SHALL 将该 expectation 的 matcher 物化为 `{exists: true}` 并在属性存在时判定通过 #### Scenario: xpath 无匹配节点失败 -- **WHEN** XML 内容中不存在 XPath 指向的节点,且规则为 `{xpath: {path: "/root/status"}}` -- **THEN** 系统 SHALL 判定该规则不通过并生成 phase 对应内容字段的 mismatch failure +- **WHEN** XML 内容中不存在 XPath 指向的节点,且 expectation 为 `{xpath: {path: "/root/status"}}` +- **THEN** 系统 SHALL 判定该 expectation 不通过并生成 phase 对应内容字段的 mismatch failure -### Requirement: KeyValueExpect 键值规则 -系统 SHALL 提供共享 `KeyValueExpect` 表达键值型观测值断言。`KeyValueExpect` SHALL 为动态键对象,每个键对应的值 MAY 为 `ValueMatcher` 或 JSON 字面量。字面量值 SHALL 等价于 `{equals: }`。调用方 MAY 指定 key 规范化策略;HTTP 与 LLM headers MUST 使用大小写不敏感的 key 匹配。 +### 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 归一化,按查询结果列名大小写敏感匹配。 #### Scenario: headers 字面量快捷写法 - **WHEN** 响应 headers 中 `content-type` 为 `application/json`,且配置为 `headers: {Content-Type: "application/json"}` -- **THEN** 系统 SHALL 按大小写不敏感 key 匹配并使用 equals 语义判定通过 +- **THEN** resolve 阶段 SHALL 将该项解析为 keyed expectation `{key: "Content-Type", matcher: {equals: "application/json"}}`,运行期按大小写不敏感 key 匹配并判定通过 #### Scenario: headers matcher 写法 - **WHEN** 响应 headers 中 `content-type` 为 `application/json; charset=utf-8`,且配置为 `headers: {Content-Type: {contains: "application/json"}}` -- **THEN** 系统 SHALL 判定该 header 规则通过 +- **THEN** 系统 SHALL 判定该 header expectation 通过 #### Scenario: 缺失键 exists false - **WHEN** 观测键值表中不存在 `x-debug`,且配置为 `{x-debug: {exists: false}}` -- **THEN** 系统 SHALL 判定该键规则通过 +- **THEN** 系统 SHALL 判定该 keyed expectation 通过 + +#### Scenario: keyed 对象值必须显式 equals +- **WHEN** Raw keyed expectation 的某个值是对象 `{foo: "bar"}` 且未写在 `equals` 下 +- **THEN** 系统 SHALL 在启动期配置校验失败,提示对象 equals 必须显式写成 `{equals: {foo: "bar"}}` + +#### Scenario: keyed 数组值必须显式 equals +- **WHEN** Raw keyed expectation 的某个值是数组 `["a"]` 且未写在 `equals` 下 +- **THEN** 系统 SHALL 在启动期配置校验失败,提示数组 equals 必须显式写成 `{equals: ["a"]}` + +#### Scenario: header 归一化重复 key 被拒绝 +- **WHEN** HTTP 或 LLM `expect.headers` 同时配置 `Content-Type` 和 `content-type` +- **THEN** 系统 SHALL 在启动期配置校验失败,提示 header key 归一化后重复 ### Requirement: 结构化失败路径 -系统 SHALL 在共享 matcher、content 和 key-value 断言失败时生成结构化 `CheckFailure`。failure SHALL 包含 `kind`、`phase`、`path`、`message`,并在 mismatch 场景包含 `expected` 和 `actual`。内容规则 failure path SHALL 包含数组下标,key-value failure path SHALL 包含键名,extractor failure path SHALL 包含 extractor 类型和 path/selector 信息。 +系统 SHALL 在共享 matcher、content 和 keyed expectation 断言失败时生成结构化 `CheckFailure`。failure SHALL 包含 `kind`、`phase`、`path`、`message`,并在 mismatch 场景包含 `expected` 和 `actual`。内容 expectation failure path SHALL 包含数组下标,keyed expectation failure path SHALL 包含键名,extractor failure path SHALL 包含 extractor 类型和 path/selector 信息。failure.expected SHOULD 使用用户可理解的 matcher 或 expectation 片段,MUST NOT 直接暴露 Resolved `kind` 执行计划;单字段 `equals` 包装 SHOULD 展示为原始 expected 值。 -#### Scenario: ContentRules 失败路径 -- **WHEN** `expect.body[1].json` 规则失败 +#### Scenario: ContentExpectations 失败路径 +- **WHEN** `expect.body[1].json` expectation 失败 - **THEN** failure.path SHALL 指向 `body[1].json($.path)` 或等价可定位路径,failure.phase SHALL 为 `body` -#### Scenario: KeyValueExpect 失败路径 +#### Scenario: KeyedExpectations 失败路径 - **WHEN** `expect.headers.Content-Type` 不匹配 - **THEN** failure.path SHALL 指向 `headers.Content-Type`,failure.phase SHALL 为 `headers` diff --git a/openspec/specs/icmp-checker/spec.md b/openspec/specs/icmp-checker/spec.md index 38cecd9..2507d1e 100644 --- a/openspec/specs/icmp-checker/spec.md +++ b/openspec/specs/icmp-checker/spec.md @@ -110,11 +110,11 @@ - **THEN** 系统 SHALL 记录 `matched=false`,failure 的 kind 为 `error`,phase 为 `icmp`,path 为 `parse`,message 包含 "无法解析 icmp 输出" ### Requirement: icmp expect 校验 -系统 SHALL 支持 icmp 专属 expect,包括 `alive`、`packetLossPercent`、`avgLatencyMs`、`maxLatencyMs` 和 `durationMs`,并按 alive、packetLossPercent、avgLatencyMs、maxLatencyMs、durationMs 的阶段顺序快速失败。`alive` SHALL 保持布尔状态语义,未配置时默认 `true`。`packetLossPercent` SHALL 表示 0 到 100 的丢包率百分比,并使用共享 `ValueMatcher`。`avgLatencyMs`、`maxLatencyMs` 和 `durationMs` SHALL 使用共享 `ValueMatcher`。 +系统 SHALL 支持 icmp 专属 expect,包括 `alive`、`packetLossPercent`、`avgLatencyMs`、`maxLatencyMs` 和 `durationMs`,并按 alive、packetLossPercent、avgLatencyMs、maxLatencyMs、durationMs 的阶段顺序快速失败。`alive` SHALL 保持布尔状态语义,未配置时在 Resolved expect 中默认 `true`。`packetLossPercent` SHALL 表示 0 到 100 的丢包率百分比,并使用共享 `RawValueExpectation` 输入、运行期 `ValueExpectation` 执行。`avgLatencyMs`、`maxLatencyMs` 和 `durationMs` SHALL 使用共享 `RawValueExpectation` 输入、运行期 `ValueExpectation` 执行。 #### Scenario: 默认 alive 成功语义 - **WHEN** icmp target 未显式配置 `expect.alive` -- **THEN** 系统 SHALL 使用默认 `expect.alive: true` 进行校验 +- **THEN** 系统 SHALL 在 Resolved icmp expect 中使用默认 `alive: true` 进行校验 #### Scenario: alive 校验通过 - **WHEN** icmp target 配置 `expect.alive: true`,且目标主机可达 @@ -165,17 +165,21 @@ - **THEN** 系统 SHALL 以配置错误退出,提示 expect 包含未知字段 #### Scenario: packetLossPercent 类型非法 -- **WHEN** YAML 中 icmp target 的 `expect.packetLossPercent` 不是合法 `ValueMatcher`,或其数值范围无法用于 0 到 100 的百分比断言 +- **WHEN** YAML 中 icmp target 的 `expect.packetLossPercent` 不是合法 `RawValueExpectation`,或其数值范围无法用于 0 到 100 的百分比断言 - **THEN** 系统 SHALL 以配置错误退出,提示 expect.packetLossPercent 格式错误 #### Scenario: avgLatencyMs 类型非法 -- **WHEN** YAML 中 icmp target 的 `expect.avgLatencyMs` 不是合法 `ValueMatcher` +- **WHEN** YAML 中 icmp target 的 `expect.avgLatencyMs` 不是合法 `RawValueExpectation` - **THEN** 系统 SHALL 以配置错误退出,提示 expect.avgLatencyMs 格式错误 #### Scenario: maxLatencyMs 类型非法 -- **WHEN** YAML 中 icmp target 的 `expect.maxLatencyMs` 不是合法 `ValueMatcher` +- **WHEN** YAML 中 icmp target 的 `expect.maxLatencyMs` 不是合法 `RawValueExpectation` - **THEN** 系统 SHALL 以配置错误退出,提示 expect.maxLatencyMs 格式错误 +#### Scenario: Raw icmp expect 不被校验阶段修改 +- **WHEN** YAML 中 icmp target 配置 `expect.durationMs: 5000` +- **THEN** 语义校验 SHALL 接受该 Raw primitive 简写且 MUST NOT 将输入对象原地改写为 `{equals: 5000}` + ### Requirement: icmp detail 摘要 系统 SHALL 在 icmp API 序列化时从 observation 动态生成结构化 detail 摘要,展示关键指标。API registry type SHALL 仍为 `icmp`。 diff --git a/openspec/specs/llm-checker/spec.md b/openspec/specs/llm-checker/spec.md index be19d9b..5757cec 100644 --- a/openspec/specs/llm-checker/spec.md +++ b/openspec/specs/llm-checker/spec.md @@ -108,18 +108,18 @@ LLM checker SHALL 在 SDK 调用结果和 expect 断言之间构建 `LlmCheckObs - **THEN** LLM checker SHALL 返回 `phase: "request"` 的 error failure ### Requirement: LLM Expect 断言 -LLM checker SHALL 支持 `expect.status`、`expect.headers`、`expect.output`、`expect.finishReason`、`expect.rawFinishReason`、`expect.usage.inputTokens`、`expect.usage.outputTokens`、`expect.usage.totalTokens`、`expect.stream.completed`、`expect.stream.firstTokenMs` 和 `expect.durationMs`。`expect.status` SHALL 保持 HTTP 状态码数组语义并复用 HTTP status 断言。`expect.headers` SHALL 使用共享 `KeyValueExpect` 且 header key 大小写不敏感。`expect.output` MUST 使用共享 `ContentRules`。`expect.finishReason` 和 `expect.rawFinishReason` SHALL 使用共享 `ValueMatcher`。`expect.usage.*`、`expect.stream.firstTokenMs` 和 `expect.durationMs` SHALL 使用共享 `ValueMatcher`。LLM checker MUST 按固定顺序快速失败,非流式顺序为 status、headers、output、finishReason、rawFinishReason、usage、durationMs;流式顺序为 status、headers、stream.completed、stream.firstTokenMs、output、finishReason、rawFinishReason、usage、durationMs。 +LLM checker SHALL 支持 `expect.status`、`expect.headers`、`expect.output`、`expect.finishReason`、`expect.rawFinishReason`、`expect.usage.inputTokens`、`expect.usage.outputTokens`、`expect.usage.totalTokens`、`expect.stream.completed`、`expect.stream.firstTokenMs` 和 `expect.durationMs`。`expect.status` SHALL 保持 HTTP 状态码数组语义并复用共享 status code 断言,未配置时在 Resolved expect 中物化默认 `[200]`。`expect.headers` SHALL 使用共享 `RawKeyedExpectations` 输入并在运行期使用 `KeyedExpectations`,header key 大小写不敏感。`expect.output` MUST 使用共享 `RawContentExpectations` 输入并在运行期使用 `ContentExpectations`。`expect.finishReason` 和 `expect.rawFinishReason` SHALL 使用共享 `RawValueExpectation` 输入并在运行期使用 `ValueExpectation`。`expect.usage.*`、`expect.stream.firstTokenMs` 和 `expect.durationMs` SHALL 使用共享 `RawValueExpectation` 输入并在运行期使用 `ValueExpectation`。LLM checker MUST 按固定顺序快速失败,非流式顺序为 status、headers、output、finishReason、rawFinishReason、usage、durationMs;流式顺序为 status、headers、stream.completed、stream.firstTokenMs、output、finishReason、rawFinishReason、usage、durationMs。 #### Scenario: 默认 status 断言 - **WHEN** LLM target 未配置 `expect.status` -- **THEN** LLM checker SHALL 使用默认 `status: [200]` 语义 +- **THEN** LLM checker SHALL 在 Resolved expect 中使用默认 `status: [200]` 语义 #### Scenario: expect headers 通过 - **WHEN** observing fetch 捕获的响应 headers 满足 `expect.headers` 配置 -- **THEN** LLM checker SHALL 判定 headers 断言通过 +- **THEN** LLM checker SHALL 通过共享 header expectation 包装函数判定 headers 断言通过 -#### Scenario: output ContentRules 通过 -- **WHEN** LLM 输出文本满足 `expect.output` 中配置的全部 ContentRules +#### Scenario: output ContentExpectations 通过 +- **WHEN** LLM 输出文本满足 `expect.output` 中配置的全部 ContentExpectations - **THEN** LLM checker SHALL 判定 output 阶段通过 #### Scenario: finishReason ValueMatcher 通过 @@ -175,18 +175,22 @@ LLM checker SHALL 使用共享 `ContentRules` 校验 `expect.output`。每个 ou #### Scenario: output JSONPath 存在性默认语义 - **WHEN** `outputText` 是 JSON 字符串且 target 配置 `expect.output: [{json: {path: "$.status"}}]` -- **THEN** LLM checker SHALL 将该规则按 `exists: true` 语义执行 +- **THEN** resolve 阶段 SHALL 将该 expectation 的 matcher 物化为 `{exists: true}`,运行期按存在性语义执行 #### Scenario: output 规则按顺序快速失败 -- **WHEN** `expect.output` 包含多个规则且第一条规则失败 -- **THEN** LLM checker SHALL 返回第一条失败规则的 mismatch failure,不继续校验后续 output 规则 +- **WHEN** `expect.output` 包含多个 expectation 且第一条 expectation 失败 +- **THEN** LLM checker SHALL 返回第一条失败 expectation 的 mismatch failure,不继续校验后续 output expectation ### Requirement: LLM Stream 断言 -LLM checker SHALL 仅允许 `mode: stream` 使用 `expect.stream`。`expect.stream.completed` 未配置时,LLM checker SHALL 在 stream observation 路径使用默认 `true` 语义。`expect.stream.firstTokenMs` SHALL 使用共享 `ValueMatcher`,并仅统计第一个非空 `text-delta` 事件耗时,不统计 reasoning、tool call 或 source 事件。 +LLM checker SHALL 仅允许 `mode: stream` 使用 `expect.stream`。仅当用户配置了 `expect.stream` 且未配置 `expect.stream.completed` 时,resolve 阶段 SHALL 在 Resolved expect 中物化默认 `completed: true`;LLM checker MUST NOT 因为 `llm.mode: stream` 自动添加 `stream.completed` 断言。`expect.stream.firstTokenMs` SHALL 使用共享 `RawValueExpectation` 输入并在运行期使用 `ValueExpectation`,且仅统计第一个非空 `text-delta` 事件耗时,不统计 reasoning、tool call 或 source 事件。 #### Scenario: stream completed 默认值 -- **WHEN** target 配置 `llm.mode: stream` 且未配置 `expect.stream.completed` -- **THEN** LLM checker SHALL 要求 SDK stream 正常完成 +- **WHEN** target 配置 `llm.mode: stream` 且配置 `expect.stream: {}` +- **THEN** resolve 阶段 SHALL 在 Resolved expect 中物化 `stream.completed: true` 并要求 SDK stream 正常完成 + +#### Scenario: 未配置 expect.stream 不添加 completed +- **WHEN** target 配置 `llm.mode: stream` 但未配置 `expect.stream` +- **THEN** resolve 阶段 SHALL NOT 自动添加 `stream.completed` 断言 #### Scenario: stream error - **WHEN** `fullStream` 产生 error part diff --git a/openspec/specs/probe-config/spec.md b/openspec/specs/probe-config/spec.md index a18a059..5a47716 100644 --- a/openspec/specs/probe-config/spec.md +++ b/openspec/specs/probe-config/spec.md @@ -101,7 +101,7 @@ 除 `headers`、`env`、`variables` 等明确声明为动态键值表的对象外,配置中的未知字段 SHALL 导致启动期配置错误。系统 MUST NOT 静默忽略未知字段。 -所有 ValueMatcher 类型的 expect 字段 SHALL 在 JSON Schema 契约中声明为 `anyOf: [primitiveValue, matcherObject]` 联合类型,同时接受 primitive 原始值(string / number / boolean / null)和 ValueMatcher 对象。语义 validator SHALL 在校验 ValueMatcher 字段之前执行归一化,将 primitive 原始值转换为 `{equals: value}` 对象形式。数组和对象 MUST NOT 作为 ValueMatcher 原始值简写;需要对数组或对象执行 equals 匹配时,配置 MUST 显式写成 `{equals: value}`。 +所有 `RawValueExpectation` 类型的 expect 字段 SHALL 在 JSON Schema 契约中声明为 `anyOf: [primitiveValue, matcherObject]` 联合类型,同时接受 primitive 原始值(string / number / boolean / null)和 `ValueMatcher` 对象。语义 validator SHALL 在不修改输入的前提下校验 `RawValueExpectation`;primitive 原始值 SHALL 被视为合法 Raw 输入,并在 checker resolve 阶段转换为 `{equals: value}` 对象形式。数组和对象 MUST NOT 作为 `RawValueExpectation` 原始值简写;需要对数组或对象执行 equals 匹配时,配置 MUST 显式写成 `{equals: value}`。`RawKeyedExpectations` 的动态键值 schema SHALL 复用 `RawValueExpectation`,MUST NOT 通过额外 JSON value 分支接受数组或对象简写。 #### Scenario: target 缺少必填字段 - **WHEN** YAML 中某个 target 缺少 id 或 type 字段 @@ -196,20 +196,20 @@ - **THEN** 系统 SHALL 以错误退出,提示该 target 的 status 数字不合法 #### Scenario: durationMs matcher 非法 -- **WHEN** YAML 中某个 target 的 `expect.durationMs` 不是合法 `ValueMatcher` 也不是 primitive 原始值 +- **WHEN** YAML 中某个 target 的 `expect.durationMs` 不是合法 `RawValueExpectation` - **THEN** 系统 SHALL 以错误退出,提示该 target 的 expect.durationMs 格式错误 #### Scenario: durationMs 原始值简写合法 - **WHEN** YAML 中某个 target 配置 `expect.durationMs: 5000` -- **THEN** 系统 SHALL 接受该配置,归一化为 `{equals: 5000}` 后校验通过 +- **THEN** 语义校验 SHALL 接受该 Raw primitive 简写且 MUST NOT 修改输入;checker resolve 阶段 SHALL 将其解析为 `{equals: 5000}` #### Scenario: ValueMatcher 字段字符串简写合法 - **WHEN** YAML 中某个 target 配置 `expect.finishReason: "stop"` -- **THEN** 系统 SHALL 接受该配置,归一化为 `{equals: "stop"}` 后校验通过 +- **THEN** 语义校验 SHALL 接受该 Raw primitive 简写且 MUST NOT 修改输入;checker resolve 阶段 SHALL 将其解析为 `{equals: "stop"}` #### Scenario: ValueMatcher 字段 null 简写合法 - **WHEN** YAML 中某个 target 配置 ValueMatcher 字段值为 `null` -- **THEN** 系统 SHALL 接受该配置,归一化为 `{equals: null}` 后校验通过 +- **THEN** 语义校验 SHALL 接受该 Raw primitive 简写且 MUST NOT 修改输入;checker resolve 阶段 SHALL 将其解析为 `{equals: null}` #### Scenario: ValueMatcher 字段数组简写非法 - **WHEN** YAML 中某个 target 配置 ValueMatcher 字段值为数组 `[1, 2]` @@ -223,40 +223,40 @@ - **WHEN** YAML 中某个 target 配置 `type: icmp` 但缺少 `icmp.host` - **THEN** 系统 SHALL 以错误退出,提示该 target 缺少 icmp.host 字段 -#### Scenario: ping expect 未知字段 -- **WHEN** YAML 中 ping target 的 expect 包含非 ping expect 字段 +#### Scenario: icmp expect 未知字段 +- **WHEN** YAML 中 icmp target 的 expect 包含非 icmp expect 字段 - **THEN** 系统 SHALL 以配置错误退出,提示 expect 包含未知字段 #### Scenario: HTTP expect headers 非法 -- **WHEN** YAML 中某个 HTTP target 的 `expect.headers` 不是对象,或某个 header 期望既不是字符串也不是合法 operator +- **WHEN** YAML 中某个 HTTP target 的 `expect.headers` 不是对象,或某个 header 期望不是合法 `RawValueExpectation` - **THEN** 系统 SHALL 以错误退出,提示该 target 的 expect.headers 格式错误 #### Scenario: HTTP expect body 必须为数组 - **WHEN** YAML 中某个 HTTP target 的 `expect.body` 已配置但不是数组 - **THEN** 系统 SHALL 以错误退出,提示该 target 的 expect.body 必须为数组 -#### Scenario: HTTP body rule 缺少支持字段 +#### Scenario: HTTP body expectation 缺少支持字段 - **WHEN** YAML 中某个 HTTP target 的 `expect.body` 数组项未包含 contains、regex、json、css、xpath 任一支持字段 -- **THEN** 系统 SHALL 以错误退出,提示该 body rule 缺少支持的规则类型 +- **THEN** 系统 SHALL 以错误退出,提示该 body expectation 缺少支持的 expectation 类型 -#### Scenario: HTTP body rule 同时配置多个支持字段 -- **WHEN** YAML 中某个 HTTP target 的同一条 body rule 同时包含 contains、regex、json、css、xpath 中的多个支持字段 -- **THEN** 系统 SHALL 以错误退出,提示每条 body rule 只能配置一种规则类型 +#### Scenario: HTTP body expectation 同时配置多个支持字段 +- **WHEN** YAML 中某个 HTTP target 的同一条 body expectation 同时包含 contains、regex、json、css、xpath 中的多个支持字段 +- **THEN** 系统 SHALL 以错误退出,提示每条 body expectation 只能配置一种 expectation 类型 #### Scenario: HTTP body regex 非法 -- **WHEN** YAML 中某个 HTTP target 的 body regex 规则不是字符串或不是可编译正则表达式 +- **WHEN** YAML 中某个 HTTP target 的 body regex expectation 不是字符串或不是可编译正则表达式 - **THEN** 系统 SHALL 以错误退出,提示该 body regex 不合法 #### Scenario: HTTP body json path 非法 -- **WHEN** YAML 中某个 HTTP target 的 body json 规则缺少 path,或 path 不符合系统支持的 JSONPath 子集 +- **WHEN** YAML 中某个 HTTP target 的 body json expectation 缺少 path,或 path 不符合系统支持的 JSONPath 子集 - **THEN** 系统 SHALL 以错误退出,提示该 body json path 不合法 #### Scenario: HTTP body css selector 非法 -- **WHEN** YAML 中某个 HTTP target 的 body css 规则缺少 selector,或 selector 不是非空字符串 +- **WHEN** YAML 中某个 HTTP target 的 body css expectation 缺少 selector,或 selector 不是非空字符串 - **THEN** 系统 SHALL 以错误退出,提示该 body css selector 不合法 #### Scenario: HTTP body xpath path 非法 -- **WHEN** YAML 中某个 HTTP target 的 body xpath 规则缺少 path,或 path 不是非空字符串,或可被现有 XPath 库静态判定为语法错误 +- **WHEN** YAML 中某个 HTTP target 的 body xpath expectation 缺少 path,或 path 不是非空字符串,或可被现有 XPath 库静态判定为语法错误 - **THEN** 系统 SHALL 以错误退出,提示该 body xpath path 不合法 #### Scenario: expect matcher 类型非法 @@ -293,23 +293,27 @@ #### Scenario: 导出配置 JSON Schema - **WHEN** 仓库生成或检查配置契约 -- **THEN** 根目录 SHALL 存在 draft-07 `probe-config.schema.json`,且其内容 SHALL 与当前公共 fragments 和已注册 checker fragments 组装出的完整 schema 一致(包含 variables 段和 target 的 id/name 字段)。所有 ValueMatcher 字段的 schema SHALL 声明为 `anyOf: [primitiveValue, matcherObject]` 联合类型 +- **THEN** 根目录 SHALL 存在 draft-07 `probe-config.schema.json`,且其内容 SHALL 与当前公共 fragments 和已注册 checker fragments 组装出的完整 schema 一致(包含 variables 段和 target 的 id/name 字段)。所有 `RawValueExpectation` 字段的 schema SHALL 声明为 `anyOf: [primitiveValue, matcherObject]` 联合类型,`RawKeyedExpectations` 的 dynamic value schema SHALL 复用 `RawValueExpectation` -#### Scenario: JSON Schema ValueMatcher 接受原始值 -- **WHEN** 使用 JSON Schema 校验配置文件中 ValueMatcher 字段值为数字 `5000` -- **THEN** JSON Schema 校验 SHALL 通过,因为 ValueMatcher schema 声明为 `anyOf: [primitiveValue, matcherObject]` +#### Scenario: JSON Schema RawValueExpectation 接受原始值 +- **WHEN** 使用 JSON Schema 校验配置文件中 RawValueExpectation 字段值为数字 `5000` +- **THEN** JSON Schema 校验 SHALL 通过,因为 RawValueExpectation schema 声明为 `anyOf: [primitiveValue, matcherObject]` -#### Scenario: JSON Schema ValueMatcher 接受 matcher 对象 -- **WHEN** 使用 JSON Schema 校验配置文件中 ValueMatcher 字段值为 `{lte: 5000}` +#### Scenario: JSON Schema RawValueExpectation 接受 matcher 对象 +- **WHEN** 使用 JSON Schema 校验配置文件中 RawValueExpectation 字段值为 `{lte: 5000}` - **THEN** JSON Schema 校验 SHALL 通过 -#### Scenario: JSON Schema ValueMatcher 拒绝数组原始值 -- **WHEN** 使用 JSON Schema 校验配置文件中 ValueMatcher 字段值为数组 `[1, 2]` +#### Scenario: JSON Schema RawValueExpectation 拒绝数组原始值 +- **WHEN** 使用 JSON Schema 校验配置文件中 RawValueExpectation 字段值为数组 `[1, 2]` - **THEN** JSON Schema 校验 SHALL 失败,因为数组不属于 primitive 原始值或 matcher 对象 -#### Scenario: JSON Schema ValueMatcher 接受 equals 数组对象 -- **WHEN** 使用 JSON Schema 校验配置文件中 ValueMatcher 字段值为 `{equals: [1, 2]}` 或 `{equals: {status: "ok"}}` +#### Scenario: JSON Schema RawValueExpectation 接受 equals 数组对象 +- **WHEN** 使用 JSON Schema 校验配置文件中 RawValueExpectation 字段值为 `{equals: [1, 2]}` 或 `{equals: {status: "ok"}}` - **THEN** JSON Schema 校验 SHALL 通过,因为 `equals` 支持任意 JSON value + +#### Scenario: JSON Schema RawKeyedExpectations 拒绝数组对象简写 +- **WHEN** 使用 JSON Schema 校验配置文件中 `expect.headers.X` 或 DB row 列值为数组 `["a"]` 或对象 `{nested: "ok"}` +- **THEN** JSON Schema 校验 SHALL 失败,除非该值显式写在 `{equals: ...}` 下 系统 SHALL 支持使用单位字符串配置读取上限,单位包括 `B`、`KB`、`MB` 和 `GB`。 #### Scenario: 解析 MB @@ -339,43 +343,45 @@ - **THEN** 系统 SHALL 调用 `Bun.YAML.parse()` 将内容解析为配置对象 ### Requirement: expect 配置增强 -系统 SHALL 支持 typed target 的领域专用 expect 配置,并通过共享 `ValueMatcher`、`ContentRules` 和 `KeyValueExpect` 表达可复用断言能力。状态类字段 SHALL 保持枚举或布尔语义,包括 HTTP/LLM 的 `status`(支持精确数字和范围模式)、cmd 的 `exitCode`、tcp 的 `connected`、ping 的 `alive` 和 udp 的 `responded`。数字指标字段 SHALL 使用 `ValueMatcher`,包括通用 `durationMs`、db 的 `rowCount`、udp 的 `responseSize`/`sourceHost`/`sourcePort`、ping 的 `packetLossPercent`/`avgLatencyMs`/`maxLatencyMs`、llm 的 usage token 与 stream 首 token 耗时。内容类字段 MUST 使用 `ContentRules` 数组表达配置顺序,包括 HTTP `body`、cmd `stdout`/`stderr`、tcp `banner`、udp `response`、llm `output` 和 db `result`。LLM `finishReason` 和 `rawFinishReason` SHALL 使用 `ValueMatcher`(非 ContentRules),因为它们是单值字符串元数据。键值类字段 SHALL 使用 `KeyValueExpect`,包括 HTTP/LLM `headers` 和 db `rows` 中的列值断言(db `rows` 的类型为 `Array`,外层数组按行索引,内层每个元素为 KeyValueExpect)。 +系统 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 保留变量替换后的 Raw expect 作为用户配置快照,同时生成 Resolved expect 作为运行期执行计划。语义校验 SHALL 只读取 Raw expect 并报告问题,MUST NOT 原地归一化或修改 Raw expect。Store 持久化 SHALL 写入 Raw expect;checker execute SHALL 只消费 Resolved expect。 #### Scenario: 解析 HTTP expect 配置 -- **WHEN** YAML 配置文件中 HTTP target 的 expect 包含 status、headers、body 规则数组和 durationMs matcher -- **THEN** 系统 SHALL 正确解析并存储为 HTTP target 的 expect 字段 +- **WHEN** YAML 配置文件中 HTTP target 的 expect 包含 status、headers、body expectation 数组和 durationMs matcher +- **THEN** 系统 SHALL 保留 Raw HTTP expect 快照,并生成包含默认 status、resolved keyed headers、resolved content body 和 resolved durationMs 的 HTTP Resolved expect #### Scenario: 解析 cmd expect 配置 - **WHEN** YAML 配置文件中 cmd target 的 expect 包含 exitCode、stdout、stderr 和 durationMs matcher -- **THEN** 系统 SHALL 正确解析并存储为 cmd target 的 expect 字段 +- **THEN** 系统 SHALL 保留 Raw cmd expect 快照,并生成包含默认 exitCode、resolved stdout/stderr content expectations 和 resolved durationMs 的 cmd Resolved expect #### Scenario: 解析 db expect 配置 - **WHEN** YAML 配置文件中 db target 的 expect 包含 durationMs、rowCount、rows 和 result -- **THEN** 系统 SHALL 正确解析并存储为 db target 的 expect 字段 +- **THEN** 系统 SHALL 保留 Raw db expect 快照,并生成包含 resolved rowCount、rows keyed expectations 和 result content expectations 的 db Resolved expect #### Scenario: 解析 tcp expect 配置 -- **WHEN** YAML 配置文件中 tcp target 的 expect 包含 connected、banner 规则数组和 durationMs matcher -- **THEN** 系统 SHALL 正确解析并存储为 tcp target 的 expect 字段 +- **WHEN** YAML 配置文件中 tcp target 的 expect 包含 connected、banner expectation 数组和 durationMs matcher +- **THEN** 系统 SHALL 保留 Raw tcp expect 快照,并生成包含默认 connected、resolved banner content expectations 和 resolved durationMs 的 tcp Resolved expect -#### Scenario: 解析 ping expect 配置 -- **WHEN** YAML 配置文件中 ping target 的 expect 包含 alive、packetLossPercent、avgLatencyMs、maxLatencyMs 和 durationMs matcher -- **THEN** 系统 SHALL 正确解析并存储为 ping target 的 expect 字段 +#### Scenario: 解析 icmp expect 配置 +- **WHEN** YAML 配置文件中 icmp target 的 expect 包含 alive、packetLossPercent、avgLatencyMs、maxLatencyMs 和 durationMs matcher +- **THEN** 系统 SHALL 保留 Raw icmp expect 快照,并生成包含默认 alive 和 resolved 数值 expectations 的 icmp Resolved expect #### Scenario: 解析 udp expect 配置 - **WHEN** YAML 配置文件中 udp target 的 expect 包含 responded、response、responseSize、sourceHost、sourcePort 和 durationMs matcher -- **THEN** 系统 SHALL 正确解析并存储为 udp target 的 expect 字段 +- **THEN** 系统 SHALL 保留 Raw udp expect 快照,并生成包含默认 responded、resolved response content expectations 和 resolved value expectations 的 udp Resolved expect #### Scenario: 解析 llm expect 配置 - **WHEN** YAML 配置文件中 llm target 的 expect 包含 status、headers、output、finishReason、rawFinishReason、usage、stream 和 durationMs matcher -- **THEN** 系统 SHALL 正确解析并存储为 llm target 的 expect 字段,并保留 output 内容规则数组顺序 +- **THEN** 系统 SHALL 保留 Raw llm expect 快照,并生成包含默认 status、resolved headers、output、finishReason、rawFinishReason、usage、stream 和 durationMs 的 llm Resolved expect,并保留 output 内容 expectation 数组顺序 -#### Scenario: 解析有序 ContentRules 数组 +#### Scenario: 解析有序 ContentExpectations 数组 - **WHEN** YAML 中任一内容类 expect 配置 contains、json、regex 三个数组项 -- **THEN** 系统 SHALL 保留数组顺序,供执行阶段按配置顺序快速失败 +- **THEN** 系统 SHALL 在 Raw expect 中保留数组顺序,并在 Resolved expect 中保留执行顺序,供执行阶段按配置顺序快速失败 #### Scenario: 不配置 HTTP status - **WHEN** HTTP target 未配置 `expect.status` -- **THEN** 系统 SHALL 在执行 expect 时使用默认 `status: [200]` 语义 +- **THEN** 系统 SHALL 在 Resolved HTTP expect 中物化默认 `status: [200]` 语义 #### Scenario: 配置 HTTP status 范围模式 - **WHEN** HTTP target 配置 `expect.status: ["2xx"]` @@ -383,19 +389,23 @@ #### Scenario: 不配置 cmd exitCode - **WHEN** cmd target 未配置 `expect.exitCode` -- **THEN** 系统 SHALL 在执行 expect 时使用默认 `exitCode: [0]` 语义 +- **THEN** 系统 SHALL 在 Resolved cmd expect 中物化默认 `exitCode: [0]` 语义 #### Scenario: 不配置 expect - **WHEN** target 未配置任何 expect 规则 -- **THEN** 系统 SHALL 正常处理,expect 字段为 undefined,并由各 checker 使用自身默认状态语义 +- **THEN** 系统 SHALL 正常处理,Raw expect 快照为 undefined,Resolved expect 由各 checker 物化自身默认状态语义 + +#### Scenario: Raw expect 不被语义校验修改 +- **WHEN** YAML 中配置 `expect.durationMs: 1000` +- **THEN** 语义校验 SHALL 接受该 Raw primitive 简写且 MUST NOT 将 Raw 输入原地修改为 `{equals: 1000}` #### Scenario: 旧 maxDurationMs 字段不再支持 - **WHEN** YAML 中任一 target 配置 `expect.maxDurationMs` - **THEN** 系统 SHALL 在启动期配置校验失败,提示该字段未知,并要求使用 `expect.durationMs` #### Scenario: 旧 match 字段不再支持 -- **WHEN** YAML 中任一 matcher 或内容规则配置 `match` -- **THEN** 系统 SHALL 在启动期配置校验失败,提示该字段未知,并要求使用 `regex` +- **WHEN** YAML 中任一 matcher 或内容 expectation 配置 `match` +- **THEN** 系统 SHALL 在启动期配置校验失败,提示该字段未知或不支持,并要求使用 `regex` #### Scenario: durationMs matcher 配置 - **WHEN** YAML 中任一 target 配置 `expect.durationMs: {lte: 1000}` @@ -405,14 +415,18 @@ - **WHEN** YAML 中 `http.headers`、`defaults.http.headers`、`llm.headers`、`defaults.llm.headers` 或 `expect.headers` 包含任意 header 名称,且对应值符合契约 - **THEN** 系统 SHALL 接受这些动态 header 名称 -#### Scenario: ContentRules 字段必须为数组 +#### Scenario: ContentExpectations 字段必须为数组 - **WHEN** YAML 中任一内容类 expect 字段配置为非数组 -- **THEN** 系统 SHALL 在启动期配置校验失败,提示该字段必须为规则数组 +- **THEN** 系统 SHALL 在启动期配置校验失败,提示该字段必须为 expectation 数组 #### Scenario: regex 字段非法 - **WHEN** YAML 中任一 `regex` 不是字符串、不是可编译正则或存在 ReDoS 风险 - **THEN** 系统 SHALL 在启动期配置校验失败,提示对应 regex 不合法 +#### Scenario: Store 持久化 Raw expect +- **WHEN** 系统同步已解析 target 到 targets 表 +- **THEN** `targets.expect` SHALL 存储变量替换后的 Raw expect JSON,而不是包含 `kind` 或 resolved matcher 的运行期执行计划 + ### Requirement: 数据保留配置字段 配置 schema 的 `runtime` 段 SHALL 支持 `retention` 字段,类型为字符串,格式为 `<数字><单位>`(单位:`d` 天、`h` 小时、`m` 分钟),用于指定历史数据保留时长。 @@ -498,7 +512,7 @@ - **THEN** target schema SHALL 声明 `id` 的 minLength 为 1、maxLength 为 30,并声明 `name` 为可选字段,类型为 string 或 null,字符串的 minLength 为 1、maxLength 为 30 ### Requirement: TCP 配置校验 -系统 SHALL 在启动期对 tcp checker 的配置契约和语义执行严格校验。Tcp target 的 `tcp` 分组 SHALL 只允许 `host`、`port`、`readBanner`、`bannerReadTimeout` 和 `maxBannerBytes` 字段;Tcp expect SHALL 只允许 `connected`、`durationMs` 和 `banner` 字段。`banner` MUST 为 `ContentRules` 数组。未知字段、非法类型、非法端口、非法 size 和不可编译正则 MUST 导致启动期配置错误。 +系统 SHALL 在启动期对 tcp checker 的配置契约和语义执行严格校验。Tcp target 的 `tcp` 分组 SHALL 只允许 `host`、`port`、`readBanner`、`bannerReadTimeout` 和 `maxBannerBytes` 字段;Tcp expect SHALL 只允许 `connected`、`durationMs` 和 `banner` 字段。`banner` MUST 为 `RawContentExpectations` 数组,`durationMs` SHALL 为 `RawValueExpectation`。未知字段、非法类型、非法端口、非法 size、非法 ContentExpectations 和不可编译正则 MUST 导致启动期配置错误。语义校验 MUST NOT 修改 Raw tcp expect 输入。 #### Scenario: tcp host 类型非法 - **WHEN** YAML 中 tcp target 的 `tcp.host` 不是非空字符串 @@ -525,7 +539,7 @@ - **THEN** 系统 SHALL 以配置错误退出,提示 expect.connected 必须为布尔值 #### Scenario: tcp expect banner 非法 -- **WHEN** YAML 中 tcp target 的 `expect.banner` 不是合法 ContentRules 数组 +- **WHEN** YAML 中 tcp target 的 `expect.banner` 不是合法 `RawContentExpectations` 数组 - **THEN** 系统 SHALL 以配置错误退出,提示 expect.banner 格式错误 #### Scenario: tcp expect banner regex 正则非法 @@ -541,7 +555,7 @@ - **THEN** 系统 SHALL 以配置错误退出,提示 defaults.tcp 包含未知字段 ### Requirement: LLM 配置校验 -系统 SHALL 在启动期对 llm checker 的配置契约和语义执行严格校验。LLM target 的 `llm` 分组 SHALL 只允许 `provider`、`url`、`model`、`prompt`、`mode`、`key`、`authToken`、`headers`、`ignoreSSL`、`options` 和 `providerOptions` 字段。`defaults.llm` 分组 SHALL 只允许 `mode`、`headers`、`ignoreSSL`、`options` 和 `providerOptions` 字段。LLM expect SHALL 只允许 `status`、`headers`、`output`、`finishReason`、`rawFinishReason`、`usage`、`stream` 和 `durationMs` 字段。`expect.output` MUST 为 `ContentRules` 数组。`expect.finishReason` 和 `expect.rawFinishReason` SHALL 使用 `ValueMatcher`。`expect.usage.*` 和 `expect.stream.firstTokenMs` SHALL 使用 `ValueMatcher`。未知字段、非法 provider、非法 URL、非法 mode、非法认证组合、非法 options、非法 output 规则和 `mode: http` 下配置 `expect.stream` MUST 导致启动期配置错误。 +系统 SHALL 在启动期对 llm checker 的配置契约和语义执行严格校验。LLM target 的 `llm` 分组 SHALL 只允许 `provider`、`url`、`model`、`prompt`、`mode`、`key`、`authToken`、`headers`、`ignoreSSL`、`options` 和 `providerOptions` 字段。`defaults.llm` 分组 SHALL 只允许 `mode`、`headers`、`ignoreSSL`、`options` 和 `providerOptions` 字段。LLM expect SHALL 只允许 `status`、`headers`、`output`、`finishReason`、`rawFinishReason`、`usage`、`stream` 和 `durationMs` 字段。`expect.output` MUST 为 `RawContentExpectations` 数组。`expect.finishReason`、`expect.rawFinishReason`、`expect.usage.*`、`expect.stream.firstTokenMs` 和 `expect.durationMs` SHALL 使用 `RawValueExpectation`。未知字段、非法 provider、非法 URL、非法 mode、非法认证组合、非法 options、非法 output expectation 和 `mode: http` 下配置 `expect.stream` MUST 导致启动期配置错误。语义校验 MUST NOT 修改 Raw llm expect 输入。 #### Scenario: llm provider 非法 - **WHEN** YAML 中 llm target 的 `llm.provider` 不是 `openai`、`openai-responses` 或 `anthropic` @@ -591,24 +605,24 @@ - **WHEN** YAML 中 llm target 配置 `api`、`providerName`、`baseURL`、`apiKey`、`messages`、`maxRetries`、`request`、`maxBodyBytes` 或 `maxStreamBytes` - **THEN** 系统 SHALL 以配置错误退出,提示 llm 分组包含未知字段 -#### Scenario: llm output 规则缺少支持字段 +#### Scenario: llm output expectation 缺少支持字段 - **WHEN** YAML 中 llm target 的 `expect.output` 数组项未包含任何合法 ValueMatcher 字段或 extractor -- **THEN** 系统 SHALL 以配置错误退出,提示 output rule 缺少支持的规则类型 +- **THEN** 系统 SHALL 以配置错误退出,提示 output expectation 缺少支持的 expectation 类型 -#### Scenario: llm output 规则同时配置多个 extractor -- **WHEN** YAML 中 llm target 的同一条 output rule 同时包含 json、css、xpath 中的多个 extractor -- **THEN** 系统 SHALL 以配置错误退出,提示每条 output rule 只能配置一种 extractor +#### Scenario: llm output expectation 同时配置多个 extractor +- **WHEN** YAML 中 llm target 的同一条 output expectation 同时包含 json、css、xpath 中的多个 extractor +- **THEN** 系统 SHALL 以配置错误退出,提示每条 output expectation 只能配置一种 extractor #### Scenario: llm output regex 非法 -- **WHEN** YAML 中 llm target 的 output regex 规则不是字符串、不是可编译正则表达式或存在 ReDoS 风险 +- **WHEN** YAML 中 llm target 的 output regex expectation 不是字符串、不是可编译正则表达式或存在 ReDoS 风险 - **THEN** 系统 SHALL 以配置错误退出,提示该 output regex 不合法 #### Scenario: llm output json path 非法 -- **WHEN** YAML 中 llm target 的 output json 规则缺少 path,或 path 不符合系统支持的 JSONPath 子集 +- **WHEN** YAML 中 llm target 的 output json expectation 缺少 path,或 path 不符合系统支持的 JSONPath 子集 - **THEN** 系统 SHALL 以配置错误退出,提示该 output json path 不合法 #### Scenario: llm expect usage 非法 -- **WHEN** YAML 中 llm target 的 `expect.usage.inputTokens`、`expect.usage.outputTokens` 或 `expect.usage.totalTokens` 不是合法 `ValueMatcher` +- **WHEN** YAML 中 llm target 的 `expect.usage.inputTokens`、`expect.usage.outputTokens` 或 `expect.usage.totalTokens` 不是合法 `RawValueExpectation` - **THEN** 系统 SHALL 以配置错误退出,提示 expect.usage 格式错误 #### Scenario: llm expect stream 仅允许 stream mode @@ -616,5 +630,5 @@ - **THEN** 系统 SHALL 以配置错误退出,提示 expect.stream 仅支持 stream mode #### Scenario: llm expect stream firstTokenMs 非法 -- **WHEN** YAML 中 llm target 的 `expect.stream.firstTokenMs` 不是合法 `ValueMatcher` +- **WHEN** YAML 中 llm target 的 `expect.stream.firstTokenMs` 不是合法 `RawValueExpectation` - **THEN** 系统 SHALL 以配置错误退出,提示 expect.stream.firstTokenMs 格式错误 diff --git a/openspec/specs/probe-data-store/spec.md b/openspec/specs/probe-data-store/spec.md index 20adf11..bce5a23 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 类型、展示名称元信息、展示摘要、领域配置、调度配置、expect 配置、分组信息和目标说明。配置中不存在的 target SHALL 被标记为非活跃而非删除。 +系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示名称元信息、展示摘要、领域配置、调度配置、变量替换后的 Raw expect 配置快照、分组信息和目标说明。配置中不存在的 target SHALL 被标记为非活跃而非删除。`targets.expect` SHALL 存储 Raw expect JSON;系统 MUST NOT 将 Resolved expect 执行计划、`ContentExpectation.kind` union 或已归一化 matcher 包装结构写入该列。 #### Scenario: 首次同步目标 - **WHEN** 数据库为空且 YAML 中定义了 N 个 typed target -- **THEN** 系统 SHALL 将所有目标插入 targets 表,包含 name、description、type、target、config、interval_ms、timeout_ms、expect、grp 和 active=1,其中 name 和 description 均可为 NULL +- **THEN** 系统 SHALL 将所有目标插入 targets 表,包含 name、description、type、target、config、interval_ms、timeout_ms、expect、grp 和 active=1,其中 name 和 description 均可为 NULL,expect 列保存变量替换后的 Raw expect JSON #### Scenario: 配置变更后重新同步 - **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启 @@ -64,6 +64,14 @@ - **WHEN** YAML target 配置 `description: null` - **THEN** targets 表 SHALL 将该目标的 description 存储为 NULL +#### Scenario: expect 列保存 Raw expect +- **WHEN** target 配置 `expect.body: [{json: {path: "$.status"}}]` 和 `expect.durationMs: 1000` +- **THEN** targets 表的 expect 列 SHALL 保存变量替换后的 Raw JSON,包含原始 `json.path` 和 `durationMs: 1000`,MUST NOT 保存 resolved `kind` 字段或 `{equals: 1000}` 执行计划 + +#### Scenario: 未配置 expect 写入 NULL +- **WHEN** target 未配置任何 expect +- **THEN** targets 表的 expect 列 SHALL 写入 NULL,即使 Resolved expect 中存在 checker 默认状态语义 + ### Requirement: check_results 表追加写入 系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。 diff --git a/openspec/specs/probe-engine/spec.md b/openspec/specs/probe-engine/spec.md index b9841e8..22b491f 100644 --- a/openspec/specs/probe-engine/spec.md +++ b/openspec/specs/probe-engine/spec.md @@ -121,11 +121,11 @@ - **THEN** 系统 SHALL 正常记录执行结果并进入 expect 校验 ### Requirement: expect 校验 -系统 SHALL 在 checker 执行完成后根据目标类型的 expect 配置校验观测结果,校验结果和首个失败原因记入 check result。HTTP checker 的 `durationMs` SHALL 表示完整 checker 执行耗时,包括重定向、响应体读取、响应体解码和 expect 校验。 +系统 SHALL 在 checker 执行完成后根据目标类型的 Resolved expect 执行计划校验观测结果,校验结果和首个失败原因记入 check result。HTTP checker 的 `durationMs` SHALL 表示完整 checker 执行耗时,包括重定向、响应体读取、响应体解码和 expect 校验。HTTP `expect.durationMs` SHALL 使用 `RawValueExpectation` 输入并在 resolve 阶段转换为运行期 `ValueExpectation`;旧 `expect.maxDurationMs` MUST NOT 再作为运行期耗时阈值使用。 #### Scenario: HTTP 默认状态码 - **WHEN** HTTP target 未配置 `expect.status` -- **THEN** 系统 SHALL 按默认 `status: [200]` 校验响应状态码 +- **THEN** 系统 SHALL 在 Resolved HTTP expect 中物化默认 `status: [200]` 并按该语义校验响应状态码 #### Scenario: 校验 HTTP 状态码精确值 - **WHEN** HTTP target 配置了 `expect.status: [200, 201]` @@ -144,39 +144,39 @@ - **THEN** 系统 SHALL 检查响应头是否符合指定规则,全部匹配时继续后续阶段 #### Scenario: 校验 HTTP 响应体 -- **WHEN** HTTP target 配置了有序 `expect.body` 规则数组 -- **THEN** 系统 SHALL 按数组顺序执行 body 规则,任一失败立即记录 failure 并停止后续规则 +- **WHEN** HTTP target 配置了有序 `expect.body` ContentExpectations 数组 +- **THEN** 系统 SHALL 按数组顺序执行 body expectations,任一失败立即记录 failure 并停止后续 expectation #### Scenario: 校验 HTTP 完整耗时阈值 -- **WHEN** 目标配置了 `expect.maxDurationMs`,且 HTTP checker 完整执行(含重定向、body 读取、解码和 expect)后的 durationMs 超过阈值 +- **WHEN** 目标配置了 `expect.durationMs: {lte: 1000}`,且 HTTP checker 完整执行(含重定向、body 读取、解码和 expect)后的 durationMs 超过阈值 - **THEN** 系统 SHALL 判定 duration 不匹配,记录完整 durationMs 和 duration failure -#### Scenario: HTTP body 前耗时已超阈值 -- **WHEN** HTTP target 配置了 body 校验和 `expect.maxDurationMs`,且进入 body 读取前的已耗时已超过阈值 +#### Scenario: HTTP body 前耗时已不可能满足 durationMs 上界 +- **WHEN** HTTP target 配置了 body 校验和 `expect.durationMs` 上界 matcher(如 `{lte: 1000}`),且进入 body 读取前的已耗时已使该 matcher 不可能通过 - **THEN** 系统 SHALL 直接返回 duration failure,且 MUST NOT 读取 response body #### Scenario: HTTP body 失败优先于后续 duration 检查 -- **WHEN** HTTP target 配置了 body 校验和 `expect.maxDurationMs`,body 阶段存在失败,且完整执行后 duration 也超过阈值 +- **WHEN** HTTP target 配置了 body 校验和 `expect.durationMs: {lte: 1000}`,body 阶段存在失败,且完整执行后 duration 也超过阈值 - **THEN** 系统 SHALL 返回 body 阶段的失败(首个失败为准),durationMs SHALL 记录完整耗时 #### Scenario: HTTP 慢响应体计入耗时 -- **WHEN** HTTP target 配置了 body 校验和 `expect.maxDurationMs`,且响应头很快返回但响应体读取导致完整执行耗时超过阈值 +- **WHEN** HTTP target 配置了 body 校验和 `expect.durationMs: {lte: 1000}`,且响应头很快返回但响应体读取导致完整执行耗时超过阈值 - **THEN** 系统 SHALL 判定 duration 不匹配并记录完整 durationMs #### Scenario: 多条 expect 规则 -- **WHEN** 目标同时配置状态、duration、元数据和内容规则 -- **THEN** 系统 SHALL 所有规则全部通过时 matched 为 true,任一不通过则为 false 并记录首个失败原因 +- **WHEN** 目标同时配置状态、duration、元数据和内容 expectations +- **THEN** 系统 SHALL 所有 expectations 全部通过时 matched 为 true,任一不通过则为 false 并记录首个失败原因 #### Scenario: cmd 默认 exitCode - **WHEN** cmd target 未配置 `expect.exitCode` -- **THEN** 系统 SHALL 按默认 `exitCode: [0]` 校验命令退出码 +- **THEN** 系统 SHALL 在 Resolved cmd expect 中物化默认 `exitCode: [0]` 并按该语义校验命令退出码 #### Scenario: 校验 cmd stdout -- **WHEN** cmd target 配置了有序 `expect.stdout` 规则数组 -- **THEN** 系统 SHALL 按数组顺序执行 stdout 规则,任一失败立即记录 failure 并停止后续规则 +- **WHEN** cmd target 配置了有序 `expect.stdout` ContentExpectations 数组 +- **THEN** 系统 SHALL 按数组顺序执行 stdout expectations,任一失败立即记录 failure 并停止后续 expectation ### Requirement: Body 校验按需解析 -系统 SHALL 仅在 HTTP target 配置了 body 校验,且 status、headers 阶段均通过,并且进入 body 前未确定 duration 已失败时才读取并解析响应体,避免不必要的读取和解析开销。HTTP target 未配置 body 校验时,系统 SHALL NOT 读取 response body。 +系统 SHALL 仅在 HTTP target 配置了 body 校验,且 status、headers 阶段均通过,并且进入 body 前未确定 `expect.durationMs` 已失败时才读取并解析响应体,避免不必要的读取和解析开销。HTTP target 未配置 body 校验时,系统 SHALL NOT 读取 response body。仅当 Resolved `durationMs` 包含上界 matcher 且当前已耗时已经使其不可能通过时,系统 MAY 在读取 body 前返回 duration failure;其他 duration matcher SHALL 在完整执行耗时可用后校验。 #### Scenario: status 失败时不读取 body - **WHEN** HTTP target 的 status 阶段不匹配 @@ -186,17 +186,17 @@ - **WHEN** HTTP target 的 status 阶段匹配但 headers 阶段不匹配 - **THEN** 系统 SHALL 立即返回 matched=false,且 MUST NOT 读取 response body -#### Scenario: 进入 body 前 duration 已失败时不读取 body -- **WHEN** HTTP target 已配置 `expect.maxDurationMs`,且进入 body 读取前的已耗时已经超过阈值 +#### Scenario: 进入 body 前 durationMs 上界已失败时不读取 body +- **WHEN** HTTP target 已配置 `expect.durationMs` 上界 matcher,且进入 body 读取前的已耗时已经使该 matcher 不可能通过 - **THEN** 系统 SHALL 返回 duration failure,且 MUST NOT 读取 response body #### Scenario: 仅配置 contains 时不解析 JSON -- **WHEN** HTTP target 仅配置 body contains 规则而未配置 json/css/xpath 规则 +- **WHEN** HTTP target 仅配置 body contains expectation 而未配置 json/css/xpath expectation - **THEN** 系统 SHALL 不执行 JSON.parse 或 HTML/XML 解析 #### Scenario: 配置 json 时解析 JSON 失败 -- **WHEN** HTTP target 配置了 body json 规则但响应体不是合法 JSON -- **THEN** 系统 SHALL 判定 matched 为 false,并记录 json 规则对应的 failure.path +- **WHEN** HTTP target 配置了 body json expectation 但响应体不是合法 JSON +- **THEN** 系统 SHALL 判定 matched 为 false,并记录 json expectation 对应的 failure.path ### Requirement: HTTP 运行期错误归属 HTTP checker SHALL 将运行期失败归属到实际失败阶段。请求、网络、TLS 和 timeout 错误 SHALL 记录为 request 阶段错误;body 超限、响应体解码失败、响应内容解析失败 SHALL 记录为 body 阶段错误;expect 不匹配 SHALL 记录为对应 mismatch 阶段。 diff --git a/openspec/specs/tcp-checker/spec.md b/openspec/specs/tcp-checker/spec.md index d4ff7f5..554e1d8 100644 --- a/openspec/specs/tcp-checker/spec.md +++ b/openspec/specs/tcp-checker/spec.md @@ -86,27 +86,27 @@ - **THEN** observation.banner SHALL 保存截断后的 banner 摘要,API detail SHALL 展示截断后的 banner 摘要,避免 UI 展示过长文本 ### Requirement: tcp expect 校验 -系统 SHALL 支持 tcp 专属 expect,包括 `connected`、`banner` 和 `durationMs`,并按 connected、banner、durationMs 的阶段顺序快速失败。`connected` SHALL 保持布尔状态语义,未配置时默认 `true`。`banner` MUST 使用共享 `ContentRules` 数组,并仅在 `tcp.readBanner: true` 时允许配置。`durationMs` SHALL 使用共享 `ValueMatcher` 校验包含连接和 banner 读取在内的完整执行耗时。 +系统 SHALL 支持 tcp 专属 expect,包括 `connected`、`banner` 和 `durationMs`,并按 connected、banner、durationMs 的阶段顺序快速失败。`connected` SHALL 保持布尔状态语义,未配置时在 Resolved expect 中默认 `true`。`banner` MUST 使用共享 `RawContentExpectations` 数组输入并在运行期使用 `ContentExpectations`,且仅在 `tcp.readBanner: true` 时允许配置。`durationMs` SHALL 使用共享 `RawValueExpectation` 输入并在运行期使用 `ValueExpectation` 校验包含连接和 banner 读取在内的完整执行耗时。 #### Scenario: 默认 connected 成功语义 - **WHEN** tcp target 未显式配置 `expect.connected` -- **THEN** 系统 SHALL 使用默认 `expect.connected: true` 进行校验 +- **THEN** 系统 SHALL 在 Resolved tcp expect 中使用默认 `connected: true` 进行校验 #### Scenario: durationMs 校验 - **WHEN** tcp target 配置 `expect.durationMs: {lte: 100}`,且完整执行耗时超过 100ms - **THEN** 系统 SHALL 返回 `matched=false`,failure 的 phase 为 `duration` -#### Scenario: banner ContentRules 校验通过 +#### Scenario: banner ContentExpectations 校验通过 - **WHEN** tcp target 配置 `readBanner: true`、`expect.banner: [{contains: "ESMTP"}]`,且实际 banner 包含 `ESMTP` - **THEN** 系统 SHALL 判定 banner 阶段通过 #### Scenario: banner regex 校验失败 - **WHEN** tcp target 配置 `readBanner: true`、`expect.banner: [{regex: "^SSH-2\\.0"}]`,且实际 banner 不匹配该正则 -- **THEN** 系统 SHALL 返回 `matched=false`,failure 的 kind 为 `mismatch`,phase 为 `banner`,path 指向失败的 banner 规则 +- **THEN** 系统 SHALL 返回 `matched=false`,failure 的 kind 为 `mismatch`,phase 为 `banner`,path 指向失败的 banner expectation #### Scenario: banner 多规则快速失败 -- **WHEN** tcp target 配置两条 banner 规则且第一条失败 -- **THEN** 系统 SHALL 返回第一条失败规则的 failure,并 MUST NOT 执行第二条规则 +- **WHEN** tcp target 配置两条 banner expectation 且第一条失败 +- **THEN** 系统 SHALL 返回第一条失败 expectation 的 failure,并 MUST NOT 执行第二条 expectation #### Scenario: expect.banner 未开启 readBanner - **WHEN** tcp target 配置 `expect.banner`,但 `tcp.readBanner` 未配置为 `true` diff --git a/openspec/specs/udp-checker/spec.md b/openspec/specs/udp-checker/spec.md index f79d9ba..b15c31c 100644 --- a/openspec/specs/udp-checker/spec.md +++ b/openspec/specs/udp-checker/spec.md @@ -133,13 +133,13 @@ - **THEN** 系统 SHALL 以配置错误退出,提示 maxResponseBytes 格式错误 ### Requirement: udp expect 校验 -系统 SHALL 支持 udp 专属 expect,包括 `responded`、`response`、`responseSize`、`sourceHost`、`sourcePort` 和 `durationMs`,并按 responded、responseSize、response、sourceHost、sourcePort、durationMs 的阶段顺序快速失败。`responded` SHALL 保持布尔状态语义,未配置时默认 `true`。`response` MUST 使用共享 `ContentRules` 数组,并作用于按 `udp.responseEncoding` 转换后的响应文本。`responseSize`、`sourceHost`、`sourcePort` 和 `durationMs` SHALL 使用共享 `ValueMatcher`。 +系统 SHALL 支持 udp 专属 expect,包括 `responded`、`response`、`responseSize`、`sourceHost`、`sourcePort` 和 `durationMs`,并按 responded、responseSize、response、sourceHost、sourcePort、durationMs 的阶段顺序快速失败。`responded` SHALL 保持布尔状态语义,未配置时在 Resolved expect 中默认 `true`。`response` MUST 使用共享 `RawContentExpectations` 数组输入并在运行期使用 `ContentExpectations`,且作用于按 `udp.responseEncoding` 转换后的响应文本。`responseSize`、`sourceHost`、`sourcePort` 和 `durationMs` SHALL 使用共享 `RawValueExpectation` 输入并在运行期使用 `ValueExpectation`。 #### Scenario: 默认 responded 成功语义 - **WHEN** udp target 未显式配置 `expect.responded` -- **THEN** 系统 SHALL 使用默认 `expect.responded: true` 进行校验 +- **THEN** 系统 SHALL 在 Resolved udp expect 中使用默认 `responded: true` 进行校验 -#### Scenario: response ContentRules 校验通过 +#### Scenario: response ContentExpectations 校验通过 - **WHEN** udp target 配置 `expect.response: [{ contains: "PONG" }]`,且按 `responseEncoding` 转换后的响应文本包含 `PONG` - **THEN** 系统 SHALL 判定 response 阶段通过 @@ -147,13 +147,13 @@ - **WHEN** udp target 收到文本响应 `{"status":"ok"}` 且配置 `expect.response: [{json: {path: "$.status", equals: "ok"}}]` - **THEN** 系统 SHALL 判定 response 阶段通过 -#### Scenario: response ContentRules 校验失败 +#### Scenario: response ContentExpectations 校验失败 - **WHEN** udp target 配置 `expect.response: [{ contains: "PONG" }]`,但按 `responseEncoding` 转换后的响应文本不包含 `PONG` -- **THEN** 系统 SHALL 返回 `matched=false`,failure 的 kind 为 `mismatch`,phase 为 `response`,path 指向失败的 response 规则 +- **THEN** 系统 SHALL 返回 `matched=false`,failure 的 kind 为 `mismatch`,phase 为 `response`,path 指向失败的 response expectation #### Scenario: responseEncoding 为 hex - **WHEN** udp target 配置 `udp.responseEncoding: "hex"` 且收到字节内容 `PONG` -- **THEN** 系统 SHALL 将响应转换为小写 hex 字符串 `504f4e47` 后执行 `expect.response` 规则 +- **THEN** 系统 SHALL 将响应转换为小写 hex 字符串 `504f4e47` 后执行 `expect.response` expectation #### Scenario: responseSize matcher 校验通过 - **WHEN** udp target 配置 `expect.responseSize: { gte: 4 }`,且实际响应为 4 字节 diff --git a/probe-config.schema.json b/probe-config.schema.json index 649c7ce..e1e6323 100644 --- a/probe-config.schema.json +++ b/probe-config.schema.json @@ -604,20 +604,21 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, { - "anyOf": [ - { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { "anyOf": [ { "type": "string" @@ -630,65 +631,36 @@ }, { "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" } ] }, - { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - }, - { - "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" - } - ] - }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" - } - } + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" } - ] + } } ] }, @@ -1915,20 +1887,21 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, { - "anyOf": [ - { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { "anyOf": [ { "type": "string" @@ -1941,65 +1914,36 @@ }, { "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" } ] }, - { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - }, - { - "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" - } - ] - }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" - } - } + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" } - ] + } } ] }, @@ -3716,20 +3660,21 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, { - "anyOf": [ - { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { "anyOf": [ { "type": "string" @@ -3742,65 +3687,36 @@ }, { "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" } ] }, - { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - }, - { - "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" - } - ] - }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" - } - } + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" } - ] + } } ] }, @@ -4625,7 +4541,7 @@ "$id": "https://dial.local/probe-config.schema.json", "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { - "ContentRules": { + "ContentExpectations": { "type": "array", "items": { "additionalProperties": false, @@ -4868,7 +4784,7 @@ } } }, - "KeyValueExpect": { + "KeyedExpectations": { "additionalProperties": { "anyOf": [ { @@ -4884,20 +4800,21 @@ }, { "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" } ] }, { - "anyOf": [ - { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { "anyOf": [ { "type": "string" @@ -4910,71 +4827,42 @@ }, { "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" } ] }, - { - "additionalProperties": false, - "minProperties": 1, - "type": "object", - "properties": { - "contains": { - "type": "string" - }, - "empty": { - "type": "boolean" - }, - "equals": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - }, - { - "type": "null" - }, - { - "items": {}, - "type": "array" - }, - { - "additionalProperties": {}, - "type": "object" - } - ] - }, - "exists": { - "type": "boolean" - }, - "gt": { - "type": "number" - }, - "gte": { - "type": "number" - }, - "lt": { - "type": "number" - }, - "lte": { - "type": "number" - }, - "regex": { - "type": "string" - } - } + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" } - ] + } } ] }, "type": "object" }, - "ValueMatcher": { + "ValueExpectation": { "anyOf": [ { "anyOf": [ @@ -5048,6 +4936,61 @@ } } ] + }, + "ValueMatcher": { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } } } } diff --git a/src/server/checker/expect/content.ts b/src/server/checker/expect/content.ts index 237f81b..9276cab 100644 --- a/src/server/checker/expect/content.ts +++ b/src/server/checker/expect/content.ts @@ -1,53 +1,67 @@ import { DOMParser } from "@xmldom/xmldom"; import * as cheerio from "cheerio"; +import { isPlainObject } from "es-toolkit"; import * as xpath from "xpath"; import type { CheckFailure } from "../types"; import type { - ContentCssRule, - ContentJsonRule, - ContentRule, - ContentRules, - ContentXpathRule, - ExpectResult, + ContentCssExpectation, + ContentExpectation, + ContentExpectations, + ContentJsonExpectation, + ContentValueExpectation, + ContentXpathExpectation, + ExpectationResult, + RawContentCssExpectation, + RawContentExpectation, + RawContentExpectations, + RawContentJsonExpectation, + RawContentXpathExpectation, + ValueExpectation, + ValueMatcher, } from "./types"; import { errorFailure, mismatchFailure } from "./failure"; -import { applyMatcher, evaluateJsonPath } from "./matcher"; +import { CONTENT_EXTRACTOR_KEY_SET, MATCHER_KEY_SET } from "./keys"; +import { applyValueMatcher, displayValueExpectation, evaluateJsonPath } from "./value"; type ParsedJsonResult = { error: string; ok: false } | { ok: true; value: unknown }; -export function checkContentRules( +export function checkContentExpectations( source: unknown, - rules: ContentRules | undefined, + expectations: ContentExpectations | undefined, options: { path?: string; phase: CheckFailure["phase"] }, -): ExpectResult { - if (!rules || rules.length === 0) return { failure: null, matched: true }; +): ExpectationResult { + if (!expectations || expectations.length === 0) return { failure: null, matched: true }; const basePath = options.path ?? options.phase; let parsedJson: ParsedJsonResult | undefined; - for (let i = 0; i < rules.length; i++) { - const rule = rules[i]!; - if ("json" in rule && parsedJson === undefined) { + for (let i = 0; i < expectations.length; i++) { + const expectation = expectations[i]!; + if (expectation.kind === "json" && parsedJson === undefined) { parsedJson = parseJsonSource(source); } - const result = checkSingleContentRule(source, rule, `${basePath}[${i}]`, options.phase, parsedJson); + const result = checkSingleContentExpectation(source, expectation, `${basePath}[${i}]`, options.phase, parsedJson); if (!result.matched) return result; } return { failure: null, matched: true }; } -function checkCssRule( +export function resolveContentExpectations(raw: RawContentExpectations | undefined): ContentExpectations | undefined { + if (raw === undefined) return undefined; + return raw.map((entry) => resolveContentExpectation(entry)); +} + +function checkCssExpectation( source: unknown, - rule: ContentCssRule, - rulePath: string, + expectation: ContentCssExpectation, + expectationPath: string, phase: CheckFailure["phase"], -): ExpectResult { - const { attr, selector, ...matcher } = rule; - const fullPath = `${rulePath}.css(${selector}${attr ? `@${attr}` : ""})`; +): ExpectationResult { + const fullPath = `${expectationPath}.css(${expectation.selector}${expectation.attr ? `@${expectation.attr}` : ""})`; let $: cheerio.CheerioAPI; try { @@ -56,13 +70,18 @@ function checkCssRule( return { failure: errorFailure(phase, fullPath, "failed to parse HTML"), matched: false }; } - const el = $(selector).first(); - const actual = el.length === 0 ? undefined : attr ? el.attr(attr) : el.text(); - const effectiveMatcher = Object.keys(matcher).length === 0 ? { exists: true } : matcher; + const el = $(expectation.selector).first(); + const actual = el.length === 0 ? undefined : expectation.attr ? el.attr(expectation.attr) : el.text(); - if (!applyMatcher(actual, effectiveMatcher)) { + if (!applyValueMatcher(actual, expectation.matcher)) { return { - failure: mismatchFailure(phase, fullPath, effectiveMatcher, actual, `css selector ${selector} mismatch`), + failure: mismatchFailure( + phase, + fullPath, + displayValueExpectation(expectation.matcher), + actual, + `css selector ${expectation.selector} mismatch`, + ), matched: false, }; } @@ -70,24 +89,28 @@ function checkCssRule( return { failure: null, matched: true }; } -function checkJsonRule( - rule: ContentJsonRule, - rulePath: string, +function checkJsonExpectation( + expectation: ContentJsonExpectation, + expectationPath: string, phase: CheckFailure["phase"], parsedJson?: ParsedJsonResult, -): ExpectResult { - const { path, ...matcher } = rule; - const fullPath = `${rulePath}.json(${path})`; +): ExpectationResult { + const fullPath = `${expectationPath}.json(${expectation.path})`; if (!parsedJson?.ok) { return { failure: errorFailure(phase, fullPath, parsedJson?.error ?? "content is not valid JSON"), matched: false }; } - const actual = evaluateJsonPath(parsedJson.value, path); - const effectiveMatcher = Object.keys(matcher).length === 0 ? { exists: true } : matcher; - if (!applyMatcher(actual, effectiveMatcher)) { + const actual = evaluateJsonPath(parsedJson.value, expectation.path); + if (!applyValueMatcher(actual, expectation.matcher)) { return { - failure: mismatchFailure(phase, fullPath, effectiveMatcher, actual, `json path ${path} mismatch`), + failure: mismatchFailure( + phase, + fullPath, + displayValueExpectation(expectation.matcher), + actual, + `json path ${expectation.path} mismatch`, + ), matched: false, }; } @@ -95,34 +118,53 @@ function checkJsonRule( return { failure: null, matched: true }; } -function checkSingleContentRule( +function checkSingleContentExpectation( source: unknown, - rule: ContentRule, - rulePath: string, + expectation: ContentExpectation, + expectationPath: string, phase: CheckFailure["phase"], parsedJson?: ParsedJsonResult, -): ExpectResult { - if ("json" in rule) return checkJsonRule(rule.json, rulePath, phase, parsedJson); - if ("css" in rule) return checkCssRule(source, rule.css, rulePath, phase); - if ("xpath" in rule) return checkXpathRule(source, rule.xpath, rulePath, phase); +): ExpectationResult { + switch (expectation.kind) { + case "css": + return checkCssExpectation(source, expectation, expectationPath, phase); + case "json": + return checkJsonExpectation(expectation, expectationPath, phase, parsedJson); + case "value": + return checkValueContentExpectation(source, expectation, expectationPath, phase); + case "xpath": + return checkXpathExpectation(source, expectation, expectationPath, phase); + } +} - if (!applyMatcher(source, rule, { stringifyNonString: true })) { +function checkValueContentExpectation( + source: unknown, + expectation: ContentValueExpectation, + expectationPath: string, + phase: CheckFailure["phase"], +): ExpectationResult { + if (!applyValueMatcher(source, expectation.matcher, { stringifyNonString: true })) { return { - failure: mismatchFailure(phase, rulePath, rule, source, `${phase} rule mismatch`), + failure: mismatchFailure( + phase, + expectationPath, + displayValueExpectation(expectation.matcher), + source, + `${phase} expectation mismatch`, + ), matched: false, }; } return { failure: null, matched: true }; } -function checkXpathRule( +function checkXpathExpectation( source: unknown, - rule: ContentXpathRule, - rulePath: string, + expectation: ContentXpathExpectation, + expectationPath: string, phase: CheckFailure["phase"], -): ExpectResult { - const { path, ...matcher } = rule; - const fullPath = `${rulePath}.xpath(${path})`; +): ExpectationResult { + const fullPath = `${expectationPath}.xpath(${expectation.path})`; let doc: ReturnType; try { @@ -131,13 +173,18 @@ function checkXpathRule( return { failure: errorFailure(phase, fullPath, "failed to parse XML/HTML"), matched: false }; } - const result = xpath.select(path, doc as unknown as Node); + const result = xpath.select(expectation.path, doc as unknown as Node); const actual = xpathValue(result); - const effectiveMatcher = Object.keys(matcher).length === 0 ? { exists: true } : matcher; - if (!applyMatcher(actual, effectiveMatcher)) { + if (!applyValueMatcher(actual, expectation.matcher)) { return { - failure: mismatchFailure(phase, fullPath, effectiveMatcher, actual, `xpath ${path} mismatch`), + failure: mismatchFailure( + phase, + fullPath, + displayValueExpectation(expectation.matcher), + actual, + `xpath ${expectation.path} mismatch`, + ), matched: false, }; } @@ -154,6 +201,28 @@ function contentText(source: unknown): string { return JSON.stringify(source) ?? ""; } +function extractDirectMatcher(raw: Record): ValueMatcher { + const matcher: ValueMatcher = {}; + for (const [key, value] of Object.entries(raw)) { + if (MATCHER_KEY_SET.has(key) && value !== undefined) { + (matcher as Record)[key] = value; + } + } + return matcher; +} + +function extractExtractorMatcher(raw: Record, ownFields: ReadonlySet): ValueExpectation { + const matcher: ValueMatcher = {}; + for (const [key, value] of Object.entries(raw)) { + if (ownFields.has(key)) continue; + if (MATCHER_KEY_SET.has(key) && value !== undefined) { + (matcher as Record)[key] = value; + } + } + if (Object.keys(matcher).length === 0) return { exists: true }; + return matcher; +} + function parseJsonSource(source: unknown): ParsedJsonResult { if (typeof source !== "string") return { ok: true, value: source }; try { @@ -163,6 +232,44 @@ function parseJsonSource(source: unknown): ParsedJsonResult { } } +function resolveContentExpectation(raw: RawContentExpectation): ContentExpectation { + if (!isPlainObject(raw)) { + return { kind: "value", matcher: { equals: raw } }; + } + const record = raw as Record; + + if (CONTENT_EXTRACTOR_KEY_SET.has("json") && isPlainObject(record["json"])) { + const json = record["json"] as RawContentJsonExpectation; + return { + kind: "json", + matcher: extractExtractorMatcher(json as unknown as Record, new Set(["path"])), + path: json.path, + }; + } + + if (isPlainObject(record["css"])) { + const css = record["css"] as RawContentCssExpectation; + const expectation: ContentCssExpectation = { + kind: "css", + matcher: extractExtractorMatcher(css as unknown as Record, new Set(["attr", "selector"])), + selector: css.selector, + }; + if (css.attr !== undefined) expectation.attr = css.attr; + return expectation; + } + + if (isPlainObject(record["xpath"])) { + const xpathExpectation = record["xpath"] as RawContentXpathExpectation; + return { + kind: "xpath", + matcher: extractExtractorMatcher(xpathExpectation as unknown as Record, new Set(["path"])), + path: xpathExpectation.path, + }; + } + + return { kind: "value", matcher: extractDirectMatcher(record) }; +} + function xpathValue(result: unknown): unknown { if (!Array.isArray(result)) return result; if (result.length === 0) return undefined; diff --git a/src/server/checker/expect/headers.ts b/src/server/checker/expect/headers.ts new file mode 100644 index 0000000..e49e6b8 --- /dev/null +++ b/src/server/checker/expect/headers.ts @@ -0,0 +1,14 @@ +import type { ExpectationResult, KeyedExpectations } from "./types"; + +import { checkKeyedExpectations } from "./keyed"; + +export function checkHeaderExpectations( + headers: Record, + expectations?: KeyedExpectations, +): ExpectationResult { + return checkKeyedExpectations(headers, expectations, { + normalizeKey: (key) => key.toLowerCase(), + path: "headers", + phase: "headers", + }); +} diff --git a/src/server/checker/expect/key-value.ts b/src/server/checker/expect/key-value.ts deleted file mode 100644 index b79ccf9..0000000 --- a/src/server/checker/expect/key-value.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { CheckFailure } from "../types"; -import type { ExpectResult, KeyValueExpect } from "./types"; - -import { mismatchFailure } from "./failure"; -import { checkExpectValue } from "./matcher"; - -export function checkKeyValueExpect( - actual: Record, - expected: KeyValueExpect | undefined, - options: { normalizeKey?: (key: string) => string; path?: string; phase: CheckFailure["phase"] }, -): ExpectResult { - if (!expected) return { failure: null, matched: true }; - - const normalizeKey = options.normalizeKey ?? ((key: string) => key); - const basePath = options.path ?? options.phase; - const actualMap = new Map(); - for (const [key, value] of Object.entries(actual)) { - actualMap.set(normalizeKey(key), value); - } - - for (const [key, expectedValue] of Object.entries(expected)) { - const actualValue = actualMap.get(normalizeKey(key)); - if (!checkExpectValue(actualValue, expectedValue)) { - return { - failure: mismatchFailure(options.phase, `${basePath}.${key}`, expectedValue, actualValue, `${key} mismatch`), - matched: false, - }; - } - } - - return { failure: null, matched: true }; -} diff --git a/src/server/checker/expect/keyed.ts b/src/server/checker/expect/keyed.ts new file mode 100644 index 0000000..0611f9d --- /dev/null +++ b/src/server/checker/expect/keyed.ts @@ -0,0 +1,46 @@ +import type { CheckFailure } from "../types"; +import type { ExpectationResult, KeyedExpectations, RawKeyedExpectations } from "./types"; + +import { mismatchFailure } from "./failure"; +import { applyValueMatcher, displayValueExpectation, resolveValueExpectation } from "./value"; + +export function checkKeyedExpectations( + actual: Record, + expectations: KeyedExpectations | undefined, + options: { normalizeKey?: (key: string) => string; path?: string; phase: CheckFailure["phase"] }, +): ExpectationResult { + if (!expectations || expectations.length === 0) return { failure: null, matched: true }; + + const normalizeKey = options.normalizeKey ?? ((key: string) => key); + const basePath = options.path ?? options.phase; + const actualMap = new Map(); + for (const [key, value] of Object.entries(actual)) { + actualMap.set(normalizeKey(key), value); + } + + for (const expectation of expectations) { + const actualValue = actualMap.get(normalizeKey(expectation.key)); + if (!applyValueMatcher(actualValue, expectation.matcher)) { + return { + failure: mismatchFailure( + options.phase, + `${basePath}.${expectation.key}`, + displayValueExpectation(expectation.matcher), + actualValue, + `${expectation.key} mismatch`, + ), + matched: false, + }; + } + } + + return { failure: null, matched: true }; +} + +export function resolveKeyedExpectations(raw: RawKeyedExpectations | undefined): KeyedExpectations | undefined { + if (raw === undefined) return undefined; + return Object.entries(raw).map(([key, value]) => ({ + key, + matcher: resolveValueExpectation(value), + })); +} diff --git a/src/server/checker/expect/keys.ts b/src/server/checker/expect/keys.ts new file mode 100644 index 0000000..aad94b7 --- /dev/null +++ b/src/server/checker/expect/keys.ts @@ -0,0 +1,7 @@ +export const MatcherKeys = ["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "regex"] as const; + +export const MATCHER_KEY_SET: ReadonlySet = new Set(MatcherKeys); + +export const ContentExtractorKeys = ["css", "json", "xpath"] as const; + +export const CONTENT_EXTRACTOR_KEY_SET: ReadonlySet = new Set(ContentExtractorKeys); diff --git a/src/server/checker/expect/normalize.ts b/src/server/checker/expect/normalize.ts deleted file mode 100644 index cb9b6cf..0000000 --- a/src/server/checker/expect/normalize.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { ValueMatcherPrimitive } from "./types"; - -import { isValueMatcherObject } from "./matcher"; - -export function isValueMatcherPrimitive(value: unknown): value is ValueMatcherPrimitive { - return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean"; -} - -export function normalizeExpectMatchers(expect: Record, keys: string[]): void { - for (const key of keys) { - if (key in expect) { - expect[key] = normalizeValueMatcher(expect[key]); - } - } -} - -export function normalizeValueMatcher(value: unknown): unknown { - if (value === undefined) return undefined; - if (isValueMatcherObject(value)) return value; - if (isValueMatcherPrimitive(value)) return { equals: value }; - return value; -} diff --git a/src/server/checker/expect/status.ts b/src/server/checker/expect/status.ts new file mode 100644 index 0000000..33f1584 --- /dev/null +++ b/src/server/checker/expect/status.ts @@ -0,0 +1,27 @@ +import { isNumber } from "es-toolkit"; + +import type { ExpectationResult } from "./types"; + +import { mismatchFailure } from "./failure"; + +export function checkStatusCode(statusCode: number, allowed: Array): ExpectationResult { + const matched = allowed.some((pattern) => { + if (isNumber(pattern)) return statusCode === pattern; + const base = parseInt(pattern[0]!, 10) * 100; + return statusCode >= base && statusCode < base + 100; + }); + + if (!matched) { + return { + failure: mismatchFailure( + "status", + "status", + allowed, + statusCode, + `status ${statusCode} not in [${allowed.join(", ")}]`, + ), + matched: false, + }; + } + return { failure: null, matched: true }; +} diff --git a/src/server/checker/expect/types.ts b/src/server/checker/expect/types.ts index fadc6d5..14cc95f 100644 --- a/src/server/checker/expect/types.ts +++ b/src/server/checker/expect/types.ts @@ -1,32 +1,75 @@ import type { CheckFailure, JsonValue } from "../types"; -export interface ContentCssRule extends ValueMatcher { +export interface ContentCssExpectation { attr?: string; + kind: "css"; + matcher: ValueExpectation; selector: string; } -export interface ContentJsonRule extends ValueMatcher { +export type ContentExpectation = + | ContentCssExpectation + | ContentJsonExpectation + | ContentValueExpectation + | ContentXpathExpectation; + +export type ContentExpectations = ContentExpectation[]; + +export interface ContentJsonExpectation { + kind: "json"; + matcher: ValueExpectation; path: string; } -export type ContentRule = - | ValueMatcher - | { css: ContentCssRule } - | { json: ContentJsonRule } - | { xpath: ContentXpathRule }; +export interface ContentValueExpectation { + kind: "value"; + matcher: ValueExpectation; +} -export type ContentRules = ContentRule[]; - -export interface ContentXpathRule extends ValueMatcher { +export interface ContentXpathExpectation { + kind: "xpath"; + matcher: ValueExpectation; path: string; } -export interface ExpectResult { +export interface ExpectationResult { failure: CheckFailure | null; matched: boolean; } -export type KeyValueExpect = Record; +export interface KeyedExpectation { + key: string; + matcher: ValueExpectation; +} + +export type KeyedExpectations = KeyedExpectation[]; + +export interface RawContentCssExpectation extends ValueMatcher { + attr?: string; + selector: string; +} + +export type RawContentExpectation = + | ValueMatcher + | { css: RawContentCssExpectation } + | { json: RawContentJsonExpectation } + | { xpath: RawContentXpathExpectation }; + +export type RawContentExpectations = RawContentExpectation[]; + +export interface RawContentJsonExpectation extends ValueMatcher { + path: string; +} + +export interface RawContentXpathExpectation extends ValueMatcher { + path: string; +} + +export type RawKeyedExpectations = Record; + +export type RawValueExpectation = ValueMatcher | ValueMatcherPrimitive; + +export type ValueExpectation = ValueMatcher; export interface ValueMatcher { contains?: string; @@ -40,6 +83,4 @@ export interface ValueMatcher { regex?: string; } -export type ValueMatcherInput = ValueMatcher | ValueMatcherPrimitive; - export type ValueMatcherPrimitive = boolean | null | number | string; diff --git a/src/server/checker/expect/validate-matcher.ts b/src/server/checker/expect/validate.ts similarity index 53% rename from src/server/checker/expect/validate-matcher.ts rename to src/server/checker/expect/validate.ts index 5ddcf81..e8ab034 100644 --- a/src/server/checker/expect/validate-matcher.ts +++ b/src/server/checker/expect/validate.ts @@ -6,14 +6,9 @@ import type { ConfigValidationIssue } from "../schema/issues"; import type { JsonValue } from "../types"; import { issue, joinPath } from "../schema/issues"; -import { isValueMatcherPrimitive } from "./normalize"; +import { CONTENT_EXTRACTOR_KEY_SET, MATCHER_KEY_SET } from "./keys"; import { isUnsafeRegex } from "./redos"; - -export const MatcherKeys = ["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "regex"] as const; - -const MATCHER_KEY_SET = new Set(MatcherKeys); -const EXTRACTOR_KEYS = ["css", "json", "xpath"] as const; -const EXTRACTOR_KEY_SET = new Set(EXTRACTOR_KEYS); +import { isValueMatcherPrimitive } from "./value"; export function isJsonValue(value: unknown): value is JsonValue { if (value === null) return true; @@ -28,45 +23,64 @@ export function isPlainRecord(value: unknown): value is Record return isPlainObject(value); } -export function validateContentRules(rules: unknown, path: string, targetName?: string): ConfigValidationIssue[] { - if (!Array.isArray(rules)) return [issue("invalid-type", path, "必须为数组", targetName)]; - return rules.flatMap((rule, index) => validateContentRule(rule, `${path}[${index}]`, targetName)); -} - -export function validateJsonPath(path: string, rulePath: string, targetName?: string): ConfigValidationIssue[] { +export function validateJsonPath(path: string, expectationPath: string, targetName?: string): ConfigValidationIssue[] { if (!path.startsWith("$.") || path.length <= 2) { - return [issue("invalid-jsonpath", joinPath(rulePath, "path"), '必须为以 "$." 开头的有效 JSONPath', targetName)]; + return [ + issue("invalid-jsonpath", joinPath(expectationPath, "path"), '必须为以 "$." 开头的有效 JSONPath', targetName), + ]; } const issues: ConfigValidationIssue[] = []; const segments = path.slice(2).split("."); for (const seg of segments) { if (seg === "") { - issues.push(issue("invalid-jsonpath", joinPath(rulePath, "path"), "包含空段", targetName)); + issues.push(issue("invalid-jsonpath", joinPath(expectationPath, "path"), "包含空段", targetName)); } const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg); if (bracketMatch?.[1]!.trim() === "") { - issues.push(issue("invalid-jsonpath", joinPath(rulePath, "path"), "数组访问缺少属性名", targetName)); + issues.push(issue("invalid-jsonpath", joinPath(expectationPath, "path"), "数组访问缺少属性名", targetName)); } } return issues; } -export function validateKeyValueExpect(value: unknown, path: string, targetName?: string): ConfigValidationIssue[] { +export function validateRawContentExpectations( + expectations: unknown, + path: string, + targetName?: string, +): ConfigValidationIssue[] { + if (!Array.isArray(expectations)) return [issue("invalid-type", path, "必须为数组", targetName)]; + return expectations.flatMap((entry, index) => validateRawContentExpectation(entry, `${path}[${index}]`, targetName)); +} + +export function validateRawKeyedExpectations( + value: unknown, + path: string, + targetName?: string, + options?: { caseInsensitive?: boolean }, +): ConfigValidationIssue[] { if (!isPlainRecord(value)) return [issue("invalid-type", path, "必须为对象", targetName)]; const issues: ConfigValidationIssue[] = []; + if (options?.caseInsensitive) { + const seen = new Map(); + for (const key of Object.keys(value)) { + const lower = key.toLowerCase(); + const prev = seen.get(lower); + if (prev !== undefined) { + issues.push(issue("duplicate-key", joinPath(path, key), `与 "${prev}" 大小写归一化后重复`, targetName)); + } else { + seen.set(lower, key); + } + } + } for (const [key, item] of Object.entries(value)) { const itemPath = joinPath(path, key); - if (isPlainRecord(item)) { - issues.push(...validateValueMatcher(item, itemPath, targetName)); - } else if (!isJsonValue(item)) { - issues.push(issue("invalid-type", itemPath, "必须为 JSON value 或 matcher 对象", targetName)); - } + issues.push(...validateRawValueExpectation(item, itemPath, targetName)); } return issues; } -export function validateValueMatcher( +export function validateRawValueExpectation( matcher: unknown, path: string, targetName?: string, @@ -74,6 +88,16 @@ export function validateValueMatcher( ): ConfigValidationIssue[] { const requireAtLeastOne = options.requireAtLeastOne ?? true; if (isValueMatcherPrimitive(matcher)) return []; + if (Array.isArray(matcher)) { + return [ + issue( + "invalid-type", + path, + "必须为 primitive 原始值或 matcher 对象;如需数组 equals 匹配应写成 {equals: [...]}", + targetName, + ), + ]; + } if (!isPlainRecord(matcher)) return [issue("invalid-type", path, "必须为 primitive 原始值或 matcher 对象", targetName)]; @@ -100,82 +124,46 @@ export function validateValueMatcher( return issues; } -function validateContentRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] { - if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)]; - - const issues: ConfigValidationIssue[] = []; - const extractors = Object.keys(rule).filter((key) => EXTRACTOR_KEY_SET.has(key)); - const directMatchers = Object.keys(rule).filter((key) => MATCHER_KEY_SET.has(key)); - - for (const key of Object.keys(rule)) { - if (!MATCHER_KEY_SET.has(key) && !EXTRACTOR_KEY_SET.has(key)) { - issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName)); - } - } - - if (extractors.length > 1) { - issues.push(issue("multiple-content-rules", path, "一条规则不能同时包含多个 extractor", targetName)); - } - if (extractors.length === 1 && directMatchers.length > 0) { - issues.push(issue("invalid-content-rule", path, "直接 matcher 不能与 extractor 混用", targetName)); - } - if (issues.length > 0) return issues; - - if (extractors.length === 0) return validateValueMatcher(rule, path, targetName); - - const extractor = extractors[0]!; - switch (extractor) { - case "css": - return validateCssRule(rule["css"], joinPath(path, "css"), targetName); - case "json": - return validateJsonRule(rule["json"], joinPath(path, "json"), targetName); - case "xpath": - return validateXpathRule(rule["xpath"], joinPath(path, "xpath"), targetName); - } - - return []; -} - -function validateCssRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] { - if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)]; +function validateCssExpectation(expectation: unknown, path: string, targetName?: string): ConfigValidationIssue[] { + if (!isPlainRecord(expectation)) return [issue("invalid-type", path, "必须为对象", targetName)]; const issues: ConfigValidationIssue[] = []; - if (!isString(rule["selector"]) || rule["selector"].trim() === "") { + if (!isString(expectation["selector"]) || expectation["selector"].trim() === "") { issues.push(issue("invalid-type", joinPath(path, "selector"), "必须为非空字符串", targetName)); } - if ("attr" in rule && !isString(rule["attr"])) { + if ("attr" in expectation && !isString(expectation["attr"])) { issues.push(issue("invalid-type", joinPath(path, "attr"), "必须为字符串", targetName)); } - issues.push(...validateExtractorMatcher(rule, new Set(["attr", "selector"]), path, targetName)); + issues.push(...validateExtractorMatcher(expectation, new Set(["attr", "selector"]), path, targetName)); return issues; } function validateExtractorMatcher( - rule: Record, + expectation: Record, allowedFields: Set, path: string, targetName?: string, ): ConfigValidationIssue[] { const matcher: Record = {}; const issues: ConfigValidationIssue[] = []; - for (const [key, value] of Object.entries(rule)) { + for (const [key, value] of Object.entries(expectation)) { if (allowedFields.has(key)) continue; matcher[key] = value; } - issues.push(...validateValueMatcher(matcher, path, targetName, { requireAtLeastOne: false })); + issues.push(...validateRawValueExpectation(matcher, path, targetName, { requireAtLeastOne: false })); return issues; } -function validateJsonRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] { - if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)]; +function validateJsonExpectation(expectation: unknown, path: string, targetName?: string): ConfigValidationIssue[] { + if (!isPlainRecord(expectation)) return [issue("invalid-type", path, "必须为对象", targetName)]; const issues: ConfigValidationIssue[] = []; - if (!isString(rule["path"])) { + if (!isString(expectation["path"])) { issues.push(issue("invalid-type", joinPath(path, "path"), "必须为字符串", targetName)); } else { - issues.push(...validateJsonPath(rule["path"], path, targetName)); + issues.push(...validateJsonPath(expectation["path"], path, targetName)); } - issues.push(...validateExtractorMatcher(rule, new Set(["path"]), path, targetName)); + issues.push(...validateExtractorMatcher(expectation, new Set(["path"]), path, targetName)); return issues; } @@ -208,20 +196,62 @@ function validateMatcherValue(key: string, value: unknown, path: string, targetN } } -function validateXpathRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] { - if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)]; +function validateRawContentExpectation( + expectation: unknown, + path: string, + targetName?: string, +): ConfigValidationIssue[] { + if (!isPlainRecord(expectation)) return [issue("invalid-type", path, "必须为对象", targetName)]; + + const issues: ConfigValidationIssue[] = []; + const extractors = Object.keys(expectation).filter((key) => CONTENT_EXTRACTOR_KEY_SET.has(key)); + const directMatchers = Object.keys(expectation).filter((key) => MATCHER_KEY_SET.has(key)); + + for (const key of Object.keys(expectation)) { + if (!MATCHER_KEY_SET.has(key) && !CONTENT_EXTRACTOR_KEY_SET.has(key)) { + issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName)); + } + } + + if (extractors.length > 1) { + issues.push( + issue("multiple-content-expectations", path, "一条 expectation 不能同时包含多个 extractor", targetName), + ); + } + if (extractors.length === 1 && directMatchers.length > 0) { + issues.push(issue("invalid-content-expectation", path, "直接 matcher 不能与 extractor 混用", targetName)); + } + if (issues.length > 0) return issues; + + if (extractors.length === 0) return validateRawValueExpectation(expectation, path, targetName); + + const extractor = extractors[0]!; + switch (extractor) { + case "css": + return validateCssExpectation(expectation["css"], joinPath(path, "css"), targetName); + case "json": + return validateJsonExpectation(expectation["json"], joinPath(path, "json"), targetName); + case "xpath": + return validateXpathExpectation(expectation["xpath"], joinPath(path, "xpath"), targetName); + } + + return []; +} + +function validateXpathExpectation(expectation: unknown, path: string, targetName?: string): ConfigValidationIssue[] { + if (!isPlainRecord(expectation)) return [issue("invalid-type", path, "必须为对象", targetName)]; const issues: ConfigValidationIssue[] = []; - if (!isString(rule["path"]) || rule["path"].trim() === "") { + if (!isString(expectation["path"]) || expectation["path"].trim() === "") { issues.push(issue("invalid-type", joinPath(path, "path"), "必须为非空字符串", targetName)); } else { try { const doc = new DOMParser().parseFromString("", "text/xml"); - xpath.select(rule["path"], doc as unknown as Node); + xpath.select(expectation["path"], doc as unknown as Node); } catch { issues.push(issue("invalid-xpath", joinPath(path, "path"), "xpath 不合法", targetName)); } } - issues.push(...validateExtractorMatcher(rule, new Set(["path"]), path, targetName)); + issues.push(...validateExtractorMatcher(expectation, new Set(["path"]), path, targetName)); return issues; } diff --git a/src/server/checker/expect/matcher.ts b/src/server/checker/expect/value.ts similarity index 71% rename from src/server/checker/expect/matcher.ts rename to src/server/checker/expect/value.ts index 5142a20..730b4f3 100644 --- a/src/server/checker/expect/matcher.ts +++ b/src/server/checker/expect/value.ts @@ -1,15 +1,12 @@ import { isEmptyObject, isEqual, isNil, isPlainObject } from "es-toolkit"; -import type { CheckFailure, JsonValue } from "../types"; -import type { ExpectResult, ValueMatcher, ValueMatcherInput } from "./types"; +import type { CheckFailure } from "../types"; +import type { ExpectationResult, RawValueExpectation, ValueExpectation, ValueMatcher } from "./types"; import { mismatchFailure } from "./failure"; +import { MATCHER_KEY_SET } from "./keys"; -export const MatcherKeys = ["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "regex"] as const; - -const MATCHER_KEY_SET = new Set(MatcherKeys); - -export function applyMatcher( +export function applyValueMatcher( actual: unknown, matcher: ValueMatcher, options: { stringifyNonString?: boolean } = {}, @@ -57,28 +54,20 @@ export function applyMatcher( return true; } -export function checkExpectValue(actual: unknown, expected: JsonValue | ValueMatcher): boolean { - if (isValueMatcherObject(expected)) { - return applyMatcher(actual, expected); - } - return applyMatcher(actual, { equals: expected }); -} - -export function checkValueMatcher( +export function checkValueExpectation( actual: unknown, - matcher: undefined | ValueMatcherInput, + expectation: undefined | ValueExpectation, options: { message?: string; path: string; phase: CheckFailure["phase"]; stringifyNonString?: boolean }, -): ExpectResult { - if (matcher === undefined) return { failure: null, matched: true }; - const normalized = isValueMatcherObject(matcher) ? matcher : { equals: matcher }; - if (applyMatcher(actual, normalized, { stringifyNonString: options.stringifyNonString })) { +): ExpectationResult { + if (expectation === undefined) return { failure: null, matched: true }; + if (applyValueMatcher(actual, expectation, { stringifyNonString: options.stringifyNonString })) { return { failure: null, matched: true }; } return { failure: mismatchFailure( options.phase, options.path, - normalized, + displayValueExpectation(expectation), actual, options.message ?? `${options.path} mismatch`, ), @@ -86,6 +75,12 @@ export function checkValueMatcher( }; } +export function displayValueExpectation(expectation: ValueExpectation): unknown { + const entries = Object.entries(expectation).filter(([, value]) => value !== undefined); + if (entries.length === 1 && entries[0]?.[0] === "equals") return entries[0][1]; + return expectation; +} + export function evaluateJsonPath(json: unknown, path: string): unknown { if (!path.startsWith("$.")) return undefined; @@ -113,6 +108,18 @@ export function isValueMatcherObject(value: unknown): value is ValueMatcher { return isPlainObject(value) && Object.keys(value).some((key) => MATCHER_KEY_SET.has(key)); } +export function isValueMatcherPrimitive(value: unknown): value is boolean | null | number | string { + return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean"; +} + +export function resolveValueExpectation(raw: RawValueExpectation): ValueExpectation; +export function resolveValueExpectation(raw: RawValueExpectation | undefined): undefined | ValueExpectation; +export function resolveValueExpectation(raw: RawValueExpectation | undefined): undefined | ValueExpectation { + if (raw === undefined) return undefined; + if (isValueMatcherObject(raw)) return raw; + return { equals: raw }; +} + function compareNumber( actual: unknown, expected: number, diff --git a/src/server/checker/runner/cmd/execute.ts b/src/server/checker/runner/cmd/execute.ts index 0a744fb..0fc05e1 100644 --- a/src/server/checker/runner/cmd/execute.ts +++ b/src/server/checker/runner/cmd/execute.ts @@ -3,11 +3,16 @@ import { resolve } from "node:path"; import type { CheckResult, RawTargetConfig } from "../../types"; import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types"; -import type { CommandExpectConfig, CommandTargetConfig, ResolvedCommandTarget } from "./types"; +import type { + CommandTargetConfig, + RawCommandExpectConfig, + ResolvedCommandExpectConfig, + ResolvedCommandTarget, +} from "./types"; -import { checkContentRules } from "../../expect/content"; +import { checkContentExpectations, resolveContentExpectations } from "../../expect/content"; import { errorFailure } from "../../expect/failure"; -import { checkValueMatcher } from "../../expect/matcher"; +import { checkValueExpectation, resolveValueExpectation } from "../../expect/value"; import { parseSize } from "../../utils"; import { checkExitCode } from "./expect"; import { commandCheckerSchemas } from "./schema"; @@ -138,7 +143,7 @@ export class CommandChecker implements CheckerDefinition }; } - const durationResult = checkValueMatcher(durationMs, t.expect?.durationMs, { + const durationResult = checkValueExpectation(durationMs, t.expect?.durationMs, { message: "durationMs mismatch", path: "durationMs", phase: "duration", @@ -156,7 +161,10 @@ export class CommandChecker implements CheckerDefinition } if (t.expect?.stdout && t.expect.stdout.length > 0) { - const stdoutResult = checkContentRules(outputResult.stdout, t.expect.stdout, { path: "stdout", phase: "stdout" }); + const stdoutResult = checkContentExpectations(outputResult.stdout, t.expect.stdout, { + path: "stdout", + phase: "stdout", + }); if (!stdoutResult.matched) { return { detail: null, @@ -171,7 +179,10 @@ export class CommandChecker implements CheckerDefinition } if (t.expect?.stderr && t.expect.stderr.length > 0) { - const stderrResult = checkContentRules(outputResult.stderr, t.expect.stderr, { path: "stderr", phase: "stderr" }); + const stderrResult = checkContentExpectations(outputResult.stderr, t.expect.stderr, { + path: "stderr", + phase: "stderr", + }); if (!stderrResult.matched) { return { detail: null, @@ -207,6 +218,16 @@ export class CommandChecker implements CheckerDefinition const env = { ...process.env, ...(t.cmd.env ?? {}) } as Record; + const rawExpect = target.expect as RawCommandExpectConfig | undefined; + const resolvedExpect: ResolvedCommandExpectConfig = rawExpect + ? { + durationMs: resolveValueExpectation(rawExpect.durationMs), + exitCode: rawExpect.exitCode ?? [0], + stderr: resolveContentExpectations(rawExpect.stderr), + stdout: resolveContentExpectations(rawExpect.stdout), + } + : { exitCode: [0] }; + return { cmd: { args: t.cmd.args ?? [], @@ -216,11 +237,12 @@ export class CommandChecker implements CheckerDefinition maxOutputBytes, }, description: null, - expect: target.expect as CommandExpectConfig | undefined, + expect: resolvedExpect, group: target.group ?? "default", id: t.id, intervalMs: context.defaultIntervalMs, name: t.name ?? null, + rawExpect, timeoutMs: context.defaultTimeoutMs, type: "cmd", } satisfies ResolvedCommandTarget; diff --git a/src/server/checker/runner/cmd/expect.ts b/src/server/checker/runner/cmd/expect.ts index 32adbcd..1576df4 100644 --- a/src/server/checker/runner/cmd/expect.ts +++ b/src/server/checker/runner/cmd/expect.ts @@ -1,8 +1,8 @@ -import type { ExpectResult } from "../../expect/types"; +import type { ExpectationResult } from "../../expect/types"; import { mismatchFailure } from "../../expect/failure"; -export function checkExitCode(exitCode: number, allowed: number[]): ExpectResult { +export function checkExitCode(exitCode: number, allowed: number[]): ExpectationResult { if (!allowed.includes(exitCode)) { return { failure: mismatchFailure( diff --git a/src/server/checker/runner/cmd/schema.ts b/src/server/checker/runner/cmd/schema.ts index 4a67b8a..317491b 100644 --- a/src/server/checker/runner/cmd/schema.ts +++ b/src/server/checker/runner/cmd/schema.ts @@ -3,8 +3,8 @@ import { Type } from "@sinclair/typebox"; import type { CheckerSchemas } from "../types"; import { - createContentRulesSchema, - createValueMatcherSchema, + createRawContentExpectationsSchema, + createRawValueExpectationSchema, sizeSchema, stringMapSchema, } from "../../schema/fragments"; @@ -29,10 +29,10 @@ export const commandCheckerSchemas: CheckerSchemas = { ), expect: Type.Object( { - durationMs: Type.Optional(createValueMatcherSchema()), + durationMs: Type.Optional(createRawValueExpectationSchema()), exitCode: Type.Optional(Type.Array(Type.Integer())), - stderr: Type.Optional(createContentRulesSchema()), - stdout: Type.Optional(createContentRulesSchema()), + stderr: Type.Optional(createRawContentExpectationsSchema()), + stdout: Type.Optional(createRawContentExpectationsSchema()), }, { additionalProperties: false }, ), diff --git a/src/server/checker/runner/cmd/types.ts b/src/server/checker/runner/cmd/types.ts index a230ef8..0b2d42c 100644 --- a/src/server/checker/runner/cmd/types.ts +++ b/src/server/checker/runner/cmd/types.ts @@ -1,4 +1,9 @@ -import type { ContentRules, ValueMatcherInput } from "../../expect/types"; +import type { + ContentExpectations, + RawContentExpectations, + RawValueExpectation, + ValueExpectation, +} from "../../expect/types"; import type { ResolvedTargetBase } from "../../types"; export interface CommandDefaultsConfig { @@ -6,13 +11,6 @@ export interface CommandDefaultsConfig { maxOutputBytes?: string; } -export interface CommandExpectConfig { - durationMs?: ValueMatcherInput; - exitCode?: number[]; - stderr?: ContentRules; - stdout?: ContentRules; -} - export interface CommandTargetConfig { args?: string[]; cwd?: string; @@ -21,6 +19,13 @@ export interface CommandTargetConfig { maxOutputBytes?: string; } +export interface RawCommandExpectConfig { + durationMs?: RawValueExpectation; + exitCode?: number[]; + stderr?: RawContentExpectations; + stdout?: RawContentExpectations; +} + export interface ResolvedCommandConfig { args: string[]; cwd: string; @@ -29,12 +34,20 @@ export interface ResolvedCommandConfig { maxOutputBytes: number; } +export interface ResolvedCommandExpectConfig { + durationMs?: ValueExpectation; + exitCode: number[]; + stderr?: ContentExpectations; + stdout?: ContentExpectations; +} + export interface ResolvedCommandTarget extends ResolvedTargetBase { cmd: ResolvedCommandConfig; - expect?: CommandExpectConfig; + expect?: ResolvedCommandExpectConfig; group: string; intervalMs: number; name: null | string; + rawExpect?: RawCommandExpectConfig; timeoutMs: number; type: "cmd"; } diff --git a/src/server/checker/runner/cmd/validate.ts b/src/server/checker/runner/cmd/validate.ts index a69ed82..ee4505e 100644 --- a/src/server/checker/runner/cmd/validate.ts +++ b/src/server/checker/runner/cmd/validate.ts @@ -1,17 +1,16 @@ -import { isNumber, isPlainObject, isString } from "es-toolkit"; +import { isNumber, isString } from "es-toolkit"; import type { ConfigValidationIssue } from "../../schema/issues"; import type { CheckerValidationInput } from "../types"; -import { normalizeExpectMatchers } from "../../expect/normalize"; -import { validateContentRules, validateValueMatcher } from "../../expect/validate-matcher"; +import { isPlainRecord, validateRawContentExpectations, validateRawValueExpectation } from "../../expect/validate"; import { issue, joinPath } from "../../schema/issues"; import { parseSize } from "../../utils"; export function validateCommandConfig(input: CheckerValidationInput): ConfigValidationIssue[] { const issues: ConfigValidationIssue[] = []; const defaults = - isPlainObject(input.defaults) && isPlainObject(input.defaults["cmd"]) ? input.defaults["cmd"] : undefined; + isPlainRecord(input.defaults) && isPlainRecord(input.defaults["cmd"]) ? input.defaults["cmd"] : undefined; if (isSizeInput(defaults?.["maxOutputBytes"])) { issues.push(...validateSizeValue(defaults["maxOutputBytes"], "defaults.cmd.maxOutputBytes")); @@ -19,7 +18,7 @@ export function validateCommandConfig(input: CheckerValidationInput): ConfigVali for (let i = 0; i < input.targets.length; i++) { const target = input.targets[i] as unknown; - if (!isPlainObject(target)) continue; + if (!isPlainRecord(target)) continue; if (target["type"] !== "cmd") continue; issues.push(...validateCommandTarget(target, `targets[${i}]`)); } @@ -39,20 +38,18 @@ function isSizeInput(value: unknown): value is number | string { function validateCommandExpect(target: Record, path: string): ConfigValidationIssue[] { const targetName = getTargetName(target); const expect = target["expect"]; - if (expect === undefined || expect === null || !isPlainObject(expect)) return []; + if (expect === undefined || expect === null || !isPlainRecord(expect)) return []; const issues: ConfigValidationIssue[] = []; const expectPath = joinPath(path, "expect"); - normalizeExpectMatchers(expect, ["durationMs"]); - if (expect["stdout"] !== undefined) { - issues.push(...validateContentRules(expect["stdout"], joinPath(expectPath, "stdout"), targetName)); + issues.push(...validateRawContentExpectations(expect["stdout"], joinPath(expectPath, "stdout"), targetName)); } if (expect["stderr"] !== undefined) { - issues.push(...validateContentRules(expect["stderr"], joinPath(expectPath, "stderr"), targetName)); + issues.push(...validateRawContentExpectations(expect["stderr"], joinPath(expectPath, "stderr"), targetName)); } if (expect["durationMs"] !== undefined) { - issues.push(...validateValueMatcher(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName)); + issues.push(...validateRawValueExpectation(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName)); } return issues; } @@ -61,7 +58,7 @@ function validateCommandTarget(target: Record, path: string): C const issues: ConfigValidationIssue[] = []; const targetName = getTargetName(target); const cmd = target["cmd"]; - if (!isPlainObject(cmd)) { + if (!isPlainRecord(cmd)) { issues.push(issue("required", joinPath(path, "cmd"), "缺少 cmd.exec 字段", targetName)); issues.push(...validateCommandExpect(target, path)); return issues; diff --git a/src/server/checker/runner/db/execute.ts b/src/server/checker/runner/db/execute.ts index 5ea6c7d..4bb9a21 100644 --- a/src/server/checker/runner/db/execute.ts +++ b/src/server/checker/runner/db/execute.ts @@ -3,11 +3,12 @@ import { isError } from "es-toolkit"; import type { CheckResult, RawTargetConfig } from "../../types"; import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types"; -import type { DbExpectConfig, DbTargetConfig, ResolvedDbTarget } from "./types"; +import type { DbTargetConfig, RawDbExpectConfig, ResolvedDbExpectConfig, ResolvedDbTarget } from "./types"; -import { checkContentRules } from "../../expect/content"; +import { checkContentExpectations, resolveContentExpectations } from "../../expect/content"; import { errorFailure } from "../../expect/failure"; -import { checkValueMatcher } from "../../expect/matcher"; +import { resolveKeyedExpectations } from "../../expect/keyed"; +import { checkValueExpectation, resolveValueExpectation } from "../../expect/value"; import { checkRowCount, checkRows } from "./expect"; import { dbCheckerSchemas } from "./schema"; import { validateDbConfig } from "./validate"; @@ -77,7 +78,7 @@ export class DbChecker implements CheckerDefinition { rowCount: null, rowsPreview: null, }; - const durationResult = checkValueMatcher(durationMs, t.expect?.durationMs, { + const durationResult = checkValueExpectation(durationMs, t.expect?.durationMs, { message: "durationMs mismatch", path: "durationMs", phase: "duration", @@ -138,7 +139,7 @@ export class DbChecker implements CheckerDefinition { }; } - const durationResult = checkValueMatcher(durationMs, t.expect?.durationMs, { + const durationResult = checkValueExpectation(durationMs, t.expect?.durationMs, { message: "durationMs mismatch", path: "durationMs", phase: "duration", @@ -186,7 +187,10 @@ export class DbChecker implements CheckerDefinition { } if (t.expect?.result && t.expect.result.length > 0) { - const resultCheck = checkContentRules({ rowCount, rows }, t.expect.result, { path: "result", phase: "result" }); + const resultCheck = checkContentExpectations({ rowCount, rows }, t.expect.result, { + path: "result", + phase: "result", + }); if (!resultCheck.matched) { return { detail: null, @@ -223,17 +227,28 @@ export class DbChecker implements CheckerDefinition { resolve(target: RawTargetConfig, context: ResolveContext): ResolvedDbTarget { const t = target as RawTargetConfig & { db: DbTargetConfig; type: "db" }; + const rawExpect = target.expect as RawDbExpectConfig | undefined; + const resolvedExpect: ResolvedDbExpectConfig | undefined = rawExpect + ? { + durationMs: resolveValueExpectation(rawExpect.durationMs), + result: resolveContentExpectations(rawExpect.result), + rowCount: resolveValueExpectation(rawExpect.rowCount), + rows: rawExpect.rows?.map((r) => resolveKeyedExpectations(r)!), + } + : undefined; + return { db: { query: t.db.query, url: t.db.url, }, description: null, - expect: target.expect as DbExpectConfig | undefined, + expect: resolvedExpect, group: target.group ?? "default", id: t.id, intervalMs: context.defaultIntervalMs, name: t.name ?? null, + rawExpect, timeoutMs: context.defaultTimeoutMs, type: "db", } satisfies ResolvedDbTarget; diff --git a/src/server/checker/runner/db/expect.ts b/src/server/checker/runner/db/expect.ts index 68af1d9..9548c1c 100644 --- a/src/server/checker/runner/db/expect.ts +++ b/src/server/checker/runner/db/expect.ts @@ -1,20 +1,20 @@ import { isPlainObject } from "es-toolkit"; -import type { ExpectResult, KeyValueExpect, ValueMatcherInput } from "../../expect/types"; +import type { ExpectationResult, KeyedExpectations, ValueExpectation } from "../../expect/types"; import { mismatchFailure } from "../../expect/failure"; -import { checkKeyValueExpect } from "../../expect/key-value"; -import { checkValueMatcher } from "../../expect/matcher"; +import { checkKeyedExpectations } from "../../expect/keyed"; +import { checkValueExpectation } from "../../expect/value"; -export function checkRowCount(actual: number, matcher: ValueMatcherInput): ExpectResult { - return checkValueMatcher(actual, matcher, { +export function checkRowCount(actual: number, matcher: ValueExpectation): ExpectationResult { + return checkValueExpectation(actual, matcher, { message: `rowCount ${actual} 不满足条件`, path: "rowCount", phase: "rowCount", }); } -export function checkRows(rows: unknown, rules: KeyValueExpect[]): ExpectResult { +export function checkRows(rows: unknown, rules: KeyedExpectations[]): ExpectationResult { if (!Array.isArray(rows)) { return { failure: mismatchFailure("row", "rows", rules, rows, "查询结果不是数组"), @@ -39,7 +39,7 @@ export function checkRows(rows: unknown, rules: KeyValueExpect[]): ExpectResult }; } - const result = checkKeyValueExpect(row, rule, { path: `rows[${i}]`, phase: "row" }); + const result = checkKeyedExpectations(row, rule, { path: `rows[${i}]`, phase: "row" }); if (!result.matched) return result; } diff --git a/src/server/checker/runner/db/schema.ts b/src/server/checker/runner/db/schema.ts index 14b55f3..750fdbd 100644 --- a/src/server/checker/runner/db/schema.ts +++ b/src/server/checker/runner/db/schema.ts @@ -2,7 +2,11 @@ import { Type } from "@sinclair/typebox"; import type { CheckerSchemas } from "../types"; -import { createContentRulesSchema, createKeyValueExpectSchema, createValueMatcherSchema } from "../../schema/fragments"; +import { + createRawContentExpectationsSchema, + createRawKeyedExpectationsSchema, + createRawValueExpectationSchema, +} from "../../schema/fragments"; export const dbCheckerSchemas: CheckerSchemas = { config: Type.Object( @@ -19,10 +23,10 @@ export const dbCheckerSchemas: CheckerSchemas = { defaults: Type.Object({}, { additionalProperties: false }), expect: Type.Object( { - durationMs: Type.Optional(createValueMatcherSchema()), - result: Type.Optional(createContentRulesSchema()), - rowCount: Type.Optional(createValueMatcherSchema()), - rows: Type.Optional(Type.Array(createKeyValueExpectSchema())), + durationMs: Type.Optional(createRawValueExpectationSchema()), + result: Type.Optional(createRawContentExpectationsSchema()), + rowCount: Type.Optional(createRawValueExpectationSchema()), + rows: Type.Optional(Type.Array(createRawKeyedExpectationsSchema())), }, { additionalProperties: false }, ), diff --git a/src/server/checker/runner/db/types.ts b/src/server/checker/runner/db/types.ts index e8b17d2..43aa88e 100644 --- a/src/server/checker/runner/db/types.ts +++ b/src/server/checker/runner/db/types.ts @@ -1,29 +1,44 @@ -import type { ContentRules, KeyValueExpect, ValueMatcherInput } from "../../expect/types"; +import type { + ContentExpectations, + KeyedExpectations, + RawContentExpectations, + RawKeyedExpectations, + RawValueExpectation, + ValueExpectation, +} from "../../expect/types"; import type { ResolvedTargetBase } from "../../types"; -export interface DbExpectConfig { - durationMs?: ValueMatcherInput; - result?: ContentRules; - rowCount?: ValueMatcherInput; - rows?: KeyValueExpect[]; -} - export interface DbTargetConfig { query?: string; url: string; } +export interface RawDbExpectConfig { + durationMs?: RawValueExpectation; + result?: RawContentExpectations; + rowCount?: RawValueExpectation; + rows?: RawKeyedExpectations[]; +} + export interface ResolvedDbConfig { query?: string; url: string; } +export interface ResolvedDbExpectConfig { + durationMs?: ValueExpectation; + result?: ContentExpectations; + rowCount?: ValueExpectation; + rows?: KeyedExpectations[]; +} + export interface ResolvedDbTarget extends ResolvedTargetBase { db: ResolvedDbConfig; - expect?: DbExpectConfig; + expect?: ResolvedDbExpectConfig; group: string; intervalMs: number; name: null | string; + rawExpect?: RawDbExpectConfig; timeoutMs: number; type: "db"; } diff --git a/src/server/checker/runner/db/validate.ts b/src/server/checker/runner/db/validate.ts index 59b6912..1cdfadc 100644 --- a/src/server/checker/runner/db/validate.ts +++ b/src/server/checker/runner/db/validate.ts @@ -1,10 +1,14 @@ -import { isPlainObject, isString } from "es-toolkit"; +import { isString } from "es-toolkit"; import type { ConfigValidationIssue } from "../../schema/issues"; import type { CheckerValidationInput } from "../types"; -import { normalizeExpectMatchers } from "../../expect/normalize"; -import { validateContentRules, validateKeyValueExpect, validateValueMatcher } from "../../expect/validate-matcher"; +import { + isPlainRecord, + validateRawContentExpectations, + validateRawKeyedExpectations, + validateRawValueExpectation, +} from "../../expect/validate"; import { issue, joinPath } from "../../schema/issues"; export function validateDbConfig(input: CheckerValidationInput): ConfigValidationIssue[] { @@ -12,7 +16,7 @@ export function validateDbConfig(input: CheckerValidationInput): ConfigValidatio for (let i = 0; i < input.targets.length; i++) { const target = input.targets[i] as unknown; - if (!isPlainObject(target)) continue; + if (!isPlainRecord(target)) continue; if (target["type"] !== "db") continue; issues.push(...validateDbTarget(target, `targets[${i}]`)); } @@ -24,11 +28,11 @@ function collectRowExpects(rows: unknown[], path: string, targetName?: string): const issues: ConfigValidationIssue[] = []; for (let i = 0; i < rows.length; i++) { const row = rows[i]!; - if (!isPlainObject(row)) { + if (!isPlainRecord(row)) { issues.push(issue("invalid-type", `${path}[${i}]`, "必须为对象", targetName)); continue; } - issues.push(...validateKeyValueExpect(row, `${path}[${i}]`, targetName)); + issues.push(...validateRawKeyedExpectations(row, `${path}[${i}]`, targetName)); } return issues; } @@ -41,18 +45,16 @@ function getTargetName(target: Record): string | undefined { function validateDbExpect(target: Record, path: string): ConfigValidationIssue[] { const targetName = getTargetName(target); const expect = target["expect"]; - if (expect === undefined || expect === null || !isPlainObject(expect)) return []; + if (expect === undefined || expect === null || !isPlainRecord(expect)) return []; const issues: ConfigValidationIssue[] = []; const expectPath = joinPath(path, "expect"); - normalizeExpectMatchers(expect, ["durationMs", "rowCount"]); - if (expect["durationMs"] !== undefined) { - issues.push(...validateValueMatcher(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName)); + issues.push(...validateRawValueExpectation(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName)); } if (expect["rowCount"] !== undefined) { - issues.push(...validateValueMatcher(expect["rowCount"], joinPath(expectPath, "rowCount"), targetName)); + issues.push(...validateRawValueExpectation(expect["rowCount"], joinPath(expectPath, "rowCount"), targetName)); } if (expect["rows"] !== undefined) { @@ -64,10 +66,9 @@ function validateDbExpect(target: Record, path: string): Config } if (expect["result"] !== undefined) { - issues.push(...validateContentRules(expect["result"], joinPath(expectPath, "result"), targetName)); + issues.push(...validateRawContentExpectations(expect["result"], joinPath(expectPath, "result"), targetName)); } - // 检查未知字段 const allowedKeys = new Set(["durationMs", "result", "rowCount", "rows"]); for (const key of Object.keys(expect)) { if (!allowedKeys.has(key)) { @@ -83,18 +84,16 @@ function validateDbTarget(target: Record, path: string): Config const targetName = getTargetName(target); const db = target["db"]; - if (!isPlainObject(db)) { + if (!isPlainRecord(db)) { issues.push(issue("required", joinPath(path, "db"), "缺少 db.url 字段", targetName)); issues.push(...validateDbExpect(target, path)); return issues; } - // url 必填 if (!isString(db["url"]) || db["url"].trim() === "") { issues.push(issue("required", joinPath(joinPath(path, "db"), "url"), "缺少 db.url 字段", targetName)); } - // query 可选但不能为空字符串 if (db["query"] !== undefined) { if (!isString(db["query"])) { issues.push(issue("invalid-type", joinPath(joinPath(path, "db"), "query"), "必须为字符串", targetName)); @@ -110,7 +109,6 @@ function validateDbTarget(target: Record, path: string): Config } } - // 检查未知字段 const allowedDbKeys = new Set(["query", "url"]); for (const key of Object.keys(db)) { if (!allowedDbKeys.has(key)) { diff --git a/src/server/checker/runner/http/execute.ts b/src/server/checker/runner/http/execute.ts index 5565d9b..c9ad06e 100644 --- a/src/server/checker/runner/http/execute.ts +++ b/src/server/checker/runner/http/execute.ts @@ -2,18 +2,19 @@ import { isError } from "es-toolkit"; import type { CheckResult, RawTargetConfig } from "../../types"; import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types"; -import type { HttpExpectConfig, HttpTargetConfig, ResolvedHttpTarget } from "./types"; +import type { HttpTargetConfig, RawHttpExpectConfig, ResolvedHttpExpectConfig, ResolvedHttpTarget } from "./types"; -import { checkContentRules } from "../../expect/content"; +import { checkContentExpectations, resolveContentExpectations } from "../../expect/content"; import { errorFailure, mismatchFailure } from "../../expect/failure"; -import { checkValueMatcher, isValueMatcherObject } from "../../expect/matcher"; +import { checkHeaderExpectations } from "../../expect/headers"; +import { resolveKeyedExpectations } from "../../expect/keyed"; +import { checkStatusCode } from "../../expect/status"; +import { checkValueExpectation, displayValueExpectation, resolveValueExpectation } from "../../expect/value"; import { parseSize } from "../../utils"; -import { checkHeaders, checkStatus } from "./expect"; import { httpCheckerSchemas } from "./schema"; import { validateHttpConfig } from "./validate"; const CHARSET_RE = /charset="?([^";\s]+)"?/i; -const BODY_PREVIEW_BYTES = 1024; const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); const SENSITIVE_HEADERS = new Set(["authorization", "cookie"]); @@ -46,27 +47,11 @@ export class HttpChecker implements CheckerDefinition { const statusCode = response.status; const responseHeaders = truncateHeaders(Object.fromEntries(response.headers)); - const hasBodyRules = !!(expect?.body && expect.body.length > 0); - const bodyReadResult = await readBodyStream( - response, - hasBodyRules ? t.http.maxBodyBytes : BODY_PREVIEW_BYTES, - !hasBodyRules, - ); let bodyPreview: null | string = null; let bodyText: null | string = null; - let bodyDecodeFailure: CheckResult["failure"] = null; + const hasBodyExpectations = !!(expect?.body && expect.body.length > 0); - if (bodyReadResult.data.byteLength > 0) { - const decodeResult = decodeBody(bodyReadResult.data, response.headers); - if (decodeResult.ok) { - bodyText = decodeResult.text; - bodyPreview = truncateBodyPreview(decodeResult.text); - } else { - bodyDecodeFailure = decodeResult.failure; - } - } - - const statusResult = checkStatus(statusCode, expect?.status ?? [200]); + const statusResult = checkStatusCode(statusCode, expect?.status ?? [200]); if (!statusResult.matched) { return makeResult( t, @@ -79,7 +64,7 @@ export class HttpChecker implements CheckerDefinition { ); } - const headersResult = checkHeaders(Object.fromEntries(response.headers), expect?.headers); + const headersResult = checkHeaderExpectations(Object.fromEntries(response.headers), expect?.headers); if (!headersResult.matched) { return makeResult( t, @@ -92,7 +77,7 @@ export class HttpChecker implements CheckerDefinition { ); } - const earlyTimeout = hasBodyRules ? checkEarlyTimeout(start, expect?.durationMs) : null; + const earlyTimeout = hasBodyExpectations ? checkEarlyTimeout(start, expect?.durationMs) : null; if (earlyTimeout) { return makeResult( t, @@ -105,32 +90,45 @@ export class HttpChecker implements CheckerDefinition { ); } - if (!bodyReadResult.ok) { - return makeResult( - t, - timestamp, - performance.now() - start, - bodyReadResult.failure, - response, - responseHeaders, - bodyPreview, - ); - } + if (hasBodyExpectations) { + const bodyReadResult = await readBodyStream(response, t.http.maxBodyBytes); + let bodyDecodeFailure: CheckResult["failure"] = null; - if (bodyDecodeFailure) { - return makeResult( - t, - timestamp, - performance.now() - start, - bodyDecodeFailure, - response, - responseHeaders, - bodyPreview, - ); - } + if (bodyReadResult.data.byteLength > 0) { + const decodeResult = decodeBody(bodyReadResult.data, response.headers); + if (decodeResult.ok) { + bodyText = decodeResult.text; + bodyPreview = truncateBodyPreview(decodeResult.text); + } else { + bodyDecodeFailure = decodeResult.failure; + } + } - if (hasBodyRules) { - const bodyResult = checkContentRules(bodyText ?? "", expect.body, { path: "body", phase: "body" }); + if (!bodyReadResult.ok) { + return makeResult( + t, + timestamp, + performance.now() - start, + bodyReadResult.failure, + response, + responseHeaders, + bodyPreview, + ); + } + + if (bodyDecodeFailure) { + return makeResult( + t, + timestamp, + performance.now() - start, + bodyDecodeFailure, + response, + responseHeaders, + bodyPreview, + ); + } + + const bodyResult = checkContentExpectations(bodyText ?? "", expect.body, { path: "body", phase: "body" }); if (!bodyResult.matched) { return makeResult( t, @@ -145,7 +143,7 @@ export class HttpChecker implements CheckerDefinition { } const durationMs = Math.round(performance.now() - start); - const durationResult = checkValueMatcher(durationMs, expect?.durationMs, { + const durationResult = checkValueExpectation(durationMs, expect?.durationMs, { message: "durationMs mismatch", path: "durationMs", phase: "duration", @@ -184,9 +182,19 @@ export class HttpChecker implements CheckerDefinition { const method = t.http.method ?? "GET"; const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB"); + const rawExpect = target.expect as RawHttpExpectConfig | undefined; + const resolvedExpect: ResolvedHttpExpectConfig = rawExpect + ? { + body: resolveContentExpectations(rawExpect.body), + durationMs: resolveValueExpectation(rawExpect.durationMs), + headers: resolveKeyedExpectations(rawExpect.headers), + status: rawExpect.status ?? [200], + } + : { status: [200] }; + return { description: null, - expect: target.expect as HttpExpectConfig | undefined, + expect: resolvedExpect, group: target.group ?? "default", http: { body: t.http.body, @@ -200,6 +208,7 @@ export class HttpChecker implements CheckerDefinition { id: t.id, intervalMs: context.defaultIntervalMs, name: t.name ?? null, + rawExpect, timeoutMs: context.defaultTimeoutMs, type: "http", } satisfies ResolvedHttpTarget; @@ -277,20 +286,16 @@ function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: strin function checkEarlyTimeout( start: number, - durationMatcher: HttpExpectConfig["durationMs"] | undefined, + durationMatcher: ResolvedHttpExpectConfig["durationMs"] | undefined, ): null | { elapsed: number; failure: CheckResult["failure"] } { - if (!isValueMatcherObject(durationMatcher)) return null; - const limit = Math.min( - durationMatcher.lte ?? Number.POSITIVE_INFINITY, - durationMatcher.lt ?? Number.POSITIVE_INFINITY, - ); - if (!Number.isFinite(limit)) return null; - + if (!durationMatcher) return null; const elapsed = performance.now() - start; - if (durationMatcher.lt !== undefined ? elapsed < limit : elapsed <= limit) return null; + const lteFailed = durationMatcher.lte !== undefined && elapsed > durationMatcher.lte; + const ltFailed = durationMatcher.lt !== undefined && elapsed >= durationMatcher.lt; + if (!lteFailed && !ltFailed) return null; const durationMs = Math.round(elapsed); - const durationResult = checkValueMatcher(durationMs, durationMatcher, { + const durationResult = checkValueExpectation(durationMs, durationMatcher, { message: "durationMs mismatch", path: "durationMs", phase: "duration", @@ -299,7 +304,13 @@ function checkEarlyTimeout( elapsed, failure: durationResult.failure ?? - mismatchFailure("duration", "durationMs", durationMatcher, durationMs, "durationMs mismatch"), + mismatchFailure( + "duration", + "durationMs", + displayValueExpectation(durationMatcher), + durationMs, + "durationMs mismatch", + ), }; } diff --git a/src/server/checker/runner/http/expect.ts b/src/server/checker/runner/http/expect.ts deleted file mode 100644 index a11ac88..0000000 --- a/src/server/checker/runner/http/expect.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { isNumber } from "es-toolkit"; - -import type { ExpectResult, KeyValueExpect } from "../../expect/types"; - -import { mismatchFailure } from "../../expect/failure"; -import { checkKeyValueExpect } from "../../expect/key-value"; - -export function checkHeaders(headers: Record, headerExpects?: KeyValueExpect): ExpectResult { - return checkKeyValueExpect(headers, headerExpects, { - normalizeKey: (key) => key.toLowerCase(), - path: "headers", - phase: "headers", - }); -} - -export function checkStatus(statusCode: number, allowed: Array): ExpectResult { - const matched = allowed.some((pattern) => { - if (isNumber(pattern)) return statusCode === pattern; - const base = parseInt(pattern[0]!, 10) * 100; - return statusCode >= base && statusCode < base + 100; - }); - - if (!matched) { - return { - failure: mismatchFailure( - "status", - "status", - allowed, - statusCode, - `status ${statusCode} not in [${allowed.join(", ")}]`, - ), - matched: false, - }; - } - return { failure: null, matched: true }; -} diff --git a/src/server/checker/runner/http/schema.ts b/src/server/checker/runner/http/schema.ts index 47b3bbd..5c9910d 100644 --- a/src/server/checker/runner/http/schema.ts +++ b/src/server/checker/runner/http/schema.ts @@ -3,9 +3,9 @@ import { Type } from "@sinclair/typebox"; import type { CheckerSchemas } from "../types"; import { - createContentRulesSchema, - createKeyValueExpectSchema, - createValueMatcherSchema, + createRawContentExpectationsSchema, + createRawKeyedExpectationsSchema, + createRawValueExpectationSchema, httpMethodSchema, sizeSchema, statusCodePatternSchema, @@ -34,9 +34,9 @@ export const httpCheckerSchemas: CheckerSchemas = { ), expect: Type.Object( { - body: Type.Optional(createContentRulesSchema()), - durationMs: Type.Optional(createValueMatcherSchema()), - headers: Type.Optional(createKeyValueExpectSchema()), + body: Type.Optional(createRawContentExpectationsSchema()), + durationMs: Type.Optional(createRawValueExpectationSchema()), + headers: Type.Optional(createRawKeyedExpectationsSchema()), status: Type.Optional(Type.Array(statusCodePatternSchema)), }, { additionalProperties: false }, diff --git a/src/server/checker/runner/http/types.ts b/src/server/checker/runner/http/types.ts index c23a692..a51a973 100644 --- a/src/server/checker/runner/http/types.ts +++ b/src/server/checker/runner/http/types.ts @@ -1,4 +1,11 @@ -import type { ContentRules, KeyValueExpect, ValueMatcherInput } from "../../expect/types"; +import type { + ContentExpectations, + KeyedExpectations, + RawContentExpectations, + RawKeyedExpectations, + RawValueExpectation, + ValueExpectation, +} from "../../expect/types"; import type { ResolvedTargetBase } from "../../types"; export interface HttpDefaultsConfig { @@ -7,13 +14,6 @@ export interface HttpDefaultsConfig { method?: string; } -export interface HttpExpectConfig { - body?: ContentRules; - durationMs?: ValueMatcherInput; - headers?: KeyValueExpect; - status?: Array; -} - export interface HttpTargetConfig { body?: string; headers?: Record; @@ -24,6 +24,13 @@ export interface HttpTargetConfig { url: string; } +export interface RawHttpExpectConfig { + body?: RawContentExpectations; + durationMs?: RawValueExpectation; + headers?: RawKeyedExpectations; + status?: Array; +} + export interface ResolvedHttpConfig { body?: string; headers: Record; @@ -34,12 +41,20 @@ export interface ResolvedHttpConfig { url: string; } +export interface ResolvedHttpExpectConfig { + body?: ContentExpectations; + durationMs?: ValueExpectation; + headers?: KeyedExpectations; + status: Array; +} + export interface ResolvedHttpTarget extends ResolvedTargetBase { - expect?: HttpExpectConfig; + expect?: ResolvedHttpExpectConfig; group: string; 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 9005ef9..dd80c9d 100644 --- a/src/server/checker/runner/http/validate.ts +++ b/src/server/checker/runner/http/validate.ts @@ -3,13 +3,12 @@ import { isNumber, isString } from "es-toolkit"; import type { ConfigValidationIssue } from "../../schema/issues"; import type { CheckerValidationInput } from "../types"; -import { normalizeExpectMatchers } from "../../expect/normalize"; import { isPlainRecord, - validateContentRules, - validateKeyValueExpect, - validateValueMatcher, -} from "../../expect/validate-matcher"; + validateRawContentExpectations, + validateRawKeyedExpectations, + validateRawValueExpectation, +} from "../../expect/validate"; import { issue, joinPath } from "../../schema/issues"; import { parseSize } from "../../utils"; @@ -34,24 +33,6 @@ export function validateHttpConfig(input: CheckerValidationInput): ConfigValidat return issues; } -export function validateJsonPath(path: string, rulePath: string, targetName?: string): ConfigValidationIssue[] { - if (!path.startsWith("$.") || path.length <= 2) { - return [issue("invalid-jsonpath", joinPath(rulePath, "path"), '必须为以 "$." 开头的有效 JSONPath', targetName)]; - } - const issues: ConfigValidationIssue[] = []; - const segments = path.slice(2).split("."); - for (const seg of segments) { - if (seg === "") { - issues.push(issue("invalid-jsonpath", joinPath(rulePath, "path"), "包含空段", targetName)); - } - const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg); - if (bracketMatch?.[1]!.trim() === "") { - issues.push(issue("invalid-jsonpath", joinPath(rulePath, "path"), "数组访问缺少属性名", targetName)); - } - } - return issues; -} - function getTargetName(target: Record): string | undefined { if (isString(target["name"])) return target["name"]; return isString(target["id"]) ? target["id"] : undefined; @@ -68,14 +49,16 @@ function validateHttpExpect(target: Record, path: string): Conf const issues: ConfigValidationIssue[] = []; const expectPath = joinPath(path, "expect"); - normalizeExpectMatchers(expect, ["durationMs"]); - if (isPlainRecord(expect["headers"])) { - issues.push(...validateKeyValueExpect(expect["headers"], joinPath(expectPath, "headers"), targetName)); + issues.push( + ...validateRawKeyedExpectations(expect["headers"], joinPath(expectPath, "headers"), targetName, { + caseInsensitive: true, + }), + ); } if (expect["body"] !== undefined) { - issues.push(...validateContentRules(expect["body"], joinPath(expectPath, "body"), targetName)); + issues.push(...validateRawContentExpectations(expect["body"], joinPath(expectPath, "body"), targetName)); } if (Array.isArray(expect["status"])) { @@ -83,7 +66,7 @@ function validateHttpExpect(target: Record, path: string): Conf } if (expect["durationMs"] !== undefined) { - issues.push(...validateValueMatcher(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName)); + issues.push(...validateRawValueExpectation(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName)); } return issues; diff --git a/src/server/checker/runner/icmp/execute.ts b/src/server/checker/runner/icmp/execute.ts index c07e6e2..94cfa91 100644 --- a/src/server/checker/runner/icmp/execute.ts +++ b/src/server/checker/runner/icmp/execute.ts @@ -2,10 +2,16 @@ import { isError } from "es-toolkit"; import type { CheckResult, RawTargetConfig } from "../../types"; import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types"; -import type { PingExpectConfig, PingStats, PingTargetConfig, ResolvedPingTarget } from "./types"; +import type { + PingStats, + PingTargetConfig, + RawIcmpExpectConfig, + ResolvedIcmpExpectConfig, + ResolvedPingTarget, +} from "./types"; import { errorFailure } from "../../expect/failure"; -import { checkValueMatcher } from "../../expect/matcher"; +import { checkValueExpectation, resolveValueExpectation } from "../../expect/value"; import { buildPingCommand } from "./command"; import { checkAlive, checkAvgLatency, checkMaxLatency, checkPacketLoss } from "./expect"; import { parsePingOutput } from "./parse"; @@ -155,9 +161,21 @@ export class IcmpChecker implements CheckerDefinition { resolve(target: RawTargetConfig, context: ResolveContext): ResolvedPingTarget { const t = target as RawTargetConfig & { icmp: PingTargetConfig; type: "icmp" }; + + const rawExpect = target.expect as RawIcmpExpectConfig | undefined; + const resolvedExpect: ResolvedIcmpExpectConfig = rawExpect + ? { + alive: rawExpect.alive ?? true, + avgLatencyMs: resolveValueExpectation(rawExpect.avgLatencyMs), + durationMs: resolveValueExpectation(rawExpect.durationMs), + maxLatencyMs: resolveValueExpectation(rawExpect.maxLatencyMs), + packetLossPercent: resolveValueExpectation(rawExpect.packetLossPercent), + } + : { alive: true }; + return { description: null, - expect: target.expect as PingExpectConfig | undefined, + expect: resolvedExpect, group: target.group ?? "default", icmp: { count: t.icmp.count ?? DEFAULT_COUNT, @@ -167,6 +185,7 @@ export class IcmpChecker implements CheckerDefinition { id: t.id, intervalMs: context.defaultIntervalMs, name: t.name ?? null, + rawExpect, timeoutMs: context.defaultTimeoutMs, type: "icmp", } satisfies ResolvedPingTarget; @@ -184,7 +203,7 @@ export class IcmpChecker implements CheckerDefinition { } } -function checkStats(stats: PingStats, expect: PingExpectConfig | undefined, durationMs: number) { +function checkStats(stats: PingStats, expect: ResolvedIcmpExpectConfig | undefined, durationMs: number) { const aliveResult = checkAlive(stats.alive, expect?.alive ?? true); if (!aliveResult.matched) return aliveResult; const packetLossResult = checkPacketLoss(stats.packetLoss, expect?.packetLossPercent); @@ -193,7 +212,7 @@ function checkStats(stats: PingStats, expect: PingExpectConfig | undefined, dura if (!avgLatencyResult.matched) return avgLatencyResult; const maxLatencyResult = checkMaxLatency(stats.maxLatencyMs, expect?.maxLatencyMs); if (!maxLatencyResult.matched) return maxLatencyResult; - return checkValueMatcher(durationMs, expect?.durationMs, { + return checkValueExpectation(durationMs, expect?.durationMs, { message: "durationMs mismatch", path: "durationMs", phase: "duration", diff --git a/src/server/checker/runner/icmp/expect.ts b/src/server/checker/runner/icmp/expect.ts index 1f83a40..27bba09 100644 --- a/src/server/checker/runner/icmp/expect.ts +++ b/src/server/checker/runner/icmp/expect.ts @@ -1,9 +1,9 @@ -import type { ExpectResult, ValueMatcherInput } from "../../expect/types"; +import type { ExpectationResult, ValueExpectation } from "../../expect/types"; import { mismatchFailure } from "../../expect/failure"; -import { checkValueMatcher } from "../../expect/matcher"; +import { checkValueExpectation } from "../../expect/value"; -export function checkAlive(actual: boolean, expected: boolean): ExpectResult { +export function checkAlive(actual: boolean, expected: boolean): ExpectationResult { if (actual === expected) return { failure: null, matched: true }; return { failure: mismatchFailure( @@ -17,24 +17,24 @@ export function checkAlive(actual: boolean, expected: boolean): ExpectResult { }; } -export function checkAvgLatency(actual: null | number, matcher: undefined | ValueMatcherInput): ExpectResult { - return checkValueMatcher(actual, matcher, { +export function checkAvgLatency(actual: null | number, matcher: undefined | ValueExpectation): ExpectationResult { + return checkValueExpectation(actual, matcher, { message: "平均延迟不满足条件", path: "avgLatencyMs", phase: "avgLatency", }); } -export function checkMaxLatency(actual: null | number, matcher: undefined | ValueMatcherInput): ExpectResult { - return checkValueMatcher(actual, matcher, { +export function checkMaxLatency(actual: null | number, matcher: undefined | ValueExpectation): ExpectationResult { + return checkValueExpectation(actual, matcher, { message: "最大延迟不满足条件", path: "maxLatencyMs", phase: "maxLatency", }); } -export function checkPacketLoss(actual: number, matcher: undefined | ValueMatcherInput): ExpectResult { - return checkValueMatcher(actual, matcher, { +export function checkPacketLoss(actual: number, matcher: undefined | ValueExpectation): ExpectationResult { + return checkValueExpectation(actual, matcher, { message: "丢包率不满足条件", path: "packetLossPercent", phase: "packetLoss", diff --git a/src/server/checker/runner/icmp/schema.ts b/src/server/checker/runner/icmp/schema.ts index 7b8dfa7..7ad2d55 100644 --- a/src/server/checker/runner/icmp/schema.ts +++ b/src/server/checker/runner/icmp/schema.ts @@ -2,7 +2,7 @@ import { Type } from "@sinclair/typebox"; import type { CheckerSchemas } from "../types"; -import { createValueMatcherSchema } from "../../schema/fragments"; +import { createRawValueExpectationSchema } from "../../schema/fragments"; export const icmpCheckerSchemas: CheckerSchemas = { config: Type.Object( @@ -17,10 +17,10 @@ export const icmpCheckerSchemas: CheckerSchemas = { expect: Type.Object( { alive: Type.Optional(Type.Boolean()), - avgLatencyMs: Type.Optional(createValueMatcherSchema()), - durationMs: Type.Optional(createValueMatcherSchema()), - maxLatencyMs: Type.Optional(createValueMatcherSchema()), - packetLossPercent: Type.Optional(createValueMatcherSchema()), + avgLatencyMs: Type.Optional(createRawValueExpectationSchema()), + durationMs: Type.Optional(createRawValueExpectationSchema()), + maxLatencyMs: Type.Optional(createRawValueExpectationSchema()), + packetLossPercent: Type.Optional(createRawValueExpectationSchema()), }, { additionalProperties: false }, ), diff --git a/src/server/checker/runner/icmp/types.ts b/src/server/checker/runner/icmp/types.ts index 7e634a4..489f54c 100644 --- a/src/server/checker/runner/icmp/types.ts +++ b/src/server/checker/runner/icmp/types.ts @@ -1,14 +1,6 @@ -import type { ValueMatcherInput } from "../../expect/types"; +import type { RawValueExpectation, ValueExpectation } from "../../expect/types"; import type { ResolvedTargetBase } from "../../types"; -export interface PingExpectConfig { - alive?: boolean; - avgLatencyMs?: ValueMatcherInput; - durationMs?: ValueMatcherInput; - maxLatencyMs?: ValueMatcherInput; - packetLossPercent?: ValueMatcherInput; -} - export interface PingStats { alive: boolean; avgLatencyMs: null | number; @@ -25,6 +17,22 @@ export interface PingTargetConfig { packetSize?: number; } +export interface RawIcmpExpectConfig { + alive?: boolean; + avgLatencyMs?: RawValueExpectation; + durationMs?: RawValueExpectation; + maxLatencyMs?: RawValueExpectation; + packetLossPercent?: RawValueExpectation; +} + +export interface ResolvedIcmpExpectConfig { + alive: boolean; + avgLatencyMs?: ValueExpectation; + durationMs?: ValueExpectation; + maxLatencyMs?: ValueExpectation; + packetLossPercent?: ValueExpectation; +} + export interface ResolvedPingConfig { count: number; host: string; @@ -32,11 +40,12 @@ export interface ResolvedPingConfig { } export interface ResolvedPingTarget extends ResolvedTargetBase { - expect?: PingExpectConfig; + expect?: ResolvedIcmpExpectConfig; group: string; icmp: ResolvedPingConfig; intervalMs: number; name: null | string; + rawExpect?: RawIcmpExpectConfig; timeoutMs: number; type: "icmp"; } diff --git a/src/server/checker/runner/icmp/validate.ts b/src/server/checker/runner/icmp/validate.ts index b249231..b7c317b 100644 --- a/src/server/checker/runner/icmp/validate.ts +++ b/src/server/checker/runner/icmp/validate.ts @@ -1,10 +1,9 @@ -import { isNumber, isPlainObject, isString } from "es-toolkit"; +import { isNumber, isString } from "es-toolkit"; import type { ConfigValidationIssue } from "../../schema/issues"; import type { CheckerValidationInput } from "../types"; -import { normalizeExpectMatchers } from "../../expect/normalize"; -import { validateValueMatcher } from "../../expect/validate-matcher"; +import { isPlainRecord, validateRawValueExpectation } from "../../expect/validate"; import { issue, joinPath } from "../../schema/issues"; export function validatePingConfig(input: CheckerValidationInput): ConfigValidationIssue[] { @@ -13,10 +12,10 @@ export function validatePingConfig(input: CheckerValidationInput): ConfigValidat const defaults = input.defaults["icmp"]; if (defaults !== undefined && defaults !== null) { const targetName = "defaults.icmp"; - if (!isPlainObject(defaults)) { + if (!isPlainRecord(defaults)) { issues.push(issue("invalid-type", "defaults.icmp", "必须为对象", targetName)); } else { - const icmpDefaults = defaults as Record; + const icmpDefaults = defaults; for (const key of Object.keys(icmpDefaults)) { issues.push(issue("unknown-field", joinPath("defaults.icmp", key), "是未知字段", targetName)); } @@ -25,8 +24,8 @@ export function validatePingConfig(input: CheckerValidationInput): ConfigValidat for (let i = 0; i < input.targets.length; i++) { const target = input.targets[i] as unknown; - if (!isPlainObject(target)) continue; - const targetRecord = target as Record; + if (!isPlainRecord(target)) continue; + const targetRecord = target; if (targetRecord["type"] !== "icmp") continue; issues.push(...validatePingTarget(targetRecord, `targets[${i}]`)); } @@ -41,20 +40,18 @@ function getTargetName(target: Record): string | undefined { function validatePingExpect(target: Record, path: string): ConfigValidationIssue[] { const rawExpect = target["expect"]; - if (rawExpect === undefined || rawExpect === null || !isPlainObject(rawExpect)) return []; - const expect = rawExpect as Record; + if (rawExpect === undefined || rawExpect === null || !isPlainRecord(rawExpect)) return []; + const expect = rawExpect; const issues: ConfigValidationIssue[] = []; const targetName = getTargetName(target); const expectPath = joinPath(path, "expect"); - normalizeExpectMatchers(expect, ["packetLossPercent", "avgLatencyMs", "maxLatencyMs", "durationMs"]); - if (expect["alive"] !== undefined && typeof expect["alive"] !== "boolean") { issues.push(issue("invalid-type", joinPath(expectPath, "alive"), "必须为布尔值", targetName)); } for (const key of ["packetLossPercent", "avgLatencyMs", "maxLatencyMs", "durationMs"]) { if (expect[key] !== undefined) { - issues.push(...validateValueMatcher(expect[key], joinPath(expectPath, key), targetName)); + issues.push(...validateRawValueExpectation(expect[key], joinPath(expectPath, key), targetName)); } } @@ -73,12 +70,12 @@ function validatePingTarget(target: Record, path: string): Conf const targetName = getTargetName(target); const rawIcmp = target["icmp"]; - if (!isPlainObject(rawIcmp)) { + if (!isPlainRecord(rawIcmp)) { issues.push(issue("required", joinPath(path, "icmp"), "缺少 icmp 配置分组", targetName)); issues.push(...validatePingExpect(target, path)); return issues; } - const icmp = rawIcmp as Record; + const icmp = rawIcmp; if (!isString(icmp["host"]) || icmp["host"].trim() === "") { issues.push(issue("required", joinPath(joinPath(path, "icmp"), "host"), "缺少 icmp.host 字段", targetName)); diff --git a/src/server/checker/runner/llm/execute.ts b/src/server/checker/runner/llm/execute.ts index 955022c..3875024 100644 --- a/src/server/checker/runner/llm/execute.ts +++ b/src/server/checker/runner/llm/execute.ts @@ -5,10 +5,12 @@ import { isError } from "es-toolkit"; import type { CheckResult, RawTargetConfig } from "../../types"; import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types"; -import type { LlmExpectConfig, LlmTargetConfig, ResolvedLlmTarget } from "./types"; +import type { LlmTargetConfig, RawLlmExpectConfig, ResolvedLlmExpectConfig, ResolvedLlmTarget } from "./types"; +import { resolveContentExpectations } from "../../expect/content"; import { errorFailure } from "../../expect/failure"; -import { checkValueMatcher } from "../../expect/matcher"; +import { resolveKeyedExpectations } from "../../expect/keyed"; +import { checkValueExpectation, resolveValueExpectation } from "../../expect/value"; import { runExpects } from "./expect"; import { buildObservationFromApiCallError, @@ -93,7 +95,7 @@ export class LlmChecker implements CheckerDefinition { }; } - const durationResult = checkValueMatcher(durationMs, expect?.durationMs, { + const durationResult = checkValueExpectation(durationMs, expect?.durationMs, { message: "durationMs mismatch", path: "durationMs", phase: "duration", @@ -171,14 +173,40 @@ export class LlmChecker implements CheckerDefinition { url: t.llm.url, }; + const rawExpect = target.expect as RawLlmExpectConfig | undefined; + const resolvedExpect: ResolvedLlmExpectConfig = rawExpect + ? { + durationMs: resolveValueExpectation(rawExpect.durationMs), + finishReason: resolveValueExpectation(rawExpect.finishReason), + headers: resolveKeyedExpectations(rawExpect.headers), + output: resolveContentExpectations(rawExpect.output), + rawFinishReason: resolveValueExpectation(rawExpect.rawFinishReason), + status: rawExpect.status ?? [200], + stream: rawExpect.stream + ? { + completed: rawExpect.stream.completed ?? true, + firstTokenMs: resolveValueExpectation(rawExpect.stream.firstTokenMs), + } + : undefined, + usage: rawExpect.usage + ? { + inputTokens: resolveValueExpectation(rawExpect.usage.inputTokens), + outputTokens: resolveValueExpectation(rawExpect.usage.outputTokens), + totalTokens: resolveValueExpectation(rawExpect.usage.totalTokens), + } + : undefined, + } + : { status: [200] }; + return { description: (target.description as null | string) ?? null, - expect: target.expect as LlmExpectConfig | undefined, + expect: resolvedExpect, group: target.group ?? "default", id: t.id, intervalMs: context.defaultIntervalMs, llm: resolvedConfig, name: (target.name as null | string) ?? null, + rawExpect, timeoutMs: context.defaultTimeoutMs, type: "llm", } satisfies ResolvedLlmTarget; @@ -210,7 +238,7 @@ export class LlmChecker implements CheckerDefinition { t: ResolvedLlmTarget, model: ReturnType["model"], httpMeta: null | { headers: Record; status: number; statusText: string }, - expect: LlmExpectConfig | undefined, + expect: ResolvedLlmExpectConfig | undefined, ctx: CheckerContext, timestamp: string, start: number, @@ -254,7 +282,7 @@ export class LlmChecker implements CheckerDefinition { ); const durationMs = Math.round(performance.now() - start); - const durationResult = checkValueMatcher(durationMs, expect?.durationMs, { + const durationResult = checkValueExpectation(durationMs, expect?.durationMs, { message: "durationMs mismatch", path: "durationMs", phase: "duration", @@ -277,7 +305,7 @@ export class LlmChecker implements CheckerDefinition { t: ResolvedLlmTarget, model: ReturnType["model"], httpMeta: null | { headers: Record; status: number; statusText: string }, - expect: LlmExpectConfig | undefined, + expect: ResolvedLlmExpectConfig | undefined, ctx: CheckerContext, timestamp: string, start: number, @@ -301,7 +329,7 @@ export class LlmChecker implements CheckerDefinition { ); const durationMs = Math.round(performance.now() - start); - const durationResult = checkValueMatcher(durationMs, expect?.durationMs, { + const durationResult = checkValueExpectation(durationMs, expect?.durationMs, { message: "durationMs mismatch", path: "durationMs", phase: "duration", diff --git a/src/server/checker/runner/llm/expect.ts b/src/server/checker/runner/llm/expect.ts index 2c50b69..d5c53a3 100644 --- a/src/server/checker/runner/llm/expect.ts +++ b/src/server/checker/runner/llm/expect.ts @@ -1,15 +1,19 @@ -import type { ExpectResult } from "../../expect/types"; -import type { LlmCheckObservation, LlmExpectConfig, LlmUsageExpect } from "./types"; +import type { ExpectationResult } from "../../expect/types"; +import type { LlmCheckObservation, ResolvedLlmExpectConfig, ResolvedLlmUsageExpect } from "./types"; -import { checkContentRules } from "../../expect/content"; +import { checkContentExpectations } from "../../expect/content"; import { mismatchFailure } from "../../expect/failure"; -import { checkValueMatcher } from "../../expect/matcher"; -import { checkHeaders, checkStatus } from "../http/expect"; +import { checkHeaderExpectations } from "../../expect/headers"; +import { checkStatusCode } from "../../expect/status"; +import { checkValueExpectation } from "../../expect/value"; -export function checkStreamExpect(observation: LlmCheckObservation, expect: LlmExpectConfig): ExpectResult { +export function checkStreamExpect( + observation: LlmCheckObservation, + expect: ResolvedLlmExpectConfig, +): ExpectationResult { if (!observation.stream || !expect.stream) return { failure: null, matched: true }; - const expectedCompleted = expect.stream.completed ?? true; + const expectedCompleted = expect.stream.completed; if (observation.stream.completed !== expectedCompleted) { return { failure: mismatchFailure( @@ -24,7 +28,7 @@ export function checkStreamExpect(observation: LlmCheckObservation, expect: LlmE } if (expect.stream.firstTokenMs && observation.stream.firstTokenMs !== null) { - return checkValueMatcher(observation.stream.firstTokenMs, expect.stream.firstTokenMs, { + return checkValueExpectation(observation.stream.firstTokenMs, expect.stream.firstTokenMs, { message: "stream.firstTokenMs mismatch", path: "stream.firstTokenMs", phase: "stream", @@ -45,20 +49,23 @@ export function checkStreamExpect(observation: LlmCheckObservation, expect: LlmE return { failure: null, matched: true }; } -export function runExpects(observation: LlmCheckObservation, expect: LlmExpectConfig | undefined): ExpectResult { +export function runExpects( + observation: LlmCheckObservation, + expect: ResolvedLlmExpectConfig | undefined, +): ExpectationResult { if (!expect) { - const defaultStatus = checkStatus(observation.http?.status ?? 0, [200]); + const defaultStatus = checkStatusCode(observation.http?.status ?? 0, [200]); if (!defaultStatus.matched) return defaultStatus; return { failure: null, matched: true }; } const http = observation.http; - const statusResult = checkStatus(http?.status ?? 0, expect.status ?? [200]); + const statusResult = checkStatusCode(http?.status ?? 0, expect.status); if (!statusResult.matched) return statusResult; if (http && expect.headers) { - const headersResult = checkHeaders(http.headers, expect.headers); + const headersResult = checkHeaderExpectations(http.headers, expect.headers); if (!headersResult.matched) return headersResult; } @@ -67,11 +74,14 @@ export function runExpects(observation: LlmCheckObservation, expect: LlmExpectCo if (!streamResult.matched) return streamResult; } - const outputResult = checkContentRules(observation.outputText, expect.output, { path: "output", phase: "output" }); + const outputResult = checkContentExpectations(observation.outputText, expect.output, { + path: "output", + phase: "output", + }); if (!outputResult.matched) return outputResult; if (expect.finishReason !== undefined) { - const result = checkValueMatcher(observation.finishReason, expect.finishReason, { + const result = checkValueExpectation(observation.finishReason, expect.finishReason, { message: "finishReason mismatch", path: "finishReason", phase: "finishReason", @@ -80,7 +90,7 @@ export function runExpects(observation: LlmCheckObservation, expect: LlmExpectCo } if (expect.rawFinishReason !== undefined) { - const result = checkValueMatcher(observation.rawFinishReason, expect.rawFinishReason, { + const result = checkValueExpectation(observation.rawFinishReason, expect.rawFinishReason, { message: "rawFinishReason mismatch", path: "rawFinishReason", phase: "rawFinishReason", @@ -98,10 +108,10 @@ export function runExpects(observation: LlmCheckObservation, expect: LlmExpectCo function checkUsageExpect( usage: { inputTokens: number; outputTokens: number; totalTokens: number }, - expectUsage: LlmUsageExpect, -): ExpectResult { + expectUsage: ResolvedLlmUsageExpect, +): ExpectationResult { if (expectUsage.inputTokens !== undefined) { - const result = checkValueMatcher(usage.inputTokens, expectUsage.inputTokens, { + const result = checkValueExpectation(usage.inputTokens, expectUsage.inputTokens, { message: "usage.inputTokens mismatch", path: "usage.inputTokens", phase: "usage", @@ -109,7 +119,7 @@ function checkUsageExpect( if (!result.matched) return result; } if (expectUsage.outputTokens !== undefined) { - const result = checkValueMatcher(usage.outputTokens, expectUsage.outputTokens, { + const result = checkValueExpectation(usage.outputTokens, expectUsage.outputTokens, { message: "usage.outputTokens mismatch", path: "usage.outputTokens", phase: "usage", @@ -117,7 +127,7 @@ function checkUsageExpect( if (!result.matched) return result; } if (expectUsage.totalTokens !== undefined) { - const result = checkValueMatcher(usage.totalTokens, expectUsage.totalTokens, { + const result = checkValueExpectation(usage.totalTokens, expectUsage.totalTokens, { message: "usage.totalTokens mismatch", path: "usage.totalTokens", phase: "usage", diff --git a/src/server/checker/runner/llm/schema.ts b/src/server/checker/runner/llm/schema.ts index 13a84a1..f6a758b 100644 --- a/src/server/checker/runner/llm/schema.ts +++ b/src/server/checker/runner/llm/schema.ts @@ -3,9 +3,9 @@ import { Type } from "@sinclair/typebox"; import type { CheckerSchemas } from "../types"; import { - createContentRulesSchema, - createKeyValueExpectSchema, - createValueMatcherSchema, + createRawContentExpectationsSchema, + createRawKeyedExpectationsSchema, + createRawValueExpectationSchema, statusCodePatternSchema, stringMapSchema, } from "../../schema/fragments"; @@ -55,17 +55,17 @@ export const llmCheckerSchemas: CheckerSchemas = { ), expect: Type.Object( { - durationMs: Type.Optional(createValueMatcherSchema()), - finishReason: Type.Optional(createValueMatcherSchema()), - headers: Type.Optional(createKeyValueExpectSchema()), - output: Type.Optional(createContentRulesSchema()), - rawFinishReason: Type.Optional(createValueMatcherSchema()), + durationMs: Type.Optional(createRawValueExpectationSchema()), + finishReason: Type.Optional(createRawValueExpectationSchema()), + headers: Type.Optional(createRawKeyedExpectationsSchema()), + output: Type.Optional(createRawContentExpectationsSchema()), + rawFinishReason: Type.Optional(createRawValueExpectationSchema()), status: Type.Optional(Type.Array(statusCodePatternSchema)), stream: Type.Optional( Type.Object( { completed: Type.Optional(Type.Boolean()), - firstTokenMs: Type.Optional(createValueMatcherSchema()), + firstTokenMs: Type.Optional(createRawValueExpectationSchema()), }, { additionalProperties: false }, ), @@ -73,9 +73,9 @@ export const llmCheckerSchemas: CheckerSchemas = { usage: Type.Optional( Type.Object( { - inputTokens: Type.Optional(createValueMatcherSchema()), - outputTokens: Type.Optional(createValueMatcherSchema()), - totalTokens: Type.Optional(createValueMatcherSchema()), + inputTokens: Type.Optional(createRawValueExpectationSchema()), + outputTokens: Type.Optional(createRawValueExpectationSchema()), + totalTokens: Type.Optional(createRawValueExpectationSchema()), }, { additionalProperties: false }, ), diff --git a/src/server/checker/runner/llm/types.ts b/src/server/checker/runner/llm/types.ts index 2c547cc..4532289 100644 --- a/src/server/checker/runner/llm/types.ts +++ b/src/server/checker/runner/llm/types.ts @@ -1,6 +1,13 @@ import type { JSONObject } from "@ai-sdk/provider"; -import type { ContentRules, KeyValueExpect, ValueMatcherInput } from "../../expect/types"; +import type { + ContentExpectations, + KeyedExpectations, + RawContentExpectations, + RawKeyedExpectations, + RawValueExpectation, + ValueExpectation, +} from "../../expect/types"; import type { ResolvedTargetBase } from "../../types"; export interface LlmCheckObservation { @@ -24,16 +31,6 @@ export interface LlmDefaultsConfig { providerOptions?: Record; } -export interface LlmExpectConfig { - durationMs?: ValueMatcherInput; - finishReason?: ValueMatcherInput; - headers?: KeyValueExpect; - output?: ContentRules; - rawFinishReason?: ValueMatcherInput; - status?: Array; - stream?: LlmStreamExpect; - usage?: LlmUsageExpect; -} export interface LlmHttpMetadata { headers: Record; status: number; @@ -80,11 +77,6 @@ export interface LlmOptions { export type LlmProvider = "anthropic" | "openai" | "openai-responses"; -export interface LlmStreamExpect { - completed?: boolean; - firstTokenMs?: ValueMatcherInput; -} - export interface LlmStreamObservation { completed: boolean; firstTokenMs: null | number; @@ -104,18 +96,34 @@ export interface LlmTargetConfig { url: string; } -export interface LlmUsageExpect { - inputTokens?: ValueMatcherInput; - outputTokens?: ValueMatcherInput; - totalTokens?: ValueMatcherInput; -} - export interface LlmUsageObservation { inputTokens: number; outputTokens: number; totalTokens: number; } +export interface RawLlmExpectConfig { + durationMs?: RawValueExpectation; + finishReason?: RawValueExpectation; + headers?: RawKeyedExpectations; + output?: RawContentExpectations; + rawFinishReason?: RawValueExpectation; + status?: Array; + stream?: RawLlmStreamExpect; + usage?: RawLlmUsageExpect; +} + +export interface RawLlmStreamExpect { + completed?: boolean; + firstTokenMs?: RawValueExpectation; +} + +export interface RawLlmUsageExpect { + inputTokens?: RawValueExpectation; + outputTokens?: RawValueExpectation; + totalTokens?: RawValueExpectation; +} + export interface ResolvedLlmConfig { authToken?: string; headers: Record; @@ -130,12 +138,35 @@ export interface ResolvedLlmConfig { url: string; } +export interface ResolvedLlmExpectConfig { + durationMs?: ValueExpectation; + finishReason?: ValueExpectation; + headers?: KeyedExpectations; + output?: ContentExpectations; + rawFinishReason?: ValueExpectation; + status: Array; + stream?: ResolvedLlmStreamExpect; + usage?: ResolvedLlmUsageExpect; +} + +export interface ResolvedLlmStreamExpect { + completed: boolean; + firstTokenMs?: ValueExpectation; +} + export interface ResolvedLlmTarget extends ResolvedTargetBase { - expect?: LlmExpectConfig; + expect?: ResolvedLlmExpectConfig; group: string; intervalMs: number; llm: ResolvedLlmConfig; name: null | string; + rawExpect?: RawLlmExpectConfig; timeoutMs: number; type: "llm"; } + +export interface ResolvedLlmUsageExpect { + inputTokens?: ValueExpectation; + outputTokens?: ValueExpectation; + totalTokens?: ValueExpectation; +} diff --git a/src/server/checker/runner/llm/validate.ts b/src/server/checker/runner/llm/validate.ts index 0c953de..61a8572 100644 --- a/src/server/checker/runner/llm/validate.ts +++ b/src/server/checker/runner/llm/validate.ts @@ -3,13 +3,12 @@ import { isBoolean, isNumber, isString } from "es-toolkit"; import type { ConfigValidationIssue } from "../../schema/issues"; import type { CheckerValidationInput } from "../types"; -import { normalizeExpectMatchers } from "../../expect/normalize"; import { isPlainRecord, - validateContentRules, - validateKeyValueExpect, - validateValueMatcher, -} from "../../expect/validate-matcher"; + validateRawContentExpectations, + validateRawKeyedExpectations, + validateRawValueExpectation, +} from "../../expect/validate"; import { issue, joinPath } from "../../schema/issues"; const ALLOWED_MODES = new Set(["http", "stream"]); @@ -73,23 +72,27 @@ function validateLlmExpect( const issues: ConfigValidationIssue[] = []; const expectPath = joinPath(path, "expect"); - normalizeExpectMatchers(expect, ["durationMs", "finishReason", "rawFinishReason"]); - if (Array.isArray(expect["status"])) { issues.push(...validateStatusValues(expect["status"], joinPath(expectPath, "status"), targetName)); } if (expect["headers"] !== undefined) { - issues.push(...validateKeyValueExpect(expect["headers"], joinPath(expectPath, "headers"), targetName)); + issues.push( + ...validateRawKeyedExpectations(expect["headers"], joinPath(expectPath, "headers"), targetName, { + caseInsensitive: true, + }), + ); } if (expect["output"] !== undefined) { - issues.push(...validateContentRules(expect["output"], joinPath(expectPath, "output"), targetName)); + issues.push(...validateRawContentExpectations(expect["output"], joinPath(expectPath, "output"), targetName)); } if (expect["finishReason"] !== undefined) { - issues.push(...validateValueMatcher(expect["finishReason"], joinPath(expectPath, "finishReason"), targetName)); + issues.push( + ...validateRawValueExpectation(expect["finishReason"], joinPath(expectPath, "finishReason"), targetName), + ); } if (expect["rawFinishReason"] !== undefined) { issues.push( - ...validateValueMatcher(expect["rawFinishReason"], joinPath(expectPath, "rawFinishReason"), targetName), + ...validateRawValueExpectation(expect["rawFinishReason"], joinPath(expectPath, "rawFinishReason"), targetName), ); } if (expect["usage"] !== undefined) { @@ -105,7 +108,7 @@ function validateLlmExpect( } } if (expect["durationMs"] !== undefined) { - issues.push(...validateValueMatcher(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName)); + issues.push(...validateRawValueExpectation(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName)); } const allowedKeys = new Set([ @@ -289,13 +292,11 @@ function validateStreamExpect(stream: unknown, path: string, targetName?: string if (!isPlainRecord(stream)) return [issue("invalid-type", path, "必须为对象", targetName)]; const issues: ConfigValidationIssue[] = []; - normalizeExpectMatchers(stream, ["firstTokenMs"]); - if (stream["completed"] !== undefined && !isBoolean(stream["completed"])) { issues.push(issue("invalid-type", joinPath(path, "completed"), "必须为布尔值", targetName)); } if (stream["firstTokenMs"] !== undefined) { - issues.push(...validateValueMatcher(stream["firstTokenMs"], joinPath(path, "firstTokenMs"), targetName)); + issues.push(...validateRawValueExpectation(stream["firstTokenMs"], joinPath(path, "firstTokenMs"), targetName)); } const allowedKeys = new Set(["completed", "firstTokenMs"]); @@ -321,11 +322,9 @@ function validateUsageExpect(usage: unknown, path: string, targetName?: string): if (!isPlainRecord(usage)) return [issue("invalid-type", path, "必须为对象", targetName)]; const issues: ConfigValidationIssue[] = []; - normalizeExpectMatchers(usage, ["inputTokens", "outputTokens", "totalTokens"]); - for (const key of ["inputTokens", "outputTokens", "totalTokens"]) { if (usage[key] !== undefined) { - issues.push(...validateValueMatcher(usage[key], joinPath(path, key), targetName)); + issues.push(...validateRawValueExpectation(usage[key], joinPath(path, key), targetName)); } } diff --git a/src/server/checker/runner/tcp/execute.ts b/src/server/checker/runner/tcp/execute.ts index af67832..e6d02d6 100644 --- a/src/server/checker/runner/tcp/execute.ts +++ b/src/server/checker/runner/tcp/execute.ts @@ -2,10 +2,11 @@ import { isError } from "es-toolkit"; import type { CheckResult, RawTargetConfig } from "../../types"; import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types"; -import type { ResolvedTcpTarget, TcpExpectConfig, TcpTargetConfig } from "./types"; +import type { RawTcpExpectConfig, ResolvedTcpExpectConfig, ResolvedTcpTarget, TcpTargetConfig } from "./types"; +import { resolveContentExpectations } from "../../expect/content"; import { errorFailure } from "../../expect/failure"; -import { checkValueMatcher } from "../../expect/matcher"; +import { checkValueExpectation, resolveValueExpectation } from "../../expect/value"; import { parseSize } from "../../utils"; import { checkBanner, checkConnected } from "./expect"; import { tcpCheckerSchemas } from "./schema"; @@ -159,7 +160,7 @@ export class TcpChecker implements CheckerDefinition { } const durationMs = Math.round(performance.now() - start); - const durationResult = checkValueMatcher(durationMs, expect?.durationMs, { + const durationResult = checkValueExpectation(durationMs, expect?.durationMs, { message: "durationMs mismatch", path: "durationMs", phase: "duration", @@ -212,13 +213,23 @@ export class TcpChecker implements CheckerDefinition { const maxBannerBytes = parseSize(t.tcp.maxBannerBytes ?? tcpDefaults?.maxBannerBytes ?? DEFAULT_MAX_BANNER_BYTES); const bannerReadTimeout = t.tcp.bannerReadTimeout ?? tcpDefaults?.bannerReadTimeout ?? DEFAULT_BANNER_READ_TIMEOUT; + const rawExpect = target.expect as RawTcpExpectConfig | undefined; + const resolvedExpect: ResolvedTcpExpectConfig = rawExpect + ? { + banner: resolveContentExpectations(rawExpect.banner), + connected: rawExpect.connected ?? true, + durationMs: resolveValueExpectation(rawExpect.durationMs), + } + : { connected: true }; + return { description: null, - expect: target.expect as TcpExpectConfig | undefined, + expect: resolvedExpect, group: target.group ?? "default", id: t.id, intervalMs: context.defaultIntervalMs, name: t.name ?? null, + rawExpect, tcp: { bannerReadTimeout, host: t.tcp.host, diff --git a/src/server/checker/runner/tcp/expect.ts b/src/server/checker/runner/tcp/expect.ts index 2c1cf40..5723154 100644 --- a/src/server/checker/runner/tcp/expect.ts +++ b/src/server/checker/runner/tcp/expect.ts @@ -1,13 +1,13 @@ -import type { ContentRules, ExpectResult } from "../../expect/types"; +import type { ContentExpectations, ExpectationResult } from "../../expect/types"; -import { checkContentRules } from "../../expect/content"; +import { checkContentExpectations } from "../../expect/content"; import { mismatchFailure } from "../../expect/failure"; -export function checkBanner(banner: string, rules: ContentRules): ExpectResult { - return checkContentRules(banner, rules, { path: "banner", phase: "banner" }); +export function checkBanner(banner: string, expectations: ContentExpectations): ExpectationResult { + return checkContentExpectations(banner, expectations, { path: "banner", phase: "banner" }); } -export function checkConnected(connected: boolean, expected: boolean): ExpectResult { +export function checkConnected(connected: boolean, expected: boolean): ExpectationResult { if (connected === expected) return { failure: null, matched: true }; if (!connected && expected) { return { diff --git a/src/server/checker/runner/tcp/schema.ts b/src/server/checker/runner/tcp/schema.ts index e17edd8..73c89f2 100644 --- a/src/server/checker/runner/tcp/schema.ts +++ b/src/server/checker/runner/tcp/schema.ts @@ -2,7 +2,11 @@ import { Type } from "@sinclair/typebox"; import type { CheckerSchemas } from "../types"; -import { createContentRulesSchema, createValueMatcherSchema, sizeSchema } from "../../schema/fragments"; +import { + createRawContentExpectationsSchema, + createRawValueExpectationSchema, + sizeSchema, +} from "../../schema/fragments"; export const tcpCheckerSchemas: CheckerSchemas = { config: Type.Object( @@ -24,9 +28,9 @@ export const tcpCheckerSchemas: CheckerSchemas = { ), expect: Type.Object( { - banner: Type.Optional(createContentRulesSchema()), + banner: Type.Optional(createRawContentExpectationsSchema()), connected: Type.Optional(Type.Boolean()), - durationMs: Type.Optional(createValueMatcherSchema()), + durationMs: Type.Optional(createRawValueExpectationSchema()), }, { additionalProperties: false }, ), diff --git a/src/server/checker/runner/tcp/types.ts b/src/server/checker/runner/tcp/types.ts index 0c0156b..cd62a1d 100644 --- a/src/server/checker/runner/tcp/types.ts +++ b/src/server/checker/runner/tcp/types.ts @@ -1,6 +1,17 @@ -import type { ContentRules, ValueMatcherInput } from "../../expect/types"; +import type { + ContentExpectations, + RawContentExpectations, + RawValueExpectation, + ValueExpectation, +} from "../../expect/types"; import type { ResolvedTargetBase } from "../../types"; +export interface RawTcpExpectConfig { + banner?: RawContentExpectations; + connected?: boolean; + durationMs?: RawValueExpectation; +} + export interface ResolvedTcpConfig { bannerReadTimeout: number; host: string; @@ -9,11 +20,18 @@ export interface ResolvedTcpConfig { readBanner: boolean; } +export interface ResolvedTcpExpectConfig { + banner?: ContentExpectations; + connected: boolean; + durationMs?: ValueExpectation; +} + export interface ResolvedTcpTarget extends ResolvedTargetBase { - expect?: TcpExpectConfig; + expect?: ResolvedTcpExpectConfig; group: string; intervalMs: number; name: null | string; + rawExpect?: RawTcpExpectConfig; tcp: ResolvedTcpConfig; timeoutMs: number; type: "tcp"; @@ -24,12 +42,6 @@ export interface TcpDefaultsConfig { maxBannerBytes?: number | string; } -export interface TcpExpectConfig { - banner?: ContentRules; - connected?: boolean; - durationMs?: ValueMatcherInput; -} - export interface TcpTargetConfig { bannerReadTimeout?: number; host: string; diff --git a/src/server/checker/runner/tcp/validate.ts b/src/server/checker/runner/tcp/validate.ts index 81537bc..2c1add4 100644 --- a/src/server/checker/runner/tcp/validate.ts +++ b/src/server/checker/runner/tcp/validate.ts @@ -1,10 +1,9 @@ -import { isNumber, isPlainObject, isString } from "es-toolkit"; +import { isNumber, isString } from "es-toolkit"; import type { ConfigValidationIssue } from "../../schema/issues"; import type { CheckerValidationInput } from "../types"; -import { normalizeExpectMatchers } from "../../expect/normalize"; -import { validateContentRules, validateValueMatcher } from "../../expect/validate-matcher"; +import { isPlainRecord, validateRawContentExpectations, validateRawValueExpectation } from "../../expect/validate"; import { issue, joinPath } from "../../schema/issues"; export function validateTcpConfig(input: CheckerValidationInput): ConfigValidationIssue[] { @@ -14,7 +13,7 @@ export function validateTcpConfig(input: CheckerValidationInput): ConfigValidati for (let i = 0; i < input.targets.length; i++) { const target = input.targets[i] as unknown; - if (!isPlainObject(target)) continue; + if (!isPlainRecord(target)) continue; if (target["type"] !== "tcp") continue; issues.push(...validateTcpTarget(target, `targets[${i}]`)); } @@ -34,7 +33,7 @@ function isNonNegativeFiniteNumber(value: unknown): boolean { function validateTcpDefaults(input: CheckerValidationInput): ConfigValidationIssue[] { const issues: ConfigValidationIssue[] = []; const defaults = input.defaults["tcp"]; - if (defaults === undefined || defaults === null || !isPlainObject(defaults)) return issues; + if (defaults === undefined || defaults === null || !isPlainRecord(defaults)) return issues; const targetName = "defaults.tcp"; @@ -72,18 +71,16 @@ function validateTcpExpect( ): ConfigValidationIssue[] { const targetName = getTargetName(target); const expect = target["expect"]; - if (expect === undefined || expect === null || !isPlainObject(expect)) return []; + if (expect === undefined || expect === null || !isPlainRecord(expect)) return []; const issues: ConfigValidationIssue[] = []; const expectPath = joinPath(path, "expect"); - normalizeExpectMatchers(expect, ["durationMs"]); - if (expect["connected"] !== undefined && typeof expect["connected"] !== "boolean") { issues.push(issue("invalid-type", joinPath(expectPath, "connected"), "必须为布尔值", targetName)); } if (expect["durationMs"] !== undefined) { - issues.push(...validateValueMatcher(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName)); + issues.push(...validateRawValueExpectation(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName)); } if (expect["banner"] !== undefined) { @@ -92,7 +89,7 @@ function validateTcpExpect( issue("invalid-value", joinPath(expectPath, "banner"), "banner 断言需要启用 tcp.readBanner", targetName), ); } else { - issues.push(...validateContentRules(expect["banner"], joinPath(expectPath, "banner"), targetName)); + issues.push(...validateRawContentExpectations(expect["banner"], joinPath(expectPath, "banner"), targetName)); } } @@ -111,7 +108,7 @@ function validateTcpTarget(target: Record, path: string): Confi const targetName = getTargetName(target); const tcp = target["tcp"]; - if (!isPlainObject(tcp)) { + if (!isPlainRecord(tcp)) { issues.push(issue("required", joinPath(path, "tcp"), "缺少 tcp 配置分组", targetName)); issues.push(...validateTcpExpect(target, path, false)); return issues; diff --git a/src/server/checker/runner/udp/execute.ts b/src/server/checker/runner/udp/execute.ts index 377345a..6d12ed7 100644 --- a/src/server/checker/runner/udp/execute.ts +++ b/src/server/checker/runner/udp/execute.ts @@ -2,10 +2,17 @@ import { isError } from "es-toolkit"; import type { CheckResult, RawTargetConfig } from "../../types"; import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types"; -import type { ResolvedUdpTarget, UdpDefaultsConfig, UdpExpectConfig, UdpTargetConfig } from "./types"; +import type { + RawUdpExpectConfig, + ResolvedUdpExpectConfig, + ResolvedUdpTarget, + UdpDefaultsConfig, + UdpTargetConfig, +} from "./types"; +import { resolveContentExpectations } from "../../expect/content"; import { errorFailure } from "../../expect/failure"; -import { checkValueMatcher } from "../../expect/matcher"; +import { checkValueExpectation, resolveValueExpectation } from "../../expect/value"; import { parseSize } from "../../utils"; import { decodePayload, encodeResponse } from "./encoding"; import { checkResponded, checkResponseSize, checkResponseText, checkSourceHost, checkSourcePort } from "./expect"; @@ -111,7 +118,7 @@ export class UdpChecker implements CheckerDefinition { }; } - const durationResult = checkValueMatcher(durationMs, expect?.durationMs, { + const durationResult = checkValueExpectation(durationMs, expect?.durationMs, { message: "durationMs mismatch", path: "durationMs", phase: "duration", @@ -255,7 +262,7 @@ export class UdpChecker implements CheckerDefinition { } } - const durationResult = checkValueMatcher(durationMs, expect?.durationMs, { + const durationResult = checkValueExpectation(durationMs, expect?.durationMs, { message: "durationMs mismatch", path: "durationMs", phase: "duration", @@ -305,13 +312,26 @@ export class UdpChecker implements CheckerDefinition { t.udp.maxResponseBytes ?? udpDefaults?.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, ); + const rawExpect = target.expect as RawUdpExpectConfig | undefined; + const resolvedExpect: ResolvedUdpExpectConfig = rawExpect + ? { + durationMs: resolveValueExpectation(rawExpect.durationMs), + responded: rawExpect.responded ?? true, + response: resolveContentExpectations(rawExpect.response), + responseSize: resolveValueExpectation(rawExpect.responseSize), + sourceHost: resolveValueExpectation(rawExpect.sourceHost), + sourcePort: resolveValueExpectation(rawExpect.sourcePort), + } + : { responded: true }; + return { description: null, - expect: target.expect as UdpExpectConfig | undefined, + expect: resolvedExpect, group: target.group ?? "default", id: t.id, intervalMs: context.defaultIntervalMs, name: t.name ?? null, + rawExpect, timeoutMs: context.defaultTimeoutMs, type: "udp", udp: { diff --git a/src/server/checker/runner/udp/expect.ts b/src/server/checker/runner/udp/expect.ts index 21c1c92..fd2e374 100644 --- a/src/server/checker/runner/udp/expect.ts +++ b/src/server/checker/runner/udp/expect.ts @@ -1,10 +1,10 @@ -import type { ContentRules, ExpectResult, ValueMatcherInput } from "../../expect/types"; +import type { ContentExpectations, ExpectationResult, ValueExpectation } from "../../expect/types"; -import { checkContentRules } from "../../expect/content"; +import { checkContentExpectations } from "../../expect/content"; import { mismatchFailure } from "../../expect/failure"; -import { checkValueMatcher } from "../../expect/matcher"; +import { checkValueExpectation } from "../../expect/value"; -export function checkResponded(responded: boolean, expected: boolean): ExpectResult { +export function checkResponded(responded: boolean, expected: boolean): ExpectationResult { if (responded === expected) return { failure: null, matched: true }; if (!responded && expected) { return { @@ -18,28 +18,28 @@ export function checkResponded(responded: boolean, expected: boolean): ExpectRes }; } -export function checkResponseSize(size: number, matcher: ValueMatcherInput): ExpectResult { - return checkValueMatcher(size, matcher, { +export function checkResponseSize(size: number, matcher: ValueExpectation): ExpectationResult { + return checkValueExpectation(size, matcher, { message: "响应大小不满足条件", path: "responseSize", phase: "responseSize", }); } -export function checkResponseText(text: string, rules: ContentRules): ExpectResult { - return checkContentRules(text, rules, { path: "response", phase: "response" }); +export function checkResponseText(text: string, expectations: ContentExpectations): ExpectationResult { + return checkContentExpectations(text, expectations, { path: "response", phase: "response" }); } -export function checkSourceHost(actual: string, matcher: ValueMatcherInput): ExpectResult { - return checkValueMatcher(actual, matcher, { +export function checkSourceHost(actual: string, matcher: ValueExpectation): ExpectationResult { + return checkValueExpectation(actual, matcher, { message: "响应来源地址不满足条件", path: "sourceHost", phase: "sourceHost", }); } -export function checkSourcePort(actual: number, matcher: ValueMatcherInput): ExpectResult { - return checkValueMatcher(actual, matcher, { +export function checkSourcePort(actual: number, matcher: ValueExpectation): ExpectationResult { + return checkValueExpectation(actual, matcher, { message: "响应来源端口不满足条件", path: "sourcePort", phase: "sourcePort", diff --git a/src/server/checker/runner/udp/schema.ts b/src/server/checker/runner/udp/schema.ts index 0d3ae47..b3985dc 100644 --- a/src/server/checker/runner/udp/schema.ts +++ b/src/server/checker/runner/udp/schema.ts @@ -2,7 +2,11 @@ import { Type } from "@sinclair/typebox"; import type { CheckerSchemas } from "../types"; -import { createContentRulesSchema, createValueMatcherSchema, sizeSchema } from "../../schema/fragments"; +import { + createRawContentExpectationsSchema, + createRawValueExpectationSchema, + sizeSchema, +} from "../../schema/fragments"; export const udpCheckerSchemas: CheckerSchemas = { config: Type.Object( @@ -26,12 +30,12 @@ export const udpCheckerSchemas: CheckerSchemas = { ), expect: Type.Object( { - durationMs: Type.Optional(createValueMatcherSchema()), + durationMs: Type.Optional(createRawValueExpectationSchema()), responded: Type.Optional(Type.Boolean()), - response: Type.Optional(createContentRulesSchema()), - responseSize: Type.Optional(createValueMatcherSchema()), - sourceHost: Type.Optional(createValueMatcherSchema()), - sourcePort: Type.Optional(createValueMatcherSchema()), + response: Type.Optional(createRawContentExpectationsSchema()), + responseSize: Type.Optional(createRawValueExpectationSchema()), + sourceHost: Type.Optional(createRawValueExpectationSchema()), + sourcePort: Type.Optional(createRawValueExpectationSchema()), }, { additionalProperties: false }, ), diff --git a/src/server/checker/runner/udp/types.ts b/src/server/checker/runner/udp/types.ts index 7a83ddf..e2890ad 100644 --- a/src/server/checker/runner/udp/types.ts +++ b/src/server/checker/runner/udp/types.ts @@ -1,6 +1,20 @@ -import type { ContentRules, ValueMatcherInput } from "../../expect/types"; +import type { + ContentExpectations, + RawContentExpectations, + RawValueExpectation, + ValueExpectation, +} from "../../expect/types"; import type { ResolvedTargetBase } from "../../types"; +export interface RawUdpExpectConfig { + durationMs?: RawValueExpectation; + responded?: boolean; + response?: RawContentExpectations; + responseSize?: RawValueExpectation; + sourceHost?: RawValueExpectation; + sourcePort?: RawValueExpectation; +} + export interface ResolvedUdpConfig { encoding: UdpEncoding; host: string; @@ -10,11 +24,21 @@ export interface ResolvedUdpConfig { responseEncoding: UdpEncoding; } +export interface ResolvedUdpExpectConfig { + durationMs?: ValueExpectation; + responded: boolean; + response?: ContentExpectations; + responseSize?: ValueExpectation; + sourceHost?: ValueExpectation; + sourcePort?: ValueExpectation; +} + export interface ResolvedUdpTarget extends ResolvedTargetBase { - expect?: UdpExpectConfig; + expect?: ResolvedUdpExpectConfig; group: string; intervalMs: number; name: null | string; + rawExpect?: RawUdpExpectConfig; timeoutMs: number; type: "udp"; udp: ResolvedUdpConfig; @@ -28,15 +52,6 @@ export interface UdpDefaultsConfig { export type UdpEncoding = "base64" | "hex" | "text"; -export interface UdpExpectConfig { - durationMs?: ValueMatcherInput; - responded?: boolean; - response?: ContentRules; - responseSize?: ValueMatcherInput; - sourceHost?: ValueMatcherInput; - sourcePort?: ValueMatcherInput; -} - export interface UdpTargetConfig { encoding?: UdpEncoding; host: string; diff --git a/src/server/checker/runner/udp/validate.ts b/src/server/checker/runner/udp/validate.ts index 1f4ca76..d3eb095 100644 --- a/src/server/checker/runner/udp/validate.ts +++ b/src/server/checker/runner/udp/validate.ts @@ -1,10 +1,9 @@ -import { isNumber, isPlainObject, isString } from "es-toolkit"; +import { isNumber, isString } from "es-toolkit"; import type { ConfigValidationIssue } from "../../schema/issues"; import type { CheckerValidationInput } from "../types"; -import { normalizeExpectMatchers } from "../../expect/normalize"; -import { validateContentRules, validateValueMatcher } from "../../expect/validate-matcher"; +import { isPlainRecord, validateRawContentExpectations, validateRawValueExpectation } from "../../expect/validate"; import { issue, joinPath } from "../../schema/issues"; const VALID_ENCODINGS = new Set(["base64", "hex", "text"]); @@ -16,7 +15,7 @@ export function validateUdpConfig(input: CheckerValidationInput): ConfigValidati for (let i = 0; i < input.targets.length; i++) { const target = input.targets[i] as unknown; - if (!isPlainObject(target)) continue; + if (!isPlainRecord(target)) continue; if (target["type"] !== "udp") continue; issues.push(...validateUdpTarget(target, `targets[${i}]`)); } @@ -48,7 +47,7 @@ function validateSize(value: unknown, path: string, targetName: string | undefin function validateUdpDefaults(input: CheckerValidationInput): ConfigValidationIssue[] { const issues: ConfigValidationIssue[] = []; const defaults = input.defaults["udp"]; - if (defaults === undefined || defaults === null || !isPlainObject(defaults)) return issues; + if (defaults === undefined || defaults === null || !isPlainRecord(defaults)) return issues; const targetName = "defaults.udp"; @@ -71,35 +70,35 @@ function validateUdpDefaults(input: CheckerValidationInput): ConfigValidationIss function validateUdpExpect(target: Record, path: string): ConfigValidationIssue[] { const targetName = getTargetName(target); const expect = target["expect"]; - if (expect === undefined || expect === null || !isPlainObject(expect)) return []; + if (expect === undefined || expect === null || !isPlainRecord(expect)) return []; const issues: ConfigValidationIssue[] = []; const expectPath = joinPath(path, "expect"); const responded: unknown = expect["responded"]; - normalizeExpectMatchers(expect, ["durationMs", "responseSize", "sourceHost", "sourcePort"]); - if (responded !== undefined && typeof responded !== "boolean") { issues.push(issue("invalid-type", joinPath(expectPath, "responded"), "必须为布尔值", targetName)); } if (expect["durationMs"] !== undefined) { - issues.push(...validateValueMatcher(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName)); + issues.push(...validateRawValueExpectation(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName)); } if (expect["response"] !== undefined) { - issues.push(...validateContentRules(expect["response"], joinPath(expectPath, "response"), targetName)); + issues.push(...validateRawContentExpectations(expect["response"], joinPath(expectPath, "response"), targetName)); } if (expect["responseSize"] !== undefined) { - issues.push(...validateValueMatcher(expect["responseSize"], joinPath(expectPath, "responseSize"), targetName)); + issues.push( + ...validateRawValueExpectation(expect["responseSize"], joinPath(expectPath, "responseSize"), targetName), + ); } if (expect["sourceHost"] !== undefined) { - issues.push(...validateValueMatcher(expect["sourceHost"], joinPath(expectPath, "sourceHost"), targetName)); + issues.push(...validateRawValueExpectation(expect["sourceHost"], joinPath(expectPath, "sourceHost"), targetName)); } if (expect["sourcePort"] !== undefined) { - issues.push(...validateValueMatcher(expect["sourcePort"], joinPath(expectPath, "sourcePort"), targetName)); + issues.push(...validateRawValueExpectation(expect["sourcePort"], joinPath(expectPath, "sourcePort"), targetName)); } const respondedFalse = responded === false; @@ -141,7 +140,7 @@ function validateUdpTarget(target: Record, path: string): Confi const targetName = getTargetName(target); const udp = target["udp"]; - if (!isPlainObject(udp)) { + if (!isPlainRecord(udp)) { issues.push(issue("required", joinPath(path, "udp"), "缺少 udp 配置分组", targetName)); issues.push(...validateUdpExpect(target, path)); return issues; diff --git a/src/server/checker/schema/builder.ts b/src/server/checker/schema/builder.ts index eb49bf8..a21049c 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 { - createContentRulesSchema, - createKeyValueExpectSchema, - createValueMatcherSchema, + createRawContentExpectationsSchema, + createRawKeyedExpectationsSchema, + createRawValueExpectationSchema, + createValueMatcherObjectSchema, durationSchema, variableValueSchema, } from "./fragments"; @@ -18,9 +19,10 @@ export function createExternalProbeConfigSchema(checkers: CheckerDefinition[]): $id: "https://dial.local/probe-config.schema.json", $schema: "http://json-schema.org/draft-07/schema#", definitions: { - ContentRules: cloneSchema(createContentRulesSchema()), - KeyValueExpect: cloneSchema(createKeyValueExpectSchema()), - ValueMatcher: cloneSchema(createValueMatcherSchema()), + ContentExpectations: cloneSchema(createRawContentExpectationsSchema()), + KeyedExpectations: cloneSchema(createRawKeyedExpectationsSchema()), + ValueExpectation: cloneSchema(createRawValueExpectationSchema()), + ValueMatcher: cloneSchema(createValueMatcherObjectSchema()), }, }; } diff --git a/src/server/checker/schema/fragments.ts b/src/server/checker/schema/fragments.ts index 39714bb..d1a5c90 100644 --- a/src/server/checker/schema/fragments.ts +++ b/src/server/checker/schema/fragments.ts @@ -6,8 +6,6 @@ import type { JsonValue } from "./types"; export const HTTP_METHODS = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] as const; -export const MatcherKeys = ["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "regex"] as const; - export const durationSchema = Type.String(); export const httpMethodSchema = Type.Union( @@ -43,7 +41,7 @@ export const stringMapSchema = Type.Unsafe>({ type: "object", }); -export function createContentRulesSchema(): TSchema { +export function createRawContentExpectationsSchema(): TSchema { return Type.Array( Type.Object( { @@ -66,21 +64,23 @@ export function createContentRulesSchema(): TSchema { ); } -export function createKeyValueExpectSchema(): TSchema { +export function createRawKeyedExpectationsSchema(): TSchema { return Type.Unsafe>({ - additionalProperties: { - anyOf: [jsonValueSchema, createValueMatcherSchema()], - }, + additionalProperties: createRawValueExpectationSchema(), type: "object", }); } -export function createValueMatcherSchema(): TSchema { +export function createRawValueExpectationSchema(): TSchema { return Type.Unsafe({ anyOf: [primitiveValueSchema, Type.Object(matcherProperties(), { additionalProperties: false, minProperties: 1 })], }); } +export function createValueMatcherObjectSchema(): TSchema { + return Type.Object(matcherProperties(), { additionalProperties: false, minProperties: 1 }); +} + export function matcherProperties(): Record { return { contains: Type.Optional(Type.String()), diff --git a/src/server/checker/store.ts b/src/server/checker/store.ts index aeb8e1c..5a4b919 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.expect ? JSON.stringify(t.expect) : null; + const expect = t.rawExpect ? JSON.stringify(t.rawExpect) : null; if (existingIds.has(t.id)) { updateStmt.run(t.name, t.description, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group, t.id); diff --git a/src/server/checker/types.ts b/src/server/checker/types.ts index e95e482..ba151b1 100644 --- a/src/server/checker/types.ts +++ b/src/server/checker/types.ts @@ -45,6 +45,7 @@ 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 c3aad9f..2872de6 100644 --- a/tests/server/checker/config-contract/validate.test.ts +++ b/tests/server/checker/config-contract/validate.test.ts @@ -84,6 +84,33 @@ describe("config contract", () => { expect(validate(target({ equals: { status: "ok" } }))).toBe(true); }); + test("导出 schema 拒绝 KeyedExpectations 的数组和对象简写", () => { + const ajv = new Ajv({ + allErrors: true, + coerceTypes: false, + removeAdditional: false, + strict: true, + useDefaults: false, + }); + const validate = ajv.compile(createProbeConfigJsonSchema(createDefaultCheckerRegistry())); + const target = (headerValue: unknown) => ({ + targets: [ + { + expect: { headers: { "x-test": headerValue } }, + http: { url: "https://example.com" }, + id: "api", + type: "http", + }, + ], + }); + + expect(validate(target("ok"))).toBe(true); + expect(validate(target({ contains: "ok" }))).toBe(true); + expect(validate(target(["ok"]))).toBe(false); + expect(validate(target({ nested: "ok" }))).toBe(false); + expect(validate(target({ equals: { nested: "ok" } }))).toBe(true); + }); + test("Ajv 错误转换为中文结构化 issue", () => { const result = validateProbeConfigContract( { diff --git a/tests/server/checker/config-loader.test.ts b/tests/server/checker/config-loader.test.ts index 5602046..395d77e 100644 --- a/tests/server/checker/config-loader.test.ts +++ b/tests/server/checker/config-loader.test.ts @@ -3,14 +3,13 @@ import { mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { ValueMatcher } from "../../../src/server/checker/expect/types"; import type { ResolvedCommandTarget } from "../../../src/server/checker/runner/cmd/types"; import type { ResolvedHttpTarget } from "../../../src/server/checker/runner/http/types"; import type { ResolvedPingTarget } from "../../../src/server/checker/runner/icmp/types"; import type { ResolvedTcpTarget } from "../../../src/server/checker/runner/tcp/types"; import { loadConfig, parseDuration } from "../../../src/server/checker/config-loader"; -import { checkValueMatcher } from "../../../src/server/checker/expect/matcher"; +import { checkValueExpectation } from "../../../src/server/checker/expect/value"; import { checkerRegistry } from "../../../src/server/checker/runner"; import { CommandChecker } from "../../../src/server/checker/runner/cmd/execute"; import { HttpChecker } from "../../../src/server/checker/runner/http/execute"; @@ -290,7 +289,7 @@ targets: expect(config.targets[0]!.name).toBeNull(); }); - test("ValueMatcher primitive 简写在加载时归一化后可运行期匹配", async () => { + test("ValueMatcher primitive 简写在 resolve 后可运行期匹配", async () => { const configPath = join(tempDir, "matcher-shorthand.yaml"); await writeFile( configPath, @@ -309,7 +308,7 @@ targets: expect(target.expect?.durationMs).toEqual({ equals: 123 }); expect( - checkValueMatcher(123, target.expect?.durationMs as ValueMatcher, { + checkValueExpectation(123, target.expect?.durationMs, { path: "durationMs", phase: "duration", }).matched, @@ -860,11 +859,19 @@ targets: const config = await loadConfig(configPath); const t = config.targets[0]!; if (t.type === "http") { - expect(t.expect).toEqual({ + expect(t.rawExpect).toEqual({ body: [{ contains: "ok" }, { json: { equals: "ok", path: "$.status" } }], durationMs: { lte: 3000 }, status: [200, 201], }); + expect(t.expect).toEqual({ + body: [ + { kind: "value", matcher: { contains: "ok" } }, + { kind: "json", matcher: { equals: "ok" }, path: "$.status" }, + ], + durationMs: { lte: 3000 }, + status: [200, 201], + }); } }); @@ -893,12 +900,21 @@ targets: const config = await loadConfig(configPath); const t = config.targets[0]!; if (t.type === "cmd") { - expect(t.expect).toEqual({ + expect(t.rawExpect).toEqual({ durationMs: { lte: 5000 }, exitCode: [0, 2], stderr: [{ empty: true }], stdout: [{ contains: "ok" }, { regex: "done" }], }); + expect(t.expect).toEqual({ + durationMs: { lte: 5000 }, + exitCode: [0, 2], + stderr: [{ kind: "value", matcher: { empty: true } }], + stdout: [ + { kind: "value", matcher: { contains: "ok" } }, + { kind: "value", matcher: { regex: "done" } }, + ], + }); } }); @@ -1917,7 +1933,7 @@ targets: const config = await loadConfig(configPath); const t = config.targets[0]! as ResolvedTcpTarget; expect(t.tcp.readBanner).toBe(true); - expect(t.expect?.banner).toEqual([{ contains: "ESMTP" }]); + expect(t.expect?.banner).toEqual([{ kind: "value", matcher: { contains: "ESMTP" } }]); }); test("tcp expect.banner 未开启 readBanner 抛出错误", async () => { diff --git a/tests/server/checker/expect/normalize.test.ts b/tests/server/checker/expect/normalize.test.ts deleted file mode 100644 index 1472e75..0000000 --- a/tests/server/checker/expect/normalize.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { normalizeExpectMatchers, normalizeValueMatcher } from "../../../../src/server/checker/expect/normalize"; - -describe("normalizeValueMatcher", () => { - test("normalizes primitive values to equals matcher", () => { - expect(normalizeValueMatcher("stop")).toEqual({ equals: "stop" }); - expect(normalizeValueMatcher(1)).toEqual({ equals: 1 }); - expect(normalizeValueMatcher(true)).toEqual({ equals: true }); - expect(normalizeValueMatcher(null)).toEqual({ equals: null }); - }); - - test("leaves undefined, matcher objects, arrays, and plain objects unchanged", () => { - const matcher = { lte: 5000 }; - const array = [1, 2]; - const object = { foo: "bar" }; - - expect(normalizeValueMatcher(undefined)).toBeUndefined(); - expect(normalizeValueMatcher(matcher)).toBe(matcher); - expect(normalizeValueMatcher(array)).toBe(array); - expect(normalizeValueMatcher(object)).toBe(object); - }); - - test("normalizes only selected expect keys", () => { - const expectConfig: Record = { durationMs: 100, responded: true }; - - normalizeExpectMatchers(expectConfig, ["durationMs"]); - - expect(expectConfig).toEqual({ durationMs: { equals: 100 }, responded: true }); - }); -}); diff --git a/tests/server/checker/runner/cmd/runner.test.ts b/tests/server/checker/runner/cmd/runner.test.ts index 1996326..7e49283 100644 --- a/tests/server/checker/runner/cmd/runner.test.ts +++ b/tests/server/checker/runner/cmd/runner.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { ResolvedCommandTarget } from "../../../../../src/server/checker/runner/cmd/types"; -import type { CheckerContext } from "../../../../../src/server/checker/runner/types"; +import type { CheckerContext, ResolveContext } from "../../../../../src/server/checker/runner/types"; import { CommandChecker } from "../../../../../src/server/checker/runner/cmd/execute"; @@ -17,6 +17,10 @@ function makeCtx(timeoutMs = 5000): CheckerContext { return { signal: controller.signal }; } +function makeResolveContext(): ResolveContext { + return { configDir: process.cwd(), defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 }; +} + function makeTarget( cmd: Partial, overrides?: Partial, @@ -94,7 +98,7 @@ describe("CommandChecker", () => { const result = await checker.execute( makeTarget( { args: ["-e", "console.log('hello')"], exec: "bun" }, - { expect: { stdout: [{ contains: "hello" }] } }, + { expect: { exitCode: [0], stdout: [{ kind: "value", matcher: { contains: "hello" } }] } }, ), makeCtx(), ); @@ -105,7 +109,7 @@ describe("CommandChecker", () => { const result = await checker.execute( makeTarget( { args: ["-e", "console.log('hello')"], exec: "bun" }, - { expect: { stdout: [{ contains: "nonexistent" }] } }, + { expect: { exitCode: [0], stdout: [{ kind: "value", matcher: { contains: "nonexistent" } }] } }, ), makeCtx(), ); @@ -117,7 +121,7 @@ describe("CommandChecker", () => { const result = await checker.execute( makeTarget( { args: ["-e", "process.stderr.write('error\\n')"], exec: "bun" }, - { expect: { stderr: [{ contains: "error" }] } }, + { expect: { exitCode: [0], stderr: [{ kind: "value", matcher: { contains: "error" } }] } }, ), makeCtx(), ); @@ -142,7 +146,10 @@ describe("CommandChecker", () => { test("不使用 shell,通配符不被展开", async () => { const result = await checker.execute( - makeTarget({ args: ["-e", "console.log('*')"], exec: "bun" }, { expect: { stdout: [{ contains: "*" }] } }), + makeTarget( + { args: ["-e", "console.log('*')"], exec: "bun" }, + { expect: { exitCode: [0], stdout: [{ kind: "value", matcher: { contains: "*" } }] } }, + ), makeCtx(), ); expect(result.matched).toBe(true); @@ -156,7 +163,7 @@ describe("CommandChecker", () => { env: { ...processEnv, DIAL_TEST_ENV: "resolved-env" }, exec: "bun", }, - { expect: { stdout: [{ contains: "resolved-env" }] } }, + { expect: { exitCode: [0], stdout: [{ kind: "value", matcher: { contains: "resolved-env" } }] } }, ), makeCtx(), ); @@ -172,4 +179,11 @@ describe("CommandChecker", () => { expect(config.exec).toBe("bun"); expect(config.args).toEqual(["-e", "console.log('hello')"]); }); + + test("resolve 未配置 expect 时物化默认 exitCode", () => { + const result = checker.resolve({ cmd: { exec: "true" }, id: "test", type: "cmd" }, makeResolveContext()); + + expect(result.rawExpect).toBeUndefined(); + expect(result.expect).toEqual({ exitCode: [0] }); + }); }); diff --git a/tests/server/checker/runner/db/execute.test.ts b/tests/server/checker/runner/db/execute.test.ts index 98132ff..b9c28ab 100644 --- a/tests/server/checker/runner/db/execute.test.ts +++ b/tests/server/checker/runner/db/execute.test.ts @@ -1,8 +1,15 @@ import { describe, expect, test } from "bun:test"; -import type { ResolvedDbTarget } from "../../../../../src/server/checker/runner/db/types"; +import type { + RawDbExpectConfig, + ResolvedDbExpectConfig, + ResolvedDbTarget, +} from "../../../../../src/server/checker/runner/db/types"; import type { CheckerContext } from "../../../../../src/server/checker/runner/types"; +import { resolveContentExpectations } from "../../../../../src/server/checker/expect/content"; +import { resolveKeyedExpectations } from "../../../../../src/server/checker/expect/keyed"; +import { resolveValueExpectation } from "../../../../../src/server/checker/expect/value"; import { DbChecker } from "../../../../../src/server/checker/runner/db/execute"; const checker = new DbChecker(); @@ -13,20 +20,31 @@ function makeCtx(timeoutMs = 5000): CheckerContext { return { signal: controller.signal }; } -function makeTarget(db: Partial, overrides?: Partial): ResolvedDbTarget { +function makeTarget(db: Partial, overrides?: { expect?: RawDbExpectConfig }): ResolvedDbTarget { + const raw = overrides?.expect; + const resolvedExpect: ResolvedDbExpectConfig | undefined = raw + ? { + durationMs: resolveValueExpectation(raw.durationMs), + result: resolveContentExpectations(raw.result), + rowCount: resolveValueExpectation(raw.rowCount), + rows: raw.rows?.map((row) => resolveKeyedExpectations(row) ?? []), + } + : undefined; + return { db: { url: "sqlite://:memory:", ...db, }, description: null, + expect: resolvedExpect, group: "default", id: "test-db", intervalMs: 60000, name: "test-db", + rawExpect: raw, timeoutMs: 5000, type: "db", - ...overrides, }; } diff --git a/tests/server/checker/runner/db/expect.test.ts b/tests/server/checker/runner/db/expect.test.ts index b29dd13..4df85e7 100644 --- a/tests/server/checker/runner/db/expect.test.ts +++ b/tests/server/checker/runner/db/expect.test.ts @@ -1,7 +1,14 @@ import { describe, expect, test } from "bun:test"; +import type { KeyedExpectations, RawValueExpectation } from "../../../../../src/server/checker/expect/types"; + +import { resolveValueExpectation } from "../../../../../src/server/checker/expect/value"; import { checkRowCount, checkRows } from "../../../../../src/server/checker/runner/db/expect"; +function row(record: Record): KeyedExpectations { + return Object.entries(record).map(([key, value]) => ({ key, matcher: resolveValueExpectation(value) })); +} + describe("checkRowCount", () => { test("空数组通过 rowCount gte 0", () => { const result = checkRowCount(0, { gte: 0 }); @@ -39,7 +46,7 @@ describe("checkRowCount", () => { describe("checkRows", () => { test("非数组返回失败", () => { - const result = checkRows(null, [{ col: 1 }]); + const result = checkRows(null, [row({ col: 1 })]); expect(result.matched).toBe(false); expect(result.failure!.phase).toBe("row"); expect(result.failure!.path).toBe("rows"); @@ -51,17 +58,17 @@ describe("checkRows", () => { }); test("单行单列匹配(字面量)", () => { - const result = checkRows([{ col: "value" }], [{ col: "value" }]); + const result = checkRows([{ col: "value" }], [row({ col: "value" })]); expect(result.matched).toBe(true); }); test("单行单列匹配(operator)", () => { - const result = checkRows([{ col: 100 }], [{ col: { gte: 50 } }]); + const result = checkRows([{ col: 100 }], [row({ col: { gte: 50 } })]); expect(result.matched).toBe(true); }); test("单行单列不匹配", () => { - const result = checkRows([{ col: 10 }], [{ col: { gte: 50 } }]); + const result = checkRows([{ col: 10 }], [row({ col: { gte: 50 } })]); expect(result.matched).toBe(false); expect(result.failure!.phase).toBe("row"); expect(result.failure!.path).toBe("rows[0].col"); @@ -73,62 +80,61 @@ describe("checkRows", () => { { id: 1, name: "Alice" }, { id: 2, name: "Bob" }, ], - [{ id: { gte: 1 } }, { name: "Bob" }], + [row({ id: { gte: 1 } }), row({ name: "Bob" })], ); expect(result.matched).toBe(true); }); test("多行中有一行不匹配", () => { - const result = checkRows([{ col: 1 }, { col: 2 }], [{ col: { gte: 2 } }, { col: { gte: 3 } }]); + const result = checkRows([{ col: 1 }, { col: 2 }], [row({ col: { gte: 2 } }), row({ col: { gte: 3 } })]); expect(result.matched).toBe(false); - // 第一行 { col: 1 } 不满足 { gte: 2 },所以失败在第一行 expect(result.failure!.path).toBe("rows[0].col"); }); test("结果行数不足", () => { - const result = checkRows([{ col: 1 }], [{ col: 1 }, { col: 2 }]); + const result = checkRows([{ col: 1 }], [row({ col: 1 }), row({ col: 2 })]); expect(result.matched).toBe(false); expect(result.failure!.message).toContain("行数不足"); }); test("只检查声明的列", () => { - const result = checkRows([{ col: 1, other: "ignored" }], [{ col: { gte: 0 } }]); + const result = checkRows([{ col: 1, other: "ignored" }], [row({ col: { gte: 0 } })]); expect(result.matched).toBe(true); }); test("行不是对象返回失败", () => { - const result = checkRows(["not-an-object"] as unknown[], [{ col: 1 }]); + const result = checkRows(["not-an-object"] as unknown[], [row({ col: 1 })]); expect(result.matched).toBe(false); expect(result.failure!.path).toBe("rows[0]"); }); test("列不存在视为 undefined", () => { - const result = checkRows([{}], [{ col: { exists: false } }]); + const result = checkRows([{}], [row({ col: { exists: false } })]); expect(result.matched).toBe(true); }); test("列存在且值为 null", () => { - const result = checkRows([{ col: null }], [{ col: { empty: true } }]); + const result = checkRows([{ col: null }], [row({ col: { empty: true } })]); expect(result.matched).toBe(true); }); test("contains 匹配字符串", () => { - const result = checkRows([{ text: "hello world" }], [{ text: { contains: "hello" } }]); + const result = checkRows([{ text: "hello world" }], [row({ text: { contains: "hello" } })]); expect(result.matched).toBe(true); }); test("regex 正则匹配", () => { - const result = checkRows([{ code: "ABC-123" }], [{ code: { regex: "^ABC-" } }]); + const result = checkRows([{ code: "ABC-123" }], [row({ code: { regex: "^ABC-" } })]); expect(result.matched).toBe(true); }); test("多个断言同时满足", () => { - const result = checkRows([{ val: 50 }], [{ val: { gte: 10, lte: 100 } }]); + const result = checkRows([{ val: 50 }], [row({ val: { gte: 10, lte: 100 } })]); expect(result.matched).toBe(true); }); test("多个断言中有一个不满足", () => { - const result = checkRows([{ val: 50 }], [{ val: { gte: 10, lte: 30 } }]); + const result = checkRows([{ val: 50 }], [row({ val: { gte: 10, lte: 30 } })]); expect(result.matched).toBe(false); }); }); diff --git a/tests/server/checker/runner/http/expect.test.ts b/tests/server/checker/runner/http/expect.test.ts index 93b4099..c20e588 100644 --- a/tests/server/checker/runner/http/expect.test.ts +++ b/tests/server/checker/runner/http/expect.test.ts @@ -1,6 +1,14 @@ import { describe, expect, test } from "bun:test"; -import { checkHeaders, checkStatus } from "../../../../../src/server/checker/runner/http/expect"; +import type { RawKeyedExpectations } from "../../../../../src/server/checker/expect/types"; + +import { checkHeaderExpectations } from "../../../../../src/server/checker/expect/headers"; +import { resolveKeyedExpectations } from "../../../../../src/server/checker/expect/keyed"; +import { checkStatusCode } from "../../../../../src/server/checker/expect/status"; + +function checkHeaders(headers: Record, raw?: RawKeyedExpectations) { + return checkHeaderExpectations(headers, resolveKeyedExpectations(raw)); +} describe("checkHeaders", () => { test("未配置 headers expect 时匹配成功", () => { @@ -46,15 +54,15 @@ describe("checkHeaders", () => { }); }); -describe("checkStatus 范围匹配", () => { - test("无 expect 配置时默认 status [200] 可由调用方使用 checkStatus 表达", () => { - const result = checkStatus(200, [200]); +describe("checkStatusCode 范围匹配", () => { + test("无 expect 配置时默认 status [200] 可由调用方使用 checkStatusCode 表达", () => { + const result = checkStatusCode(200, [200]); expect(result.matched).toBe(true); expect(result.failure).toBeNull(); }); test("status 不匹配返回 phase=status 的失败", () => { - const result = checkStatus(503, [200]); + const result = checkStatusCode(503, [200]); expect(result.matched).toBe(false); expect(result.failure!.phase).toBe("status"); expect(result.failure!.expected).toEqual([200]); @@ -62,47 +70,47 @@ describe("checkStatus 范围匹配", () => { }); test("2xx 范围匹配 200", () => { - expect(checkStatus(200, ["2xx"]).matched).toBe(true); + expect(checkStatusCode(200, ["2xx"]).matched).toBe(true); }); test("2xx 范围匹配 204", () => { - expect(checkStatus(204, ["2xx"]).matched).toBe(true); + expect(checkStatusCode(204, ["2xx"]).matched).toBe(true); }); test("2xx 范围不匹配 301", () => { - expect(checkStatus(301, ["2xx"]).matched).toBe(false); + expect(checkStatusCode(301, ["2xx"]).matched).toBe(false); }); test("5xx 范围匹配 503", () => { - expect(checkStatus(503, ["5xx"]).matched).toBe(true); + expect(checkStatusCode(503, ["5xx"]).matched).toBe(true); }); test("混合精确值与范围模式命中精确值", () => { - expect(checkStatus(301, ["2xx", 301]).matched).toBe(true); + expect(checkStatusCode(301, ["2xx", 301]).matched).toBe(true); }); test("混合精确值与范围模式命中范围", () => { - expect(checkStatus(204, ["2xx", 301]).matched).toBe(true); + expect(checkStatusCode(204, ["2xx", 301]).matched).toBe(true); }); test("混合模式都不匹配", () => { - expect(checkStatus(404, ["2xx", 301]).matched).toBe(false); + expect(checkStatusCode(404, ["2xx", 301]).matched).toBe(false); }); test("纯精确值仍正常工作", () => { - expect(checkStatus(200, [200, 201]).matched).toBe(true); - expect(checkStatus(404, [200, 201]).matched).toBe(false); + expect(checkStatusCode(200, [200, 201]).matched).toBe(true); + expect(checkStatusCode(404, [200, 201]).matched).toBe(false); }); test("1xx 范围匹配 101", () => { - expect(checkStatus(101, ["1xx"]).matched).toBe(true); + expect(checkStatusCode(101, ["1xx"]).matched).toBe(true); }); test("3xx 范围匹配 301", () => { - expect(checkStatus(301, ["3xx"]).matched).toBe(true); + expect(checkStatusCode(301, ["3xx"]).matched).toBe(true); }); test("4xx 范围匹配 404", () => { - expect(checkStatus(404, ["4xx"]).matched).toBe(true); + expect(checkStatusCode(404, ["4xx"]).matched).toBe(true); }); }); diff --git a/tests/server/checker/runner/http/runner.test.ts b/tests/server/checker/runner/http/runner.test.ts index 5a0b90c..6abf368 100644 --- a/tests/server/checker/runner/http/runner.test.ts +++ b/tests/server/checker/runner/http/runner.test.ts @@ -1,13 +1,22 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import type { ResolvedHttpTarget } from "../../../../../src/server/checker/runner/http/types"; +import type { + RawHttpExpectConfig, + ResolvedHttpExpectConfig, + ResolvedHttpTarget, +} from "../../../../../src/server/checker/runner/http/types"; import type { CheckerContext, ResolveContext } from "../../../../../src/server/checker/runner/types"; +import { resolveContentExpectations } from "../../../../../src/server/checker/expect/content"; +import { resolveKeyedExpectations } from "../../../../../src/server/checker/expect/keyed"; +import { checkStatusCode } from "../../../../../src/server/checker/expect/status"; +import { resolveValueExpectation } from "../../../../../src/server/checker/expect/value"; import { HttpChecker } from "../../../../../src/server/checker/runner/http/execute"; -import { checkStatus } from "../../../../../src/server/checker/runner/http/expect"; import { formatConfigIssues } from "../../../../../src/server/checker/schema/issues"; const checker = new HttpChecker(); +const SLOW_BODY_DELAY_MS = 1000; +const FAST_RESPONSE_LIMIT_MS = 500; function validateHttpTarget(target: unknown): string { return formatConfigIssues(checker.validate({ defaults: {}, targets: [target as never] })); @@ -145,7 +154,7 @@ describe("HttpChecker", () => { function makeTarget(overrides: { body?: string; - expect?: Record; + expect?: RawHttpExpectConfig; headers?: Record; ignoreSSL?: boolean; maxBodyBytes?: number; @@ -154,9 +163,19 @@ describe("HttpChecker", () => { timeoutMs?: number; url?: string; }): ResolvedHttpTarget { + const raw = overrides.expect; + const resolvedExpect: ResolvedHttpExpectConfig | undefined = raw + ? { + body: resolveContentExpectations(raw.body), + durationMs: resolveValueExpectation(raw.durationMs), + headers: resolveKeyedExpectations(raw.headers), + status: raw.status ?? [200], + } + : undefined; + return { description: null, - expect: overrides.expect, + expect: resolvedExpect, group: "default", http: { body: overrides.body, @@ -170,6 +189,7 @@ describe("HttpChecker", () => { id: "test-http", intervalMs: 60000, name: "test-http", + rawExpect: raw, timeoutMs: overrides.timeoutMs ?? 5000, type: "http", }; @@ -181,6 +201,29 @@ describe("HttpChecker", () => { return { signal: controller.signal }; } + function startSlowBodyServer(init: ResponseInit = {}) { + return Bun.serve({ + fetch() { + let sentFirstChunk = false; + return new Response( + new ReadableStream({ + async pull(controller) { + if (!sentFirstChunk) { + sentFirstChunk = true; + controller.enqueue(new TextEncoder().encode("slow body")); + return; + } + await new Promise((resolve) => setTimeout(resolve, SLOW_BODY_DELAY_MS)); + controller.close(); + }, + }), + init, + ); + }, + port: 0, + }); + } + test("成功请求 200", async () => { const result = await checker.execute(makeTarget({ url: `${baseUrl}/ok` }), makeCtx()); expect(result.matched).toBe(true); @@ -192,7 +235,7 @@ describe("HttpChecker", () => { test("404 不匹配默认 status [200]", async () => { const result = await checker.execute(makeTarget({ url: `${baseUrl}/notfound` }), makeCtx()); expect(result.matched).toBe(false); - expect(result.observation).toMatchObject({ bodyPreview: "not found", statusCode: 404 }); + expect(result.observation).toMatchObject({ bodyPreview: null, statusCode: 404 }); expect(result.failure!.phase).toBe("status"); }); @@ -218,7 +261,7 @@ describe("HttpChecker", () => { makeCtx(), ); expect(result.matched).toBe(false); - expect(result.observation).toMatchObject({ bodyPreview: "hello world", statusCode: 200 }); + expect(result.observation).toMatchObject({ bodyPreview: null, statusCode: 200 }); expect(result.failure!.phase).toBe("headers"); }); @@ -279,12 +322,43 @@ describe("HttpChecker", () => { }); test("快速失败:status 失败时不读取 body", async () => { - const result = await checker.execute( - makeTarget({ expect: { body: [{ contains: "something" }], status: [200] }, url: `${baseUrl}/notfound` }), - makeCtx(), - ); - expect(result.matched).toBe(false); - expect(result.failure!.phase).toBe("status"); + const slowBodyServer = startSlowBodyServer({ status: 404 }); + try { + const start = performance.now(); + const result = await checker.execute( + makeTarget({ + expect: { body: [{ contains: "something" }], status: [200] }, + url: `http://localhost:${slowBodyServer.port}/`, + }), + makeCtx(), + ); + expect(performance.now() - start).toBeLessThan(FAST_RESPONSE_LIMIT_MS); + expect(result.matched).toBe(false); + expect(result.failure!.phase).toBe("status"); + expect(result.observation?.["bodyPreview"]).toBeNull(); + } finally { + void slowBodyServer.stop(); + } + }); + + test("快速失败:headers 失败时不读取 body", async () => { + const slowBodyServer = startSlowBodyServer({ headers: { "x-custom": "actual" }, status: 200 }); + try { + const start = performance.now(); + const result = await checker.execute( + makeTarget({ + expect: { body: [{ contains: "something" }], headers: { "x-custom": "expected" }, status: [200] }, + url: `http://localhost:${slowBodyServer.port}/`, + }), + makeCtx(), + ); + expect(performance.now() - start).toBeLessThan(FAST_RESPONSE_LIMIT_MS); + expect(result.matched).toBe(false); + expect(result.failure!.phase).toBe("headers"); + expect(result.observation?.["bodyPreview"]).toBeNull(); + } finally { + void slowBodyServer.stop(); + } }); test("status 通过但 body 失败", async () => { @@ -511,12 +585,40 @@ describe("HttpChecker", () => { } }); - test("无 body rules 时不读取 body", async () => { - const result = await checker.execute( - makeTarget({ expect: { status: [200] }, maxBodyBytes: 1, url: `${baseUrl}/large` }), - makeCtx(), - ); - expect(result.matched).toBe(true); + test("无 body expectations 时不读取 body", async () => { + const slowBodyServer = startSlowBodyServer({ status: 200 }); + try { + const start = performance.now(); + const result = await checker.execute( + makeTarget({ expect: { status: [200] }, maxBodyBytes: 1, url: `http://localhost:${slowBodyServer.port}/` }), + makeCtx(), + ); + expect(performance.now() - start).toBeLessThan(FAST_RESPONSE_LIMIT_MS); + expect(result.matched).toBe(true); + expect(result.observation?.["bodyPreview"]).toBeNull(); + } finally { + void slowBodyServer.stop(); + } + }); + + test("进入 body 前 durationMs 上界已失败时不读取 body", async () => { + const slowBodyServer = startSlowBodyServer({ status: 200 }); + try { + const start = performance.now(); + const result = await checker.execute( + makeTarget({ + expect: { body: [{ contains: "slow" }], durationMs: { lte: 0 }, status: [200] }, + url: `http://localhost:${slowBodyServer.port}/`, + }), + makeCtx(), + ); + expect(performance.now() - start).toBeLessThan(FAST_RESPONSE_LIMIT_MS); + expect(result.matched).toBe(false); + expect(result.failure!.phase).toBe("duration"); + expect(result.observation?.["bodyPreview"]).toBeNull(); + } finally { + void slowBodyServer.stop(); + } }); test("body 失败优先于 duration 检查", async () => { @@ -598,7 +700,7 @@ describe("HttpChecker", () => { expect(result.observation).toMatchObject({ statusCode: 200 }); }); - test("混合 body rules 集成检查", async () => { + test("混合 body expectations 集成检查", async () => { const result = await checker.execute( makeTarget({ expect: { @@ -649,7 +751,7 @@ describe("HttpChecker", () => { }); test("1xx 范围模式匹配 101", () => { - const r = checkStatus(101, ["1xx"]); + const r = checkStatusCode(101, ["1xx"]); expect(r.matched).toBe(true); }); @@ -868,6 +970,16 @@ describe("HttpChecker.resolve", () => { expect(result.http.ignoreSSL).toBe(false); }); + test("未配置 expect 时在 Resolved 模型物化默认 status", () => { + const result = checker.resolve( + { http: { url: "https://example.com" }, id: "test", name: "test", type: "http" }, + makeResolveContext(), + ); + + expect(result.rawExpect).toBeUndefined(); + expect(result.expect).toEqual({ status: [200] }); + }); + test("maxRedirects 默认值为 0", () => { const result = checker.resolve( { http: { url: "https://example.com" }, id: "test", name: "test", type: "http" }, diff --git a/tests/server/checker/runner/icmp/execute.test.ts b/tests/server/checker/runner/icmp/execute.test.ts index 02bf67d..65aed15 100644 --- a/tests/server/checker/runner/icmp/execute.test.ts +++ b/tests/server/checker/runner/icmp/execute.test.ts @@ -84,7 +84,10 @@ rtt min/avg/max/mdev = 1.234/2.345/3.456/0.567 ms`); test("packetLoss 断言失败", async () => { mockSpawn(`3 packets transmitted, 2 received, 33% packet loss, time 2003ms rtt min/avg/max/mdev = 1.234/156.000/340.000/0.567 ms`); - const result = await checker.execute(makeTarget({ expect: { packetLossPercent: { lte: 10 } } }), makeCtx()); + const result = await checker.execute( + makeTarget({ expect: { alive: true, packetLossPercent: { lte: 10 } } }), + makeCtx(), + ); expect(result.matched).toBe(false); expect(result.failure?.phase).toBe("packetLoss"); expect(result.observation).toMatchObject({ alive: true, maxLatencyMs: 340 }); @@ -125,6 +128,8 @@ describe("IcmpChecker resolve", () => { ); expect(target.icmp).toEqual({ count: 3, host: "10.0.0.1", packetSize: 56 }); expect(target.group).toBe("default"); + expect(target.rawExpect).toBeUndefined(); + expect(target.expect).toEqual({ alive: true }); }); test("serialize 返回摘要和配置", () => { diff --git a/tests/server/checker/runner/llm/execute.test.ts b/tests/server/checker/runner/llm/execute.test.ts index 47658d1..d78c732 100644 --- a/tests/server/checker/runner/llm/execute.test.ts +++ b/tests/server/checker/runner/llm/execute.test.ts @@ -1,8 +1,15 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import type { ResolvedLlmTarget } from "../../../../../src/server/checker/runner/llm/types"; +import type { + RawLlmExpectConfig, + ResolvedLlmExpectConfig, + ResolvedLlmTarget, +} from "../../../../../src/server/checker/runner/llm/types"; import type { CheckerContext } from "../../../../../src/server/checker/runner/types"; +import { resolveContentExpectations } from "../../../../../src/server/checker/expect/content"; +import { resolveKeyedExpectations } from "../../../../../src/server/checker/expect/keyed"; +import { resolveValueExpectation } from "../../../../../src/server/checker/expect/value"; import { LlmChecker } from "../../../../../src/server/checker/runner/llm/execute"; const MOCK_PORT = 18456; @@ -14,13 +21,34 @@ function makeCtx(timeoutMs = 10000): CheckerContext { return { signal: controller.signal }; } -function makeTarget( - overrides?: Partial, - expectOverrides?: Partial, -): ResolvedLlmTarget { +function makeTarget(overrides?: Partial, rawExpect?: RawLlmExpectConfig): ResolvedLlmTarget { + const resolvedExpect: ResolvedLlmExpectConfig | undefined = rawExpect + ? { + durationMs: resolveValueExpectation(rawExpect.durationMs), + finishReason: resolveValueExpectation(rawExpect.finishReason), + headers: resolveKeyedExpectations(rawExpect.headers), + output: resolveContentExpectations(rawExpect.output), + rawFinishReason: resolveValueExpectation(rawExpect.rawFinishReason), + status: rawExpect.status ?? [200], + stream: rawExpect.stream + ? { + completed: rawExpect.stream.completed ?? true, + firstTokenMs: resolveValueExpectation(rawExpect.stream.firstTokenMs), + } + : undefined, + usage: rawExpect.usage + ? { + inputTokens: resolveValueExpectation(rawExpect.usage.inputTokens), + outputTokens: resolveValueExpectation(rawExpect.usage.outputTokens), + totalTokens: resolveValueExpectation(rawExpect.usage.totalTokens), + } + : undefined, + } + : undefined; + return { description: null, - expect: expectOverrides, + expect: resolvedExpect, group: "default", id: "test-llm", intervalMs: 30000, @@ -38,6 +66,7 @@ function makeTarget( ...overrides, }, name: null, + rawExpect, timeoutMs: 10000, type: "llm", }; diff --git a/tests/server/checker/runner/llm/output-expect.test.ts b/tests/server/checker/runner/llm/output-expect.test.ts index 1281dd7..0ba22f0 100644 --- a/tests/server/checker/runner/llm/output-expect.test.ts +++ b/tests/server/checker/runner/llm/output-expect.test.ts @@ -1,12 +1,32 @@ import { describe, expect, test } from "bun:test"; -import type { LlmCheckObservation } from "../../../../../src/server/checker/runner/llm/types"; +import type { + RawContentExpectations, + RawKeyedExpectations, + RawValueExpectation, +} from "../../../../../src/server/checker/expect/types"; +import type { LlmCheckObservation, ResolvedLlmExpectConfig } from "../../../../../src/server/checker/runner/llm/types"; -import { checkContentRules } from "../../../../../src/server/checker/expect/content"; +import { checkContentExpectations, resolveContentExpectations } from "../../../../../src/server/checker/expect/content"; +import { resolveKeyedExpectations } from "../../../../../src/server/checker/expect/keyed"; +import { resolveValueExpectation } from "../../../../../src/server/checker/expect/value"; import { runExpects } from "../../../../../src/server/checker/runner/llm/expect"; -function checkOutputRules(outputText: null | string, rules: Parameters[1]) { - return checkContentRules(outputText, rules, { path: "output", phase: "output" }); +interface RawLlmExpectInput { + finishReason?: RawValueExpectation; + headers?: RawKeyedExpectations; + output?: RawContentExpectations; + rawFinishReason?: RawValueExpectation; + status?: Array; + stream?: { completed?: boolean; firstTokenMs?: RawValueExpectation }; + usage?: { inputTokens?: RawValueExpectation; outputTokens?: RawValueExpectation; totalTokens?: RawValueExpectation }; +} + +function checkOutputRules(outputText: null | string, rawRules: RawContentExpectations | undefined) { + return checkContentExpectations(outputText, resolveContentExpectations(rawRules), { + path: "output", + phase: "output", + }); } function makeObservation(overrides?: Partial): LlmCheckObservation { @@ -25,7 +45,31 @@ function makeObservation(overrides?: Partial): LlmCheckObse }; } -describe("LLM output rules", () => { +function resolveLlmExpect(raw: RawLlmExpectInput | undefined): ResolvedLlmExpectConfig | undefined { + if (raw === undefined) return undefined; + return { + finishReason: resolveValueExpectation(raw.finishReason), + headers: resolveKeyedExpectations(raw.headers), + output: resolveContentExpectations(raw.output), + rawFinishReason: resolveValueExpectation(raw.rawFinishReason), + status: raw.status ?? [200], + stream: raw.stream + ? { + completed: raw.stream.completed ?? true, + firstTokenMs: resolveValueExpectation(raw.stream.firstTokenMs), + } + : undefined, + usage: raw.usage + ? { + inputTokens: resolveValueExpectation(raw.usage.inputTokens), + outputTokens: resolveValueExpectation(raw.usage.outputTokens), + totalTokens: resolveValueExpectation(raw.usage.totalTokens), + } + : undefined, + }; +} + +describe("LLM output expectations", () => { test("equals 严格匹配", () => { expect(checkOutputRules("OK", [{ equals: "OK" }]).matched).toBe(true); expect(checkOutputRules("OK\n", [{ equals: "OK" }]).matched).toBe(false); @@ -66,7 +110,7 @@ describe("LLM output rules", () => { expect(result.failure?.phase).toBe("output"); }); - test("undefined rules 返回通过", () => { + test("undefined expectations 返回通过", () => { expect(checkOutputRules("anything", undefined).matched).toBe(true); expect(checkOutputRules(null, undefined).matched).toBe(true); }); @@ -75,11 +119,14 @@ describe("LLM output rules", () => { describe("LLM runExpects", () => { test("全部 expect 通过", () => { const observation = makeObservation(); - const result = runExpects(observation, { - finishReason: { equals: "stop" }, - output: [{ contains: "OK" }], - status: [200], - }); + const result = runExpects( + observation, + resolveLlmExpect({ + finishReason: { equals: "stop" }, + output: [{ contains: "OK" }], + status: [200], + }), + ); expect(result.matched).toBe(true); expect(result.failure).toBeNull(); }); @@ -92,35 +139,35 @@ describe("LLM runExpects", () => { test("status 不匹配失败", () => { const observation = makeObservation(); - const result = runExpects(observation, { status: [404] }); + const result = runExpects(observation, resolveLlmExpect({ status: [404] })); expect(result.matched).toBe(false); expect(result.failure?.phase).toBe("status"); }); test("finishReason 不匹配失败", () => { const observation = makeObservation(); - const result = runExpects(observation, { finishReason: { equals: "length" } }); + const result = runExpects(observation, resolveLlmExpect({ finishReason: { equals: "length" } })); expect(result.matched).toBe(false); expect(result.failure?.phase).toBe("finishReason"); }); test("rawFinishReason 不匹配失败", () => { const observation = makeObservation(); - const result = runExpects(observation, { rawFinishReason: { equals: "end_turn" } }); + const result = runExpects(observation, resolveLlmExpect({ rawFinishReason: { equals: "end_turn" } })); expect(result.matched).toBe(false); expect(result.failure?.phase).toBe("rawFinishReason"); }); test("usage 不匹配失败", () => { const observation = makeObservation(); - const result = runExpects(observation, { usage: { totalTokens: { gte: 100 } } }); + const result = runExpects(observation, resolveLlmExpect({ usage: { totalTokens: { gte: 100 } } })); expect(result.matched).toBe(false); expect(result.failure?.phase).toBe("usage"); }); test("usage 匹配通过", () => { const observation = makeObservation(); - const result = runExpects(observation, { usage: { totalTokens: { lte: 20 } } }); + const result = runExpects(observation, resolveLlmExpect({ usage: { totalTokens: { lte: 20 } } })); expect(result.matched).toBe(true); }); @@ -129,9 +176,12 @@ describe("LLM runExpects", () => { mode: "stream", stream: { completed: true, firstTokenMs: 500 }, }); - const result = runExpects(observation, { - stream: { completed: true }, - }); + const result = runExpects( + observation, + resolveLlmExpect({ + stream: { completed: true }, + }), + ); expect(result.matched).toBe(true); }); @@ -140,9 +190,12 @@ describe("LLM runExpects", () => { mode: "stream", stream: { completed: true, firstTokenMs: 500 }, }); - const result = runExpects(observation, { - stream: { firstTokenMs: { lte: 1000 } }, - }); + const result = runExpects( + observation, + resolveLlmExpect({ + stream: { firstTokenMs: { lte: 1000 } }, + }), + ); expect(result.matched).toBe(true); }); @@ -151,9 +204,12 @@ describe("LLM runExpects", () => { mode: "stream", stream: { completed: true, firstTokenMs: null }, }); - const result = runExpects(observation, { - stream: { firstTokenMs: { lte: 1000 } }, - }); + const result = runExpects( + observation, + resolveLlmExpect({ + stream: { firstTokenMs: { lte: 1000 } }, + }), + ); expect(result.matched).toBe(false); expect(result.failure?.phase).toBe("stream"); }); @@ -162,9 +218,12 @@ describe("LLM runExpects", () => { const observation = makeObservation({ http: { headers: { "content-type": "application/json" }, status: 200, statusText: "OK" }, }); - const result = runExpects(observation, { - headers: { "content-type": "application/json" }, - }); + const result = runExpects( + observation, + resolveLlmExpect({ + headers: { "content-type": "application/json" }, + }), + ); expect(result.matched).toBe(true); }); @@ -172,19 +231,25 @@ describe("LLM runExpects", () => { const observation = makeObservation({ http: { headers: { "content-type": "text/plain" }, status: 200, statusText: "OK" }, }); - const result = runExpects(observation, { - headers: { "content-type": "application/json" }, - }); + const result = runExpects( + observation, + resolveLlmExpect({ + headers: { "content-type": "application/json" }, + }), + ); expect(result.matched).toBe(false); expect(result.failure?.phase).toBe("headers"); }); test("首个 expect 失败立即返回", () => { const observation = makeObservation(); - const result = runExpects(observation, { - output: [{ contains: "OK" }], - status: [404], - }); + const result = runExpects( + observation, + resolveLlmExpect({ + output: [{ contains: "OK" }], + status: [404], + }), + ); expect(result.matched).toBe(false); expect(result.failure?.phase).toBe("status"); }); @@ -196,7 +261,7 @@ describe("LLM runExpects", () => { outputText: null, usage: null, }); - const result = runExpects(observation, { status: [401] }); + const result = runExpects(observation, resolveLlmExpect({ status: [401] })); expect(result.matched).toBe(true); }); }); 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 f19723b..af3af31 100644 --- a/tests/server/checker/runner/llm/schema-validate-resolve.test.ts +++ b/tests/server/checker/runner/llm/schema-validate-resolve.test.ts @@ -214,7 +214,7 @@ describe("LlmChecker validate", () => { defaults: {}, targets: [makeRawTarget({ expect: { output: [{ contains: "y", json: { path: "$.status" } }] } })], }); - expect(issues.some((i) => i.code === "invalid-content-rule")).toBe(true); + expect(issues.some((i) => i.code === "invalid-content-expectation")).toBe(true); }); test("expect.output regex ReDoS 报错", () => { @@ -284,6 +284,44 @@ describe("LlmChecker resolve", () => { expect(resolved.group).toBe("default"); expect(resolved.intervalMs).toBe(30000); expect(resolved.timeoutMs).toBe(10000); + expect(resolved.rawExpect).toBeUndefined(); + expect(resolved.expect).toEqual({ status: [200] }); + }); + + test("stream mode 未配置 expect.stream 时不物化 completed", () => { + const raw = makeRawTarget({ + expect: { output: [{ contains: "OK" }] }, + llm: { + mode: "stream", + model: "gpt-4o-mini", + prompt: "Say OK", + provider: "openai", + url: "https://api.openai.com/v1", + }, + }); + + const resolved = asLlm(checker.resolve(raw, makeResolveContext())); + + expect(resolved.rawExpect).toEqual({ output: [{ contains: "OK" }] }); + expect(resolved.expect?.stream).toBeUndefined(); + }); + + test("配置 expect.stream 但省略 completed 时默认 true", () => { + const raw = makeRawTarget({ + expect: { stream: { firstTokenMs: 100 } }, + llm: { + mode: "stream", + model: "gpt-4o-mini", + prompt: "Say OK", + provider: "openai", + url: "https://api.openai.com/v1", + }, + }); + + const resolved = asLlm(checker.resolve(raw, makeResolveContext())); + + expect(resolved.rawExpect).toEqual({ stream: { firstTokenMs: 100 } }); + expect(resolved.expect?.stream).toEqual({ completed: true, firstTokenMs: { equals: 100 } }); }); test("defaults.llm 与 target.llm 浅合并", () => { diff --git a/tests/server/checker/runner/shared/body.test.ts b/tests/server/checker/runner/shared/body.test.ts index 43e1be8..1d4bc81 100644 --- a/tests/server/checker/runner/shared/body.test.ts +++ b/tests/server/checker/runner/shared/body.test.ts @@ -1,12 +1,15 @@ import { describe, expect, test } from "bun:test"; -import { checkContentRules } from "../../../../../src/server/checker/expect/content"; +import type { RawContentExpectations } from "../../../../../src/server/checker/expect/types"; -function checkBodyExpect(body: string, rules?: Parameters[1]) { - return checkContentRules(body, rules, { path: "body", phase: "body" }); +import { checkContentExpectations, resolveContentExpectations } from "../../../../../src/server/checker/expect/content"; + +function checkBodyExpect(body: string, rawExpectations?: RawContentExpectations) { + const resolved = resolveContentExpectations(rawExpectations); + return checkContentExpectations(body, resolved, { path: "body", phase: "body" }); } -describe("checkBodyExpect (BodyRule[])", () => { +describe("checkBodyExpect (ContentExpectations)", () => { test("无规则返回匹配成功", () => { const r = checkBodyExpect("anything"); expect(r.matched).toBe(true); @@ -19,6 +22,22 @@ describe("checkBodyExpect (BodyRule[])", () => { expect(r.failure).toBeNull(); }); + test("resolve 输出显式 kind union 并物化 extractor 默认 exists", () => { + expect( + resolveContentExpectations([ + { contains: "ok" }, + { json: { path: "$.status" } }, + { css: { attr: "content", selector: "meta[name=status]" } }, + { xpath: { path: "/root/status" } }, + ]), + ).toEqual([ + { kind: "value", matcher: { contains: "ok" } }, + { kind: "json", matcher: { exists: true }, path: "$.status" }, + { attr: "content", kind: "css", matcher: { exists: true }, selector: "meta[name=status]" }, + { kind: "xpath", matcher: { exists: true }, path: "/root/status" }, + ]); + }); + test("contains 规则匹配成功", () => { const r = checkBodyExpect("hello world", [{ contains: "hello" }]); expect(r.matched).toBe(true); diff --git a/tests/server/checker/runner/shared/duplicate-header-key.test.ts b/tests/server/checker/runner/shared/duplicate-header-key.test.ts new file mode 100644 index 0000000..6030cab --- /dev/null +++ b/tests/server/checker/runner/shared/duplicate-header-key.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test"; + +import type { CheckerValidationInput } from "../../../../../src/server/checker/runner/types"; + +import { validateDbConfig } from "../../../../../src/server/checker/runner/db/validate"; +import { validateHttpConfig } from "../../../../../src/server/checker/runner/http/validate"; +import { validateLlmConfig } from "../../../../../src/server/checker/runner/llm/validate"; + +function input(target: Record): CheckerValidationInput { + return { defaults: {}, targets: [target as CheckerValidationInput["targets"][number]] }; +} + +describe("HTTP/LLM headers reject case-insensitive duplicate keys", () => { + test("HTTP headers 大小写不同的重复 key 报错", () => { + const target = { + expect: { headers: { "Content-Type": "application/json", "content-type": "text/plain" } }, + http: { url: "https://example.com" }, + id: "dup", + type: "http", + }; + + const issues = validateHttpConfig(input(target)); + expect(issues.some((i) => i.code === "duplicate-key" && i.path.includes("headers"))).toBe(true); + }); + + test("LLM headers 大小写不同的重复 key 报错", () => { + const target = { + expect: { headers: { "X-Trace": "a", "x-trace": "b" } }, + id: "dup", + llm: { + mode: "stream", + model: "test-model", + prompt: "hello", + provider: "openai", + url: "https://example.com/v1/chat/completions", + }, + type: "llm", + }; + + const issues = validateLlmConfig(input(target)); + expect(issues.some((i) => i.code === "duplicate-key" && i.path.includes("headers"))).toBe(true); + }); + + test("HTTP headers 不同 key 不触发 duplicate-key", () => { + const target = { + expect: { headers: { Accept: "application/json", "Content-Type": "application/json" } }, + http: { url: "https://example.com" }, + id: "ok", + type: "http", + }; + + expect(validateHttpConfig(input(target)).some((i) => i.code === "duplicate-key")).toBe(false); + }); + + test("DB rows 保留大小写敏感不触发 duplicate-key", () => { + const target = { + db: { query: "SELECT 1", url: "sqlite://:memory:" }, + expect: { rows: [{ Name: "a", name: "b" }] }, + id: "dup-rows", + type: "db", + }; + + expect(validateDbConfig(input(target)).some((i) => i.code === "duplicate-key")).toBe(false); + }); +}); diff --git a/tests/server/checker/runner/shared/duration.test.ts b/tests/server/checker/runner/shared/duration.test.ts index 037fbe0..26d858c 100644 --- a/tests/server/checker/runner/shared/duration.test.ts +++ b/tests/server/checker/runner/shared/duration.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { checkValueMatcher } from "../../../../../src/server/checker/expect/matcher"; +import { checkValueExpectation } from "../../../../../src/server/checker/expect/value"; function checkDuration(durationMs: number, maxDurationMs?: number) { - return checkValueMatcher(durationMs, maxDurationMs === undefined ? undefined : { lte: maxDurationMs }, { + return checkValueExpectation(durationMs, maxDurationMs === undefined ? undefined : { lte: maxDurationMs }, { path: "durationMs", phase: "duration", }); diff --git a/tests/server/checker/runner/shared/keyed.test.ts b/tests/server/checker/runner/shared/keyed.test.ts new file mode 100644 index 0000000..66e0f39 --- /dev/null +++ b/tests/server/checker/runner/shared/keyed.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; + +import { checkKeyedExpectations, resolveKeyedExpectations } from "../../../../../src/server/checker/expect/keyed"; + +describe("KeyedExpectations", () => { + test("resolve 将 Raw Record 转为保持顺序的有序数组", () => { + expect(resolveKeyedExpectations({ Count: { gte: 1 }, Name: "alice" })).toEqual([ + { key: "Count", matcher: { gte: 1 } }, + { key: "Name", matcher: { equals: "alice" } }, + ]); + }); + + test("failure.path 包含基础路径和原始 key", () => { + const result = checkKeyedExpectations( + { "content-type": "text/plain" }, + resolveKeyedExpectations({ "Content-Type": { contains: "json" } }), + { normalizeKey: (key) => key.toLowerCase(), path: "headers", phase: "headers" }, + ); + + expect(result.matched).toBe(false); + expect(result.failure?.path).toBe("headers.Content-Type"); + }); + + test("DB rows 默认保持大小写敏感匹配", () => { + const expectations = resolveKeyedExpectations({ Name: "alice", name: "bob" }); + + expect( + checkKeyedExpectations({ Name: "alice", name: "bob" }, expectations, { path: "rows[0]", phase: "rows" }).matched, + ).toBe(true); + expect( + checkKeyedExpectations({ Name: "bob", name: "alice" }, expectations, { path: "rows[0]", phase: "rows" }).matched, + ).toBe(false); + }); +}); diff --git a/tests/server/checker/runner/shared/operator.test.ts b/tests/server/checker/runner/shared/operator.test.ts index aebd1b3..d1fb3fc 100644 --- a/tests/server/checker/runner/shared/operator.test.ts +++ b/tests/server/checker/runner/shared/operator.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { applyMatcher, checkExpectValue, evaluateJsonPath } from "../../../../../src/server/checker/expect/matcher"; +import { + applyValueMatcher, + checkValueExpectation, + displayValueExpectation, + evaluateJsonPath, + resolveValueExpectation, +} from "../../../../../src/server/checker/expect/value"; describe("evaluateJsonPath", () => { const obj = { @@ -55,97 +61,115 @@ describe("evaluateJsonPath", () => { }); }); -describe("applyMatcher", () => { +describe("applyValueMatcher", () => { test("equals 操作符", () => { - expect(applyMatcher("ok", { equals: "ok" })).toBe(true); - expect(applyMatcher("ok", { equals: "error" })).toBe(false); - expect(applyMatcher(42, { equals: 42 })).toBe(true); - expect(applyMatcher(42, { equals: 41 })).toBe(false); - expect(applyMatcher(null, { equals: null })).toBe(true); - expect(applyMatcher(true, { equals: true })).toBe(true); + expect(applyValueMatcher("ok", { equals: "ok" })).toBe(true); + expect(applyValueMatcher("ok", { equals: "error" })).toBe(false); + expect(applyValueMatcher(42, { equals: 42 })).toBe(true); + expect(applyValueMatcher(42, { equals: 41 })).toBe(false); + expect(applyValueMatcher(null, { equals: null })).toBe(true); + expect(applyValueMatcher(true, { equals: true })).toBe(true); }); test("equals 支持 JSON 对象和数组", () => { - expect(applyMatcher({ status: "ok" }, { equals: { status: "ok" } })).toBe(true); - expect(applyMatcher({ status: "ok" }, { equals: { status: "fail" } })).toBe(false); - expect(applyMatcher(["a", "b"], { equals: ["a", "b"] })).toBe(true); - expect(applyMatcher(["a", "b"], { equals: ["b", "a"] })).toBe(false); + expect(applyValueMatcher({ status: "ok" }, { equals: { status: "ok" } })).toBe(true); + expect(applyValueMatcher({ status: "ok" }, { equals: { status: "fail" } })).toBe(false); + expect(applyValueMatcher(["a", "b"], { equals: ["a", "b"] })).toBe(true); + expect(applyValueMatcher(["a", "b"], { equals: ["b", "a"] })).toBe(false); }); test("contains 操作符", () => { - expect(applyMatcher("hello world", { contains: "hello" })).toBe(true); - expect(applyMatcher("hello world", { contains: "missing" })).toBe(false); - expect(applyMatcher(12345, { contains: "23" })).toBe(true); + expect(applyValueMatcher("hello world", { contains: "hello" })).toBe(true); + expect(applyValueMatcher("hello world", { contains: "missing" })).toBe(false); + expect(applyValueMatcher(12345, { contains: "23" })).toBe(true); }); test("regex matcher", () => { - expect(applyMatcher("v2.1.0", { regex: "\\d+\\.\\d+\\.\\d+" })).toBe(true); - expect(applyMatcher("v2.1", { regex: "\\d+\\.\\d+\\.\\d+" })).toBe(false); - expect(applyMatcher("abc123", { regex: "^\\w+\\d+$" })).toBe(true); + expect(applyValueMatcher("v2.1.0", { regex: "\\d+\\.\\d+\\.\\d+" })).toBe(true); + expect(applyValueMatcher("v2.1", { regex: "\\d+\\.\\d+\\.\\d+" })).toBe(false); + expect(applyValueMatcher("abc123", { regex: "^\\w+\\d+$" })).toBe(true); }); test("empty 操作符", () => { - expect(applyMatcher("", { empty: true })).toBe(true); - expect(applyMatcher(null, { empty: true })).toBe(true); - expect(applyMatcher(undefined, { empty: true })).toBe(true); - expect(applyMatcher([], { empty: true })).toBe(true); - expect(applyMatcher({}, { empty: true })).toBe(true); - expect(applyMatcher("ok", { empty: true })).toBe(false); - expect(applyMatcher(0, { empty: true })).toBe(false); - expect(applyMatcher(false, { empty: true })).toBe(false); - expect(applyMatcher([1, 2], { empty: false })).toBe(true); - expect(applyMatcher([], { empty: false })).toBe(false); + expect(applyValueMatcher("", { empty: true })).toBe(true); + expect(applyValueMatcher(null, { empty: true })).toBe(true); + expect(applyValueMatcher(undefined, { empty: true })).toBe(true); + expect(applyValueMatcher([], { empty: true })).toBe(true); + expect(applyValueMatcher({}, { empty: true })).toBe(true); + expect(applyValueMatcher("ok", { empty: true })).toBe(false); + expect(applyValueMatcher(0, { empty: true })).toBe(false); + expect(applyValueMatcher(false, { empty: true })).toBe(false); + expect(applyValueMatcher([1, 2], { empty: false })).toBe(true); + expect(applyValueMatcher([], { empty: false })).toBe(false); }); test("exists 操作符", () => { - expect(applyMatcher("ok", { exists: true })).toBe(true); - expect(applyMatcher(null, { exists: true })).toBe(true); - expect(applyMatcher(undefined, { exists: true })).toBe(false); - expect(applyMatcher(undefined, { exists: false })).toBe(true); - expect(applyMatcher("ok", { exists: false })).toBe(false); + expect(applyValueMatcher("ok", { exists: true })).toBe(true); + expect(applyValueMatcher(null, { exists: true })).toBe(true); + expect(applyValueMatcher(undefined, { exists: true })).toBe(false); + expect(applyValueMatcher(undefined, { exists: false })).toBe(true); + expect(applyValueMatcher("ok", { exists: false })).toBe(false); }); test("gte 操作符", () => { - expect(applyMatcher(10, { gte: 5 })).toBe(true); - expect(applyMatcher(5, { gte: 5 })).toBe(true); - expect(applyMatcher(3, { gte: 5 })).toBe(false); - expect(applyMatcher("10", { gte: 5 })).toBe(true); + expect(applyValueMatcher(10, { gte: 5 })).toBe(true); + expect(applyValueMatcher(5, { gte: 5 })).toBe(true); + expect(applyValueMatcher(3, { gte: 5 })).toBe(false); + expect(applyValueMatcher("10", { gte: 5 })).toBe(true); }); test("lte 操作符", () => { - expect(applyMatcher(3, { lte: 5 })).toBe(true); - expect(applyMatcher(5, { lte: 5 })).toBe(true); - expect(applyMatcher(10, { lte: 5 })).toBe(false); + expect(applyValueMatcher(3, { lte: 5 })).toBe(true); + expect(applyValueMatcher(5, { lte: 5 })).toBe(true); + expect(applyValueMatcher(10, { lte: 5 })).toBe(false); }); test("gt 操作符", () => { - expect(applyMatcher(10, { gt: 5 })).toBe(true); - expect(applyMatcher(5, { gt: 5 })).toBe(false); + expect(applyValueMatcher(10, { gt: 5 })).toBe(true); + expect(applyValueMatcher(5, { gt: 5 })).toBe(false); }); test("lt 操作符", () => { - expect(applyMatcher(3, { lt: 5 })).toBe(true); - expect(applyMatcher(5, { lt: 5 })).toBe(false); + expect(applyValueMatcher(3, { lt: 5 })).toBe(true); + expect(applyValueMatcher(5, { lt: 5 })).toBe(false); }); test("多操作符 AND 组合", () => { - expect(applyMatcher(7, { gte: 5, lte: 10 })).toBe(true); - expect(applyMatcher(3, { gte: 5, lte: 10 })).toBe(false); - expect(applyMatcher(15, { gte: 5, lte: 10 })).toBe(false); + expect(applyValueMatcher(7, { gte: 5, lte: 10 })).toBe(true); + expect(applyValueMatcher(3, { gte: 5, lte: 10 })).toBe(false); + expect(applyValueMatcher(15, { gte: 5, lte: 10 })).toBe(false); }); }); -describe("checkExpectValue", () => { - test("原始值直接比较", () => { - expect(checkExpectValue("ok", "ok")).toBe(true); - expect(checkExpectValue("ok", "error")).toBe(false); - expect(checkExpectValue(42, 42)).toBe(true); - expect(checkExpectValue(null, null)).toBe(true); +describe("resolveValueExpectation", () => { + test("原始值解析为 equals matcher", () => { + expect(resolveValueExpectation("ok")).toEqual({ equals: "ok" }); + expect(resolveValueExpectation(42)).toEqual({ equals: 42 }); + expect(resolveValueExpectation(null)).toEqual({ equals: null }); + expect(resolveValueExpectation(true)).toEqual({ equals: true }); }); - test("对象作为操作符", () => { - expect(checkExpectValue(42, { gte: 10 })).toBe(true); - expect(checkExpectValue(42, { gte: 100 })).toBe(false); - expect(checkExpectValue("hello", { contains: "ell" })).toBe(true); + test("对象 matcher 原样保留", () => { + const matcher = { gte: 10 }; + expect(resolveValueExpectation(matcher)).toBe(matcher); + expect(resolveValueExpectation({ contains: "ell" })).toEqual({ contains: "ell" }); + }); + + test("undefined 返回 undefined", () => { + expect(resolveValueExpectation(undefined)).toBeUndefined(); + }); + + test("failure expected 使用用户可读的 equals 值", () => { + const result = checkValueExpectation("actual", resolveValueExpectation("expected"), { + path: "finishReason", + phase: "finishReason", + }); + + expect(result.matched).toBe(false); + expect(result.failure?.expected).toBe("expected"); + }); + + test("displayValueExpectation 保留多 matcher 对象", () => { + expect(displayValueExpectation({ contains: "ok", regex: "^ok$" })).toEqual({ contains: "ok", regex: "^ok$" }); }); }); diff --git a/tests/server/checker/runner/shared/text.test.ts b/tests/server/checker/runner/shared/text.test.ts index 1b97697..e903827 100644 --- a/tests/server/checker/runner/shared/text.test.ts +++ b/tests/server/checker/runner/shared/text.test.ts @@ -1,9 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { checkContentRules } from "../../../../../src/server/checker/expect/content"; +import type { RawContentExpectations } from "../../../../../src/server/checker/expect/types"; -function checkTextRules(text: string, rules: Parameters[1], phase: string) { - return checkContentRules(text, rules, { path: phase, phase }); +import { checkContentExpectations, resolveContentExpectations } from "../../../../../src/server/checker/expect/content"; + +function checkTextRules(text: string, rawRules: RawContentExpectations, phase: string) { + const resolved = resolveContentExpectations(rawRules); + return checkContentExpectations(text, resolved, { path: phase, phase }); } describe("checkTextRules", () => { diff --git a/tests/server/checker/runner/shared/value-matcher-shorthand.test.ts b/tests/server/checker/runner/shared/value-matcher-shorthand.test.ts index ce120f4..f0a1484 100644 --- a/tests/server/checker/runner/shared/value-matcher-shorthand.test.ts +++ b/tests/server/checker/runner/shared/value-matcher-shorthand.test.ts @@ -15,7 +15,7 @@ function input(target: Record): CheckerValidationInput { } describe("ValueMatcher primitive shorthand in checker validators", () => { - test("normalizes shorthand for all checker ValueMatcher fields", () => { + test("accepts shorthand for all checker ValueMatcher fields", () => { const targets = [ { expect: { durationMs: 100 }, @@ -82,9 +82,10 @@ describe("ValueMatcher primitive shorthand in checker validators", () => { for (const target of targets) { const { validate, ...config } = target; + const original = structuredClone(config); expect(validate(input(config))).toHaveLength(0); - expect((config.expect as Record)["durationMs"]).toEqual({ equals: 100 }); + expect(config).toEqual(original); } }); diff --git a/tests/server/checker/runner/tcp/execute.test.ts b/tests/server/checker/runner/tcp/execute.test.ts index a480e35..9e5a1fa 100644 --- a/tests/server/checker/runner/tcp/execute.test.ts +++ b/tests/server/checker/runner/tcp/execute.test.ts @@ -1,8 +1,14 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import type { ResolvedTcpTarget } from "../../../../../src/server/checker/runner/tcp/types"; +import type { + RawTcpExpectConfig, + ResolvedTcpExpectConfig, + ResolvedTcpTarget, +} from "../../../../../src/server/checker/runner/tcp/types"; import type { CheckerContext } from "../../../../../src/server/checker/runner/types"; +import { resolveContentExpectations } from "../../../../../src/server/checker/expect/content"; +import { resolveValueExpectation } from "../../../../../src/server/checker/expect/value"; import { TcpChecker } from "../../../../../src/server/checker/runner/tcp/execute"; const checker = new TcpChecker(); @@ -20,13 +26,27 @@ function makeCtx(timeoutMs = 5000): CheckerContext { return { signal: controller.signal }; } -function makeTarget(tcp: Partial, overrides?: Partial): ResolvedTcpTarget { +function makeTarget( + tcp: Partial, + overrides?: { expect?: RawTcpExpectConfig }, +): ResolvedTcpTarget { + const raw = overrides?.expect; + const resolvedExpect: ResolvedTcpExpectConfig | undefined = raw + ? { + banner: resolveContentExpectations(raw.banner), + connected: raw.connected ?? true, + durationMs: resolveValueExpectation(raw.durationMs), + } + : undefined; + return { description: null, + expect: resolvedExpect, group: "default", id: "test-tcp", intervalMs: 60000, name: "test-tcp", + rawExpect: raw, tcp: { bannerReadTimeout: 2000, host: "127.0.0.1", @@ -37,7 +57,6 @@ function makeTarget(tcp: Partial, overrides?: Partial< }, timeoutMs: 5000, type: "tcp", - ...overrides, }; } @@ -295,6 +314,8 @@ describe("TcpChecker resolve", () => { expect(target.name).toBeNull(); expect(target.intervalMs).toBe(30000); expect(target.timeoutMs).toBe(10000); + expect(target.rawExpect).toBeUndefined(); + expect(target.expect).toEqual({ connected: true }); }); test("bannerReadTimeout 和 maxBannerBytes 支持 per-target 覆盖", () => { @@ -356,7 +377,12 @@ describe("TcpChecker resolve", () => { }, { configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 }, ); - expect(target.expect).toEqual({ banner: [{ contains: "ESMTP" }], connected: false, durationMs: { lte: 5000 } }); + expect(target.expect).toEqual({ + banner: [{ kind: "value", matcher: { contains: "ESMTP" } }], + connected: false, + durationMs: { lte: 5000 }, + }); + expect(target.rawExpect).toEqual({ banner: [{ contains: "ESMTP" }], connected: false, durationMs: { lte: 5000 } }); }); test("name 和 group 解析", () => { diff --git a/tests/server/checker/runner/tcp/expect.test.ts b/tests/server/checker/runner/tcp/expect.test.ts index 329a990..b76743e 100644 --- a/tests/server/checker/runner/tcp/expect.test.ts +++ b/tests/server/checker/runner/tcp/expect.test.ts @@ -1,7 +1,13 @@ import { describe, expect, test } from "bun:test"; +import type { ContentExpectations } from "../../../../../src/server/checker/expect/types"; + import { checkBanner, checkConnected } from "../../../../../src/server/checker/runner/tcp/expect"; +function value(matcher: ContentExpectations[number]["matcher"]): ContentExpectations[number] { + return { kind: "value", matcher }; +} + describe("checkConnected", () => { test("connected=true 期望 true 匹配", () => { const result = checkConnected(true, true); @@ -32,34 +38,34 @@ describe("checkConnected", () => { describe("checkBanner", () => { test("contains 匹配", () => { - const result = checkBanner("220 smtp.example.com ESMTP", [{ contains: "ESMTP" }]); + const result = checkBanner("220 smtp.example.com ESMTP", [value({ contains: "ESMTP" })]); expect(result.matched).toBe(true); }); test("contains 不匹配", () => { - const result = checkBanner("220 smtp.example.com ESMTP", [{ contains: "POSTFIX" }]); + const result = checkBanner("220 smtp.example.com ESMTP", [value({ contains: "POSTFIX" })]); expect(result.matched).toBe(false); expect(result.failure!.kind).toBe("mismatch"); expect(result.failure!.phase).toBe("banner"); }); test("regex 正则匹配", () => { - const result = checkBanner("220 smtp.example.com ESMTP", [{ regex: "^220" }]); + const result = checkBanner("220 smtp.example.com ESMTP", [value({ regex: "^220" })]); expect(result.matched).toBe(true); }); test("空 banner 与 contains 空字符串", () => { - const result = checkBanner("", [{ contains: "" }]); + const result = checkBanner("", [value({ contains: "" })]); expect(result.matched).toBe(true); }); test("多 operator 同时匹配", () => { - const result = checkBanner("220 ESMTP", [{ contains: "ESMTP", regex: "^220" }]); + const result = checkBanner("220 ESMTP", [value({ contains: "ESMTP", regex: "^220" })]); expect(result.matched).toBe(true); }); test("多 operator 部分不匹配", () => { - const result = checkBanner("220 ESMTP", [{ contains: "ESMTP", regex: "^250" }]); + const result = checkBanner("220 ESMTP", [value({ contains: "ESMTP", regex: "^250" })]); expect(result.matched).toBe(false); }); }); diff --git a/tests/server/checker/runner/udp/execute.test.ts b/tests/server/checker/runner/udp/execute.test.ts index 86b1540..58aa0f6 100644 --- a/tests/server/checker/runner/udp/execute.test.ts +++ b/tests/server/checker/runner/udp/execute.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from "bun:test"; -import type { ResolvedUdpTarget, UdpExpectConfig } from "../../../../../src/server/checker/runner/udp/types"; +import type { + RawUdpExpectConfig, + ResolvedUdpExpectConfig, + ResolvedUdpTarget, +} from "../../../../../src/server/checker/runner/udp/types"; +import { resolveContentExpectations } from "../../../../../src/server/checker/expect/content"; +import { resolveValueExpectation } from "../../../../../src/server/checker/expect/value"; import { UdpChecker } from "../../../../../src/server/checker/runner/udp/execute"; async function createEchoServer(): Promise<{ close: () => void; port: number }> { @@ -27,14 +33,26 @@ function makeSignal(timeoutMs: number): { cleanup: () => void; signal: AbortSign return { cleanup: () => clearTimeout(timer), signal: controller.signal }; } -function makeTarget(overrides: Partial = {}, expect?: UdpExpectConfig): ResolvedUdpTarget { +function makeTarget(overrides: Partial = {}, raw?: RawUdpExpectConfig): ResolvedUdpTarget { + const resolvedExpect: ResolvedUdpExpectConfig | undefined = raw + ? { + durationMs: resolveValueExpectation(raw.durationMs), + responded: raw.responded ?? true, + response: resolveContentExpectations(raw.response), + responseSize: resolveValueExpectation(raw.responseSize), + sourceHost: resolveValueExpectation(raw.sourceHost), + sourcePort: resolveValueExpectation(raw.sourcePort), + } + : undefined; + return { description: null, - expect, + expect: resolvedExpect, group: "default", id: "test-udp", intervalMs: 30000, name: null, + rawExpect: raw, timeoutMs: 10000, type: "udp", udp: { @@ -322,6 +340,8 @@ describe("UdpChecker resolve", () => { expect(target.udp.encoding).toBe("text"); expect(target.udp.responseEncoding).toBe("text"); expect(target.udp.maxResponseBytes).toBe(4096); + expect(target.rawExpect).toBeUndefined(); + expect(target.expect).toEqual({ responded: true }); }); it("should use defaults.udp for missing fields", () => { diff --git a/tests/server/checker/runner/udp/expect.test.ts b/tests/server/checker/runner/udp/expect.test.ts index 753bce9..cc359b9 100644 --- a/tests/server/checker/runner/udp/expect.test.ts +++ b/tests/server/checker/runner/udp/expect.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "bun:test"; +import type { ContentExpectations } from "../../../../../src/server/checker/expect/types"; + import { checkResponded, checkResponseSize, @@ -8,6 +10,10 @@ import { checkSourcePort, } from "../../../../../src/server/checker/runner/udp/expect"; +function value(matcher: ContentExpectations[number]["matcher"]): ContentExpectations[number] { + return { kind: "value", matcher }; +} + describe("checkResponded", () => { it("responded=true 期望 true → 匹配", () => { const result = checkResponded(true, true); @@ -63,26 +69,26 @@ describe("checkResponseSize", () => { describe("checkResponseText", () => { it("单条 contains 匹配", () => { - const result = checkResponseText("PONG", [{ contains: "PONG" }]); + const result = checkResponseText("PONG", [value({ contains: "PONG" })]); expect(result.matched).toBe(true); expect(result.failure).toBeNull(); }); it("单条 contains 不匹配,phase=response", () => { - const result = checkResponseText("PING", [{ contains: "PONG" }]); + const result = checkResponseText("PING", [value({ contains: "PONG" })]); expect(result.matched).toBe(false); expect(result.failure!.kind).toBe("mismatch"); expect(result.failure!.phase).toBe("response"); }); it("多条规则全部匹配", () => { - const result = checkResponseText("hello world", [{ contains: "hello" }, { regex: "^hello" }]); + const result = checkResponseText("hello world", [value({ contains: "hello" }), value({ regex: "^hello" })]); expect(result.matched).toBe(true); expect(result.failure).toBeNull(); }); it("多条规则第二条失败 → 不匹配", () => { - const result = checkResponseText("hello world", [{ contains: "hello" }, { regex: "^world" }]); + const result = checkResponseText("hello world", [value({ contains: "hello" }), value({ regex: "^world" })]); expect(result.matched).toBe(false); expect(result.failure!.phase).toBe("response"); expect(result.failure!.path).toBe("response[1]"); diff --git a/tests/server/checker/store.test.ts b/tests/server/checker/store.test.ts index 50a9471..a298727 100644 --- a/tests/server/checker/store.test.ts +++ b/tests/server/checker/store.test.ts @@ -26,7 +26,7 @@ beforeAll(() => { const httpTarget: ResolvedHttpTarget = { description: null, - expect: { durationMs: { lte: 3000 }, status: [200] }, + expect: { body: [{ kind: "value", matcher: { contains: "ok" } }], durationMs: { equals: 3000 }, status: [200] }, group: "default", http: { headers: { Accept: "application/json" }, @@ -39,6 +39,7 @@ const httpTarget: ResolvedHttpTarget = { id: "test-http", intervalMs: 30000, name: "test-http", + rawExpect: { body: [{ contains: "ok" }], durationMs: 3000 }, timeoutMs: 10000, type: "http", }; @@ -106,7 +107,7 @@ describe("ProbeStore", () => { expect(config.maxRedirects).toBe(0); expect(t.interval_ms).toBe(30000); expect(t.timeout_ms).toBe(10000); - expect(JSON.parse(t.expect!)).toEqual({ durationMs: { lte: 3000 }, status: [200] }); + expect(JSON.parse(t.expect!)).toEqual({ body: [{ contains: "ok" }], durationMs: 3000 }); }); test("cmd target 字段正确", () => { diff --git a/tests/web/constants/target-table-columns.test.ts b/tests/web/constants/target-table-columns.test.ts index 98616ee..0aaae8b 100644 --- a/tests/web/constants/target-table-columns.test.ts +++ b/tests/web/constants/target-table-columns.test.ts @@ -103,7 +103,7 @@ describe("createTargetTableColumns", () => { test("延迟列超过 9999ms 时显示上限文案", () => { const latencyColumn = getColumn(createTargetTableColumns(["http"]), "latestCheck.durationMs"); const renderCell = latencyColumn.cell as (params: PrimaryTableCellParams) => { - props: { children: string; className: string }; + props: { children: string[]; className: string }; }; const element = renderCell({ col: latencyColumn, @@ -133,7 +133,7 @@ describe("createTargetTableColumns", () => { test("延迟列正常值包含 ms 单位", () => { const latencyColumn = getColumn(createTargetTableColumns(["http"]), "latestCheck.durationMs"); const renderCell = latencyColumn.cell as (params: PrimaryTableCellParams) => { - props: { children: string; className: string }; + props: { children: string[]; className: string }; }; const element = renderCell({ col: latencyColumn,