1
0

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +0,0 @@
import type { ValueMatcherPrimitive } from "./types";
import { isValueMatcherObject } from "./matcher";
export function isValueMatcherPrimitive(value: unknown): value is ValueMatcherPrimitive {
return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
}
export function normalizeExpectMatchers(expect: Record<string, unknown>, keys: string[]): void {
for (const key of keys) {
if (key in expect) {
expect[key] = normalizeValueMatcher(expect[key]);
}
}
}
export function normalizeValueMatcher(value: unknown): unknown {
if (value === undefined) return undefined;
if (isValueMatcherObject(value)) return value;
if (isValueMatcherPrimitive(value)) return { equals: value };
return value;
}

View File

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

View File

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

View File

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

View File

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