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

@@ -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<ResolvedHttpTarget> {
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<ResolvedHttpTarget> {
);
}
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<ResolvedHttpTarget> {
);
}
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<ResolvedHttpTarget> {
);
}
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<ResolvedHttpTarget> {
}
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<ResolvedHttpTarget> {
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<ResolvedHttpTarget> {
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",
),
};
}