1
0

refactor: 引入 Checker 统一接口与 Runner 抽象机制

定义 Checker 接口(resolve/execute/serialize)和 CheckerRegistry
注册中心,消除 engine/config-loader/store 中硬编码类型分支。
按 checker 类型分子包(runner/http/、runner/command/),提取
共享 expect 到 runner/shared/。超时控制通过引擎注入 AbortSignal。
CheckFailure.phase 从联合类型改为 string。配置校验下沉到各
Checker.resolve() 内部。

新增 checker-runner-abstraction spec,更新 DEVELOPMENT.md。
This commit is contained in:
2026-05-12 17:08:57 +08:00
parent e1c33b4002
commit ce8baae3d1
41 changed files with 1493 additions and 1395 deletions

View File

@@ -0,0 +1,95 @@
import type { HeaderExpect, HttpExpectConfig } from "../../types";
import { mismatchFailure, errorFailure } from "../shared/failure";
import { applyOperator } from "../shared/operator";
import { checkDuration } from "../shared/duration";
import { checkBodyExpect } from "../shared/body";
import type { ExpectResult } from "../shared/duration";
export function checkStatus(statusCode: number, allowed: number[]): ExpectResult {
if (!allowed.includes(statusCode)) {
return {
matched: false,
failure: mismatchFailure(
"status",
"status",
allowed,
statusCode,
`status ${statusCode} not in [${allowed}]`,
),
};
}
return { matched: true, failure: null };
}
export function checkHeaders(
headers: Record<string, string>,
headerExpects?: Record<string, HeaderExpect>,
): ExpectResult {
if (!headerExpects) return { matched: true, failure: null };
for (const [key, expected] of Object.entries(headerExpects)) {
const actualValue = headers[key.toLowerCase()];
const path = `headers.${key}`;
if (typeof expected === "string") {
if (actualValue !== expected) {
return {
matched: false,
failure: mismatchFailure("headers", path, expected, actualValue, `header ${key} mismatch`),
};
}
} else {
if (actualValue === undefined) {
if (expected.exists !== false) {
return {
matched: false,
failure: mismatchFailure("headers", path, "defined", undefined, `header ${key} not found`),
};
}
continue;
}
if (!applyOperator(actualValue, expected)) {
return {
matched: false,
failure: mismatchFailure("headers", path, expected, actualValue, `header ${key} mismatch`),
};
}
}
}
return { matched: true, failure: null };
}
export function checkHttpExpect(
statusCode: number,
headers: Record<string, string>,
body: string | null,
durationMs: number,
expect?: HttpExpectConfig,
): ExpectResult {
if (!expect) {
return checkStatus(statusCode, [200]);
}
const statusResult = checkStatus(statusCode, expect.status ?? [200]);
if (!statusResult.matched) return statusResult;
const durationResult = checkDuration(durationMs, expect.maxDurationMs);
if (!durationResult.matched) return durationResult;
const headersResult = checkHeaders(headers, expect.headers);
if (!headersResult.matched) return headersResult;
if (expect.body && expect.body.length > 0) {
if (body === null) {
return {
matched: false,
failure: errorFailure("body", "body", "body is null but body rules are configured"),
};
}
const bodyResult = checkBodyExpect(body, expect.body);
if (!bodyResult.matched) return bodyResult;
}
return { matched: true, failure: null };
}

View File

@@ -0,0 +1,145 @@
import type { CheckResult } from "../../types";
import { isError } from "es-toolkit";
import type {
Checker,
CheckerContext,
ResolveContext,
} from "../types";
import type {
HttpExpectConfig,
HttpTargetConfig,
ResolvedHttpTarget,
ResolvedTarget,
TargetConfig,
} from "../../types";
import { parseSize } from "../../size";
import { checkHttpExpect } from "./expect";
import { errorFailure } from "../shared/failure";
export class HttpChecker implements Checker {
readonly type = "http";
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget {
const t = target as TargetConfig & { type: "http"; http: HttpTargetConfig };
const httpDefaults = context.defaults.http;
if (!t.http.url || t.http.url.trim() === "") {
throw new Error(`target "${t.name}" 缺少 http.url 字段`);
}
const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB");
return {
type: "http",
name: t.name,
group: target.group ?? "default",
http: {
url: t.http.url,
method: t.http.method ?? httpDefaults?.method ?? "GET",
headers: { ...(httpDefaults?.headers ?? {}), ...(t.http.headers ?? {}) },
body: t.http.body,
maxBodyBytes,
},
intervalMs: context.defaultIntervalMs,
timeoutMs: context.defaultTimeoutMs,
expect: target.expect as HttpExpectConfig | undefined,
} satisfies ResolvedHttpTarget;
}
async execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult> {
const t = target as ResolvedHttpTarget;
const timestamp = new Date().toISOString();
try {
const start = performance.now();
const response = await fetch(t.http.url, {
method: t.http.method,
headers: t.http.headers,
body: t.http.method !== "GET" && t.http.method !== "HEAD" ? t.http.body : undefined,
signal: ctx.signal,
});
const durationMs = Math.round(performance.now() - start);
const statusCode = response.status;
const responseHeaders = Object.fromEntries(response.headers);
const hasBodyRules = !!(t.expect?.body && t.expect.body.length > 0);
const preBodyExpect = t.expect
? { status: t.expect.status, maxDurationMs: t.expect.maxDurationMs, headers: t.expect.headers }
: undefined;
const preBodyResult = checkHttpExpect(statusCode, responseHeaders, null, durationMs, preBodyExpect);
if (!hasBodyRules || !preBodyResult.matched) {
return {
targetName: t.name,
timestamp,
matched: preBodyResult.matched,
durationMs,
statusDetail: `HTTP ${statusCode}`,
failure: preBodyResult.failure,
};
}
const bodyBuffer = await response.arrayBuffer();
if (bodyBuffer.byteLength > t.http.maxBodyBytes) {
return {
targetName: t.name,
timestamp,
matched: false,
durationMs,
statusDetail: `HTTP ${statusCode}`,
failure: errorFailure(
"body",
"body",
`响应体大小 ${bodyBuffer.byteLength} 超过限制 ${t.http.maxBodyBytes}`,
),
};
}
const body = new TextDecoder().decode(bodyBuffer);
const fullResult = checkHttpExpect(statusCode, responseHeaders, body, durationMs, t.expect);
return {
targetName: t.name,
timestamp,
matched: fullResult.matched,
durationMs,
statusDetail: `HTTP ${statusCode}`,
failure: fullResult.failure,
};
} catch (error) {
const isTimeout = ctx.signal.aborted || (error instanceof DOMException && error.name === "AbortError");
return {
targetName: t.name,
timestamp,
matched: false,
durationMs: null,
statusDetail: null,
failure: errorFailure(
"status",
"request",
isTimeout ? `请求超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
),
};
}
}
serialize(target: ResolvedTarget): { target: string; config: string } {
const t = target as ResolvedHttpTarget;
return {
target: t.http.url,
config: JSON.stringify({
url: t.http.url,
method: t.http.method,
headers: t.http.headers,
body: t.http.body,
maxBodyBytes: t.http.maxBodyBytes,
}),
};
}
}