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:
@@ -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;
|
||||
|
||||
14
src/server/checker/expect/headers.ts
Normal file
14
src/server/checker/expect/headers.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
46
src/server/checker/expect/keyed.ts
Normal file
46
src/server/checker/expect/keyed.ts
Normal 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),
|
||||
}));
|
||||
}
|
||||
7
src/server/checker/expect/keys.ts
Normal file
7
src/server/checker/expect/keys.ts
Normal 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);
|
||||
@@ -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;
|
||||
}
|
||||
27
src/server/checker/expect/status.ts
Normal file
27
src/server/checker/expect/status.ts
Normal 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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
Reference in New Issue
Block a user