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:
@@ -1,152 +0,0 @@
|
||||
import { isError } from "es-toolkit";
|
||||
import type { CheckResult, ResolvedCommandTarget } from "./types";
|
||||
import { checkCommandExpect } from "./expect/command";
|
||||
import { errorFailure } from "./expect/failure";
|
||||
|
||||
async function readOutput(
|
||||
stdout: ReadableStream<Uint8Array>,
|
||||
stderr: ReadableStream<Uint8Array>,
|
||||
kill: () => void,
|
||||
maxBytes: number,
|
||||
): Promise<{ stdout: string; stderr: string; exceeded: boolean }> {
|
||||
let totalBytes = 0;
|
||||
let exceeded = false;
|
||||
let killed = false;
|
||||
|
||||
async function readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let text = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
totalBytes += value.byteLength;
|
||||
text += decoder.decode(value, { stream: true });
|
||||
if (totalBytes > maxBytes && !killed) {
|
||||
exceeded = true;
|
||||
killed = true;
|
||||
try {
|
||||
kill();
|
||||
} catch {
|
||||
/* best-effort kill */
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* stream already closed */
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
/* already released */
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
const [out, err] = await Promise.all([readStream(stdout), readStream(stderr)]);
|
||||
|
||||
return { stdout: out, stderr: err, exceeded };
|
||||
}
|
||||
|
||||
export async function runCommandCheck(target: ResolvedCommandTarget): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const start = performance.now();
|
||||
|
||||
let proc: ReturnType<typeof Bun.spawn>;
|
||||
|
||||
try {
|
||||
proc = Bun.spawn([target.command.exec, ...target.command.args], {
|
||||
cwd: target.command.cwd,
|
||||
env: target.command.env,
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: null,
|
||||
failure: errorFailure("exitCode", "spawn", isError(error) ? error.message : String(error)),
|
||||
};
|
||||
}
|
||||
|
||||
let timedOut = false;
|
||||
const timeoutId = setTimeout(() => {
|
||||
timedOut = true;
|
||||
try {
|
||||
proc.kill();
|
||||
} catch {
|
||||
/* best-effort kill */
|
||||
}
|
||||
}, target.timeoutMs);
|
||||
|
||||
let outputResult: { stdout: string; stderr: string; exceeded: boolean };
|
||||
|
||||
try {
|
||||
outputResult = await readOutput(
|
||||
proc.stdout as ReadableStream<Uint8Array>,
|
||||
proc.stderr as ReadableStream<Uint8Array>,
|
||||
() => proc.kill(),
|
||||
target.command.maxOutputBytes,
|
||||
);
|
||||
} catch {
|
||||
clearTimeout(timeoutId);
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: null,
|
||||
failure: errorFailure("exitCode", "execution", "输出读取失败"),
|
||||
};
|
||||
}
|
||||
|
||||
await proc.exited;
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const exitCode = proc.exitCode ?? 1;
|
||||
|
||||
if (outputResult.exceeded) {
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
failure: errorFailure("exitCode", "output", `输出超过限制 ${target.command.maxOutputBytes} 字节`),
|
||||
};
|
||||
}
|
||||
|
||||
if (timedOut) {
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: null,
|
||||
failure: errorFailure("exitCode", "timeout", `命令执行超时 (${target.timeoutMs}ms)`),
|
||||
};
|
||||
}
|
||||
|
||||
const obs = { exitCode, stdout: outputResult.stdout, stderr: outputResult.stderr, durationMs };
|
||||
const expectResult = checkCommandExpect(obs, target.expect);
|
||||
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
matched: expectResult.matched,
|
||||
durationMs,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
failure: expectResult.failure,
|
||||
};
|
||||
}
|
||||
@@ -1,32 +1,19 @@
|
||||
import type {
|
||||
CommandDefaultsConfig,
|
||||
CommandTargetConfig,
|
||||
DefaultsConfig,
|
||||
HttpDefaultsConfig,
|
||||
HttpExpectConfig,
|
||||
HttpTargetConfig,
|
||||
ProbeConfig,
|
||||
ResolvedCommandTarget,
|
||||
ResolvedHttpTarget,
|
||||
ResolvedTarget,
|
||||
EngineRuntimeConfig,
|
||||
TargetConfig,
|
||||
TargetType,
|
||||
} from "./types";
|
||||
import { parseSize } from "./size";
|
||||
import { resolve } from "node:path";
|
||||
import { dirname } from "node:path";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { checkerRegistry } from "./runner";
|
||||
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
const DEFAULT_PORT = 3000;
|
||||
const DEFAULT_DATA_DIR = "./data";
|
||||
const DEFAULT_INTERVAL = "30s";
|
||||
const DEFAULT_TIMEOUT = "10s";
|
||||
const DEFAULT_HTTP_METHOD = "GET";
|
||||
const DEFAULT_MAX_BODY_BYTES = "100MB";
|
||||
const DEFAULT_MAX_OUTPUT_BYTES = "100MB";
|
||||
const DEFAULT_MAX_CONCURRENT_CHECKS = 20;
|
||||
const SUPPORTED_TYPES: TargetType[] = ["http", "command"];
|
||||
|
||||
export interface ResolvedConfig {
|
||||
host: string;
|
||||
@@ -100,73 +87,14 @@ function resolveTarget(
|
||||
): ResolvedTarget {
|
||||
const intervalMs = parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL);
|
||||
const timeoutMs = parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT);
|
||||
const group = target.group ?? "default";
|
||||
|
||||
if (target.type === "http") {
|
||||
return resolveHttpTarget(target, defaults.http, intervalMs, timeoutMs, group);
|
||||
}
|
||||
const checker = checkerRegistry.get(target.type);
|
||||
const result = checker.resolve(target, { defaults, configDir, defaultIntervalMs, defaultTimeoutMs });
|
||||
|
||||
return resolveCommandTarget(target, defaults.command, intervalMs, timeoutMs, configDir, group);
|
||||
}
|
||||
result.intervalMs = intervalMs;
|
||||
result.timeoutMs = timeoutMs;
|
||||
|
||||
function resolveHttpTarget(
|
||||
target: TargetConfig & { type: "http"; http: HttpTargetConfig },
|
||||
httpDefaults: HttpDefaultsConfig | undefined,
|
||||
intervalMs: number,
|
||||
timeoutMs: number,
|
||||
group: string,
|
||||
): ResolvedHttpTarget {
|
||||
const maxBodyBytes = parseSize(target.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES);
|
||||
|
||||
return {
|
||||
type: "http",
|
||||
name: target.name,
|
||||
group,
|
||||
http: {
|
||||
url: target.http.url,
|
||||
method: target.http.method ?? httpDefaults?.method ?? DEFAULT_HTTP_METHOD,
|
||||
headers: { ...(httpDefaults?.headers ?? {}), ...(target.http.headers ?? {}) },
|
||||
body: target.http.body,
|
||||
maxBodyBytes,
|
||||
},
|
||||
intervalMs,
|
||||
timeoutMs,
|
||||
expect: target.expect as HttpExpectConfig | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCommandTarget(
|
||||
target: TargetConfig & { type: "command"; command: CommandTargetConfig },
|
||||
commandDefaults: CommandDefaultsConfig | undefined,
|
||||
intervalMs: number,
|
||||
timeoutMs: number,
|
||||
configDir: string,
|
||||
group: string,
|
||||
): ResolvedCommandTarget {
|
||||
const cwd = target.command.cwd ?? commandDefaults?.cwd ?? ".";
|
||||
const resolvedCwd = resolve(configDir, cwd);
|
||||
|
||||
const maxOutputBytes = parseSize(
|
||||
target.command.maxOutputBytes ?? commandDefaults?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
|
||||
);
|
||||
|
||||
const env = { ...process.env, ...(target.command.env ?? {}) } as Record<string, string>;
|
||||
|
||||
return {
|
||||
type: "command",
|
||||
name: target.name,
|
||||
group,
|
||||
command: {
|
||||
exec: target.command.exec,
|
||||
args: target.command.args ?? [],
|
||||
cwd: resolvedCwd,
|
||||
env,
|
||||
maxOutputBytes,
|
||||
},
|
||||
intervalMs,
|
||||
timeoutMs,
|
||||
expect: target.expect as import("./types").CommandExpectConfig | undefined,
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateConfig(config: ProbeConfig): void {
|
||||
@@ -175,6 +103,7 @@ function validateConfig(config: ProbeConfig): void {
|
||||
}
|
||||
|
||||
const names = new Set<string>();
|
||||
const supportedTypes = checkerRegistry.supportedTypes;
|
||||
|
||||
for (let i = 0; i < config.targets.length; i++) {
|
||||
const raw = config.targets[i] as unknown as Record<string, unknown>;
|
||||
@@ -189,22 +118,8 @@ function validateConfig(config: ProbeConfig): void {
|
||||
throw new Error(`target "${name}" 缺少 type 字段`);
|
||||
}
|
||||
|
||||
if (!SUPPORTED_TYPES.includes(type as TargetType)) {
|
||||
throw new Error(`target "${name}" 使用不支持的 type: "${type}",支持: ${SUPPORTED_TYPES.join(", ")}`);
|
||||
}
|
||||
|
||||
if (type === "http") {
|
||||
const http = raw["http"] as Record<string, unknown> | undefined;
|
||||
if (!http?.["url"] || typeof http["url"] !== "string" || (http["url"] as string).trim() === "") {
|
||||
throw new Error(`target "${name}" 缺少 http.url 字段`);
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "command") {
|
||||
const cmd = raw["command"] as Record<string, unknown> | undefined;
|
||||
if (!cmd?.["exec"] || typeof cmd["exec"] !== "string" || (cmd["exec"] as string).trim() === "") {
|
||||
throw new Error(`target "${name}" 缺少 command.exec 字段`);
|
||||
}
|
||||
if (!supportedTypes.includes(type)) {
|
||||
throw new Error(`target "${name}" 使用不支持的 type: "${type}",支持: ${supportedTypes.join(", ")}`);
|
||||
}
|
||||
|
||||
const group = raw["group"];
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { CheckResult, ResolvedTarget } from "./types";
|
||||
import type { ProbeStore } from "./store";
|
||||
import { runHttpCheck } from "./fetcher";
|
||||
import { runCommandCheck } from "./command-runner";
|
||||
import { checkerRegistry } from "./runner";
|
||||
import { groupBy, Semaphore } from "es-toolkit";
|
||||
|
||||
export class ProbeEngine {
|
||||
@@ -61,11 +60,14 @@ export class ProbeEngine {
|
||||
}
|
||||
|
||||
private async runCheck(target: ResolvedTarget): Promise<CheckResult> {
|
||||
switch (target.type) {
|
||||
case "http":
|
||||
return runHttpCheck(target);
|
||||
case "command":
|
||||
return runCommandCheck(target);
|
||||
const checker = checkerRegistry.get(target.type);
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), target.timeoutMs);
|
||||
|
||||
try {
|
||||
return await checker.execute(target, { signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import type { CheckFailure, CommandExpectConfig, TextRule } from "../types";
|
||||
import { applyOperator } from "./body";
|
||||
import { mismatchFailure } from "./failure";
|
||||
|
||||
export interface CommandObservation {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
function checkExitCode(obs: CommandObservation, allowed: number[]): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (!allowed.includes(obs.exitCode)) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure(
|
||||
"exitCode",
|
||||
"exitCode",
|
||||
allowed,
|
||||
obs.exitCode,
|
||||
`exitCode ${obs.exitCode} not in [${allowed}]`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
function checkDuration(
|
||||
obs: CommandObservation,
|
||||
maxDurationMs?: number,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (maxDurationMs === undefined) return { matched: true, failure: null };
|
||||
if (obs.durationMs > maxDurationMs) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure(
|
||||
"duration",
|
||||
"duration",
|
||||
`<=${maxDurationMs}ms`,
|
||||
obs.durationMs,
|
||||
`duration ${obs.durationMs}ms > ${maxDurationMs}ms`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
function checkTextRules(
|
||||
text: string,
|
||||
rules: TextRule[],
|
||||
phase: "stdout" | "stderr",
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
const rule = rules[i]!;
|
||||
const path = `${phase}[${i}]`;
|
||||
if (!applyOperator(text, rule)) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure(phase, path, rule, text, `${phase} rule at index ${i} mismatch`),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
export function checkCommandExpect(
|
||||
obs: CommandObservation,
|
||||
expect?: CommandExpectConfig,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (!expect) {
|
||||
return checkExitCode(obs, [0]);
|
||||
}
|
||||
|
||||
const exitCodeResult = checkExitCode(obs, expect.exitCode ?? [0]);
|
||||
if (!exitCodeResult.matched) return exitCodeResult;
|
||||
|
||||
const durationResult = checkDuration(obs, expect.maxDurationMs);
|
||||
if (!durationResult.matched) return durationResult;
|
||||
|
||||
if (expect.stdout && expect.stdout.length > 0) {
|
||||
const stdoutResult = checkTextRules(obs.stdout, expect.stdout, "stdout");
|
||||
if (!stdoutResult.matched) return stdoutResult;
|
||||
}
|
||||
|
||||
if (expect.stderr && expect.stderr.length > 0) {
|
||||
const stderrResult = checkTextRules(obs.stderr, expect.stderr, "stderr");
|
||||
if (!stderrResult.matched) return stderrResult;
|
||||
}
|
||||
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import type { BodyRule, CheckFailure, HeaderExpect, HttpExpectConfig } from "../types";
|
||||
import { checkBodyExpect } from "./body";
|
||||
import { applyOperator } from "./body";
|
||||
import { mismatchFailure, errorFailure } from "./failure";
|
||||
|
||||
export interface HttpObservation {
|
||||
statusCode: number;
|
||||
headers: Record<string, string>;
|
||||
body: string | null;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
function checkStatus(obs: HttpObservation, allowed: number[]): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (!allowed.includes(obs.statusCode)) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure(
|
||||
"status",
|
||||
"status",
|
||||
allowed,
|
||||
obs.statusCode,
|
||||
`status ${obs.statusCode} not in [${allowed}]`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
function checkDuration(
|
||||
obs: HttpObservation,
|
||||
maxDurationMs?: number,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (maxDurationMs === undefined) return { matched: true, failure: null };
|
||||
if (obs.durationMs > maxDurationMs) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure(
|
||||
"duration",
|
||||
"duration",
|
||||
`<=${maxDurationMs}ms`,
|
||||
obs.durationMs,
|
||||
`duration ${obs.durationMs}ms > ${maxDurationMs}ms`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
function checkHeaders(
|
||||
obs: HttpObservation,
|
||||
headerExpects?: Record<string, HeaderExpect>,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (!headerExpects) return { matched: true, failure: null };
|
||||
|
||||
for (const [key, expected] of Object.entries(headerExpects)) {
|
||||
const actualValue = obs.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 };
|
||||
}
|
||||
|
||||
function checkBody(obs: HttpObservation, bodyRules?: BodyRule[]): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (!bodyRules || bodyRules.length === 0) return { matched: true, failure: null };
|
||||
|
||||
if (obs.body === null) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: errorFailure("body", "body", "body is null but body rules are configured"),
|
||||
};
|
||||
}
|
||||
|
||||
return checkBodyExpect(obs.body, bodyRules);
|
||||
}
|
||||
|
||||
export function checkHttpExpect(
|
||||
obs: HttpObservation,
|
||||
expect?: HttpExpectConfig,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (!expect) {
|
||||
return checkStatus(obs, [200]);
|
||||
}
|
||||
|
||||
const statusResult = checkStatus(obs, expect.status ?? [200]);
|
||||
if (!statusResult.matched) return statusResult;
|
||||
|
||||
const durationResult = checkDuration(obs, expect.maxDurationMs);
|
||||
if (!durationResult.matched) return durationResult;
|
||||
|
||||
const headersResult = checkHeaders(obs, expect.headers);
|
||||
if (!headersResult.matched) return headersResult;
|
||||
|
||||
const bodyResult = checkBody(obs, expect.body);
|
||||
if (!bodyResult.matched) return bodyResult;
|
||||
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import type { CheckResult, ResolvedHttpTarget } from "./types";
|
||||
import { checkHttpExpect } from "./expect/http";
|
||||
import { errorFailure } from "./expect/failure";
|
||||
import { isError } from "es-toolkit";
|
||||
|
||||
export async function runHttpCheck(target: ResolvedHttpTarget): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), target.timeoutMs);
|
||||
|
||||
try {
|
||||
const start = performance.now();
|
||||
|
||||
const response = await fetch(target.http.url, {
|
||||
method: target.http.method,
|
||||
headers: target.http.headers,
|
||||
body: target.http.method !== "GET" && target.http.method !== "HEAD" ? target.http.body : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const statusCode = response.status;
|
||||
const responseHeaders = Object.fromEntries(response.headers);
|
||||
|
||||
const hasBodyRules = !!(target.expect?.body && target.expect.body.length > 0);
|
||||
|
||||
const preBodyExpect = target.expect
|
||||
? { status: target.expect.status, maxDurationMs: target.expect.maxDurationMs, headers: target.expect.headers }
|
||||
: undefined;
|
||||
|
||||
const preBodyObs = { statusCode, headers: responseHeaders, body: null as string | null, durationMs };
|
||||
const preBodyResult = checkHttpExpect(preBodyObs, preBodyExpect);
|
||||
|
||||
if (!hasBodyRules || !preBodyResult.matched) {
|
||||
clearTimeout(timeoutId);
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
matched: preBodyResult.matched,
|
||||
durationMs,
|
||||
statusDetail: `HTTP ${statusCode}`,
|
||||
failure: preBodyResult.failure,
|
||||
};
|
||||
}
|
||||
|
||||
const bodyBuffer = await response.arrayBuffer();
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (bodyBuffer.byteLength > target.http.maxBodyBytes) {
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: `HTTP ${statusCode}`,
|
||||
failure: errorFailure(
|
||||
"body",
|
||||
"body",
|
||||
`响应体大小 ${bodyBuffer.byteLength} 超过限制 ${target.http.maxBodyBytes}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const body = new TextDecoder().decode(bodyBuffer);
|
||||
const fullObs = { statusCode, headers: responseHeaders, body, durationMs };
|
||||
const fullResult = checkHttpExpect(fullObs, target.expect);
|
||||
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
matched: fullResult.matched,
|
||||
durationMs,
|
||||
statusDetail: `HTTP ${statusCode}`,
|
||||
failure: fullResult.failure,
|
||||
};
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
const isTimeout = error instanceof DOMException && error.name === "AbortError";
|
||||
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs: null,
|
||||
statusDetail: null,
|
||||
failure: errorFailure(
|
||||
"status",
|
||||
"request",
|
||||
isTimeout ? `请求超时 (${target.timeoutMs}ms)` : isError(error) ? error.message : String(error),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
18
src/server/checker/runner/command/expect.ts
Normal file
18
src/server/checker/runner/command/expect.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { mismatchFailure } from "../shared/failure";
|
||||
import type { ExpectResult } from "../shared/duration";
|
||||
|
||||
export function checkExitCode(exitCode: number, allowed: number[]): ExpectResult {
|
||||
if (!allowed.includes(exitCode)) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure(
|
||||
"exitCode",
|
||||
"exitCode",
|
||||
allowed,
|
||||
exitCode,
|
||||
`exitCode ${exitCode} not in [${allowed}]`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
263
src/server/checker/runner/command/runner.ts
Normal file
263
src/server/checker/runner/command/runner.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { isError } from "es-toolkit";
|
||||
import type { CheckResult } from "../../types";
|
||||
import type { Checker, CheckerContext, ResolveContext } from "../types";
|
||||
import type {
|
||||
CommandExpectConfig,
|
||||
CommandTargetConfig,
|
||||
ResolvedCommandTarget,
|
||||
ResolvedTarget,
|
||||
TargetConfig,
|
||||
} from "../../types";
|
||||
import { parseSize } from "../../size";
|
||||
import { checkExitCode } from "./expect";
|
||||
import { checkDuration } from "../shared/duration";
|
||||
import { checkTextRules } from "../shared/text";
|
||||
import { errorFailure } from "../shared/failure";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
async function readOutput(
|
||||
stdout: ReadableStream<Uint8Array>,
|
||||
stderr: ReadableStream<Uint8Array>,
|
||||
kill: () => void,
|
||||
maxBytes: number,
|
||||
): Promise<{ stdout: string; stderr: string; exceeded: boolean }> {
|
||||
let totalBytes = 0;
|
||||
let exceeded = false;
|
||||
let killed = false;
|
||||
|
||||
async function readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let text = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
totalBytes += value.byteLength;
|
||||
text += decoder.decode(value, { stream: true });
|
||||
if (totalBytes > maxBytes && !killed) {
|
||||
exceeded = true;
|
||||
killed = true;
|
||||
try {
|
||||
kill();
|
||||
} catch {
|
||||
/* best-effort kill */
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* stream already closed */
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
/* already released */
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
const [out, err] = await Promise.all([readStream(stdout), readStream(stderr)]);
|
||||
|
||||
return { stdout: out, stderr: err, exceeded };
|
||||
}
|
||||
|
||||
export class CommandChecker implements Checker {
|
||||
readonly type = "command";
|
||||
|
||||
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget {
|
||||
const t = target as TargetConfig & { type: "command"; command: CommandTargetConfig };
|
||||
const commandDefaults = context.defaults.command;
|
||||
|
||||
if (!t.command.exec || t.command.exec.trim() === "") {
|
||||
throw new Error(`target "${t.name}" 缺少 command.exec 字段`);
|
||||
}
|
||||
|
||||
const cwd = t.command.cwd ?? commandDefaults?.cwd ?? ".";
|
||||
const resolvedCwd = resolve(context.configDir, cwd);
|
||||
|
||||
const maxOutputBytes = parseSize(
|
||||
t.command.maxOutputBytes ?? commandDefaults?.maxOutputBytes ?? "100MB",
|
||||
);
|
||||
|
||||
const env = { ...process.env, ...(t.command.env ?? {}) } as Record<string, string>;
|
||||
|
||||
return {
|
||||
type: "command",
|
||||
name: t.name,
|
||||
group: target.group ?? "default",
|
||||
command: {
|
||||
exec: t.command.exec,
|
||||
args: t.command.args ?? [],
|
||||
cwd: resolvedCwd,
|
||||
env,
|
||||
maxOutputBytes,
|
||||
},
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
expect: target.expect as CommandExpectConfig | undefined,
|
||||
} satisfies ResolvedCommandTarget;
|
||||
}
|
||||
|
||||
async execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const t = target as ResolvedCommandTarget;
|
||||
const timestamp = new Date().toISOString();
|
||||
const start = performance.now();
|
||||
|
||||
let proc: ReturnType<typeof Bun.spawn>;
|
||||
|
||||
try {
|
||||
proc = Bun.spawn([t.command.exec, ...t.command.args], {
|
||||
cwd: t.command.cwd,
|
||||
env: t.command.env,
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: null,
|
||||
failure: errorFailure("exitCode", "spawn", isError(error) ? error.message : String(error)),
|
||||
};
|
||||
}
|
||||
|
||||
ctx.signal.addEventListener("abort", () => {
|
||||
try {
|
||||
proc.kill();
|
||||
} catch {
|
||||
/* best-effort kill */
|
||||
}
|
||||
}, { once: true });
|
||||
|
||||
let outputResult: { stdout: string; stderr: string; exceeded: boolean };
|
||||
|
||||
try {
|
||||
outputResult = await readOutput(
|
||||
proc.stdout as ReadableStream<Uint8Array>,
|
||||
proc.stderr as ReadableStream<Uint8Array>,
|
||||
() => proc.kill(),
|
||||
t.command.maxOutputBytes,
|
||||
);
|
||||
} catch {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: null,
|
||||
failure: errorFailure("exitCode", "execution", "输出读取失败"),
|
||||
};
|
||||
}
|
||||
|
||||
await proc.exited;
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const exitCode = proc.exitCode ?? 1;
|
||||
|
||||
if (outputResult.exceeded) {
|
||||
return {
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
failure: errorFailure("exitCode", "output", `输出超过限制 ${t.command.maxOutputBytes} 字节`),
|
||||
};
|
||||
}
|
||||
|
||||
if (ctx.signal.aborted) {
|
||||
return {
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: null,
|
||||
failure: errorFailure("exitCode", "timeout", `命令执行超时 (${t.timeoutMs}ms)`),
|
||||
};
|
||||
}
|
||||
|
||||
const exitCodeResult = checkExitCode(exitCode, t.expect?.exitCode ?? [0]);
|
||||
if (!exitCodeResult.matched) {
|
||||
return {
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
failure: exitCodeResult.failure,
|
||||
};
|
||||
}
|
||||
|
||||
const durationResult = checkDuration(durationMs, t.expect?.maxDurationMs);
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
failure: durationResult.failure,
|
||||
};
|
||||
}
|
||||
|
||||
if (t.expect?.stdout && t.expect.stdout.length > 0) {
|
||||
const stdoutResult = checkTextRules(outputResult.stdout, t.expect.stdout, "stdout");
|
||||
if (!stdoutResult.matched) {
|
||||
return {
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
failure: stdoutResult.failure,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (t.expect?.stderr && t.expect.stderr.length > 0) {
|
||||
const stderrResult = checkTextRules(outputResult.stderr, t.expect.stderr, "stderr");
|
||||
if (!stderrResult.matched) {
|
||||
return {
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
failure: stderrResult.failure,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
matched: true,
|
||||
durationMs,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
failure: null,
|
||||
};
|
||||
}
|
||||
|
||||
serialize(target: ResolvedTarget): { target: string; config: string } {
|
||||
const t = target as ResolvedCommandTarget;
|
||||
const parts = [t.command.exec, ...t.command.args];
|
||||
return {
|
||||
target: `exec ${parts.join(" ")}`,
|
||||
config: JSON.stringify({
|
||||
exec: t.command.exec,
|
||||
args: t.command.args,
|
||||
cwd: t.command.cwd,
|
||||
env: t.command.env,
|
||||
maxOutputBytes: t.command.maxOutputBytes,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
95
src/server/checker/runner/http/expect.ts
Normal file
95
src/server/checker/runner/http/expect.ts
Normal 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 };
|
||||
}
|
||||
145
src/server/checker/runner/http/runner.ts
Normal file
145
src/server/checker/runner/http/runner.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
10
src/server/checker/runner/index.ts
Normal file
10
src/server/checker/runner/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { checkerRegistry } from "./registry";
|
||||
import { HttpChecker } from "./http/runner";
|
||||
import { CommandChecker } from "./command/runner";
|
||||
|
||||
export function registerCheckers(): void {
|
||||
checkerRegistry.register(new HttpChecker());
|
||||
checkerRegistry.register(new CommandChecker());
|
||||
}
|
||||
|
||||
export { checkerRegistry } from "./registry";
|
||||
26
src/server/checker/runner/registry.ts
Normal file
26
src/server/checker/runner/registry.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Checker } from "./types";
|
||||
|
||||
export class CheckerRegistry {
|
||||
private checkers = new Map<string, Checker>();
|
||||
|
||||
register(checker: Checker): void {
|
||||
if (this.checkers.has(checker.type)) {
|
||||
throw new Error(`Checker type "${checker.type}" 已注册`);
|
||||
}
|
||||
this.checkers.set(checker.type, checker);
|
||||
}
|
||||
|
||||
get(type: string): Checker {
|
||||
const checker = this.checkers.get(type);
|
||||
if (!checker) {
|
||||
throw new Error(`不支持的 probe type: "${type}"`);
|
||||
}
|
||||
return checker;
|
||||
}
|
||||
|
||||
get supportedTypes(): string[] {
|
||||
return [...this.checkers.keys()];
|
||||
}
|
||||
}
|
||||
|
||||
export const checkerRegistry = new CheckerRegistry();
|
||||
@@ -1,89 +1,16 @@
|
||||
import type { BodyRule, CheckFailure, CssRule, ExpectOperator, ExpectValue, JsonRule, XpathRule } from "../types";
|
||||
import type { BodyRule, CssRule, JsonRule, XpathRule } from "../../types";
|
||||
import * as cheerio from "cheerio";
|
||||
import * as xpath from "xpath";
|
||||
import { DOMParser } from "@xmldom/xmldom";
|
||||
import { mismatchFailure, errorFailure } from "./failure";
|
||||
import { isNil, isEmptyObject, isEqual, isPlainObject } from "es-toolkit";
|
||||
|
||||
export function evaluateJsonPath(json: unknown, path: string): unknown {
|
||||
if (!path.startsWith("$.")) return undefined;
|
||||
|
||||
const segments = path.slice(2).split(".");
|
||||
let current: unknown = json;
|
||||
|
||||
for (const seg of segments) {
|
||||
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
|
||||
if (bracketMatch) {
|
||||
current = (current as Record<string, unknown>)?.[bracketMatch[1]!];
|
||||
const idx = parseInt(bracketMatch[2]!, 10);
|
||||
if (!Array.isArray(current) || idx >= current.length) return undefined;
|
||||
current = current[idx];
|
||||
} else {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
current = (current as Record<string, unknown>)[seg];
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
|
||||
for (const [key, expected] of Object.entries(op)) {
|
||||
if (expected === undefined) continue;
|
||||
|
||||
switch (key) {
|
||||
case "equals":
|
||||
if (!isEqual(actual, expected)) return false;
|
||||
break;
|
||||
case "contains":
|
||||
if (!String(actual).includes(expected as string)) return false;
|
||||
break;
|
||||
case "match":
|
||||
if (!new RegExp(expected as string).test(String(actual))) return false;
|
||||
break;
|
||||
case "empty": {
|
||||
const isEmpty =
|
||||
isNil(actual) || actual === "" || (Array.isArray(actual) && actual.length === 0) || isEmptyObject(actual);
|
||||
if (expected !== isEmpty) return false;
|
||||
break;
|
||||
}
|
||||
case "exists":
|
||||
if (expected) {
|
||||
if (actual === undefined) return false;
|
||||
} else {
|
||||
if (actual !== undefined) return false;
|
||||
}
|
||||
break;
|
||||
case "gte":
|
||||
if (!(Number(actual) >= (expected as number))) return false;
|
||||
break;
|
||||
case "lte":
|
||||
if (!(Number(actual) <= (expected as number))) return false;
|
||||
break;
|
||||
case "gt":
|
||||
if (!(Number(actual) > (expected as number))) return false;
|
||||
break;
|
||||
case "lt":
|
||||
if (!(Number(actual) < (expected as number))) return false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function checkExpectValue(actual: unknown, expected: ExpectValue): boolean {
|
||||
if (isPlainObject(expected)) {
|
||||
return applyOperator(actual, expected as ExpectOperator);
|
||||
}
|
||||
return applyOperator(actual, { equals: expected as string | number | boolean | null });
|
||||
}
|
||||
import { applyOperator, evaluateJsonPath } from "./operator";
|
||||
import type { ExpectResult } from "./duration";
|
||||
|
||||
function checkJsonRule(
|
||||
body: string,
|
||||
rule: JsonRule,
|
||||
rulePath: string,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
): ExpectResult {
|
||||
const { path, ...operators } = rule;
|
||||
const fullPath = `${rulePath}.json(${path})`;
|
||||
|
||||
@@ -124,7 +51,7 @@ function checkCssRule(
|
||||
body: string,
|
||||
rule: CssRule,
|
||||
rulePath: string,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
): ExpectResult {
|
||||
const { selector, attr, ...operators } = rule;
|
||||
const fullPath = `${rulePath}.css(${selector}${attr ? `@${attr}` : ""})`;
|
||||
|
||||
@@ -201,7 +128,7 @@ function checkXpathRule(
|
||||
body: string,
|
||||
rule: XpathRule,
|
||||
rulePath: string,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
): ExpectResult {
|
||||
const { path, ...operators } = rule;
|
||||
const fullPath = `${rulePath}.xpath(${path})`;
|
||||
|
||||
@@ -245,7 +172,7 @@ function checkSingleBodyRule(
|
||||
body: string,
|
||||
rule: BodyRule,
|
||||
index: number,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
): ExpectResult {
|
||||
const rulePath = `body[${index}]`;
|
||||
|
||||
if ("contains" in rule) {
|
||||
@@ -285,7 +212,7 @@ function checkSingleBodyRule(
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
export function checkBodyExpect(body: string, rules?: BodyRule[]): { matched: boolean; failure: CheckFailure | null } {
|
||||
export function checkBodyExpect(body: string, rules?: BodyRule[]): ExpectResult {
|
||||
if (!rules || rules.length === 0) return { matched: true, failure: null };
|
||||
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
24
src/server/checker/runner/shared/duration.ts
Normal file
24
src/server/checker/runner/shared/duration.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { CheckFailure } from "../../types";
|
||||
import { mismatchFailure } from "./failure";
|
||||
|
||||
export interface ExpectResult {
|
||||
matched: boolean;
|
||||
failure: CheckFailure | null;
|
||||
}
|
||||
|
||||
export function checkDuration(durationMs: number, maxDurationMs?: number): ExpectResult {
|
||||
if (maxDurationMs === undefined) return { matched: true, failure: null };
|
||||
if (durationMs > maxDurationMs) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure(
|
||||
"duration",
|
||||
"duration",
|
||||
`<=${maxDurationMs}ms`,
|
||||
durationMs,
|
||||
`duration ${durationMs}ms > ${maxDurationMs}ms`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CheckFailure } from "../types";
|
||||
import type { CheckFailure } from "../../types";
|
||||
|
||||
export function truncateActual(value: unknown, maxLen = 200): unknown {
|
||||
if (value === undefined || value === null) return value;
|
||||
76
src/server/checker/runner/shared/operator.ts
Normal file
76
src/server/checker/runner/shared/operator.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { isNil, isEmptyObject, isEqual, isPlainObject } from "es-toolkit";
|
||||
import type { ExpectOperator, ExpectValue } from "../../types";
|
||||
|
||||
export function evaluateJsonPath(json: unknown, path: string): unknown {
|
||||
if (!path.startsWith("$.")) return undefined;
|
||||
|
||||
const segments = path.slice(2).split(".");
|
||||
let current: unknown = json;
|
||||
|
||||
for (const seg of segments) {
|
||||
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
|
||||
if (bracketMatch) {
|
||||
current = (current as Record<string, unknown>)?.[bracketMatch[1]!];
|
||||
const idx = parseInt(bracketMatch[2]!, 10);
|
||||
if (!Array.isArray(current) || idx >= current.length) return undefined;
|
||||
current = current[idx];
|
||||
} else {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
current = (current as Record<string, unknown>)[seg];
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
|
||||
for (const [key, expected] of Object.entries(op)) {
|
||||
if (expected === undefined) continue;
|
||||
|
||||
switch (key) {
|
||||
case "equals":
|
||||
if (!isEqual(actual, expected)) return false;
|
||||
break;
|
||||
case "contains":
|
||||
if (!String(actual).includes(expected as string)) return false;
|
||||
break;
|
||||
case "match":
|
||||
if (!new RegExp(expected as string).test(String(actual))) return false;
|
||||
break;
|
||||
case "empty": {
|
||||
const isEmpty =
|
||||
isNil(actual) || actual === "" || (Array.isArray(actual) && actual.length === 0) || isEmptyObject(actual);
|
||||
if (expected !== isEmpty) return false;
|
||||
break;
|
||||
}
|
||||
case "exists":
|
||||
if (expected) {
|
||||
if (actual === undefined) return false;
|
||||
} else {
|
||||
if (actual !== undefined) return false;
|
||||
}
|
||||
break;
|
||||
case "gte":
|
||||
if (!(Number(actual) >= (expected as number))) return false;
|
||||
break;
|
||||
case "lte":
|
||||
if (!(Number(actual) <= (expected as number))) return false;
|
||||
break;
|
||||
case "gt":
|
||||
if (!(Number(actual) > (expected as number))) return false;
|
||||
break;
|
||||
case "lt":
|
||||
if (!(Number(actual) < (expected as number))) return false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function checkExpectValue(actual: unknown, expected: ExpectValue): boolean {
|
||||
if (isPlainObject(expected)) {
|
||||
return applyOperator(actual, expected as ExpectOperator);
|
||||
}
|
||||
return applyOperator(actual, { equals: expected as string | number | boolean | null });
|
||||
}
|
||||
18
src/server/checker/runner/shared/text.ts
Normal file
18
src/server/checker/runner/shared/text.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { TextRule } from "../../types";
|
||||
import { applyOperator } from "./operator";
|
||||
import { mismatchFailure } from "./failure";
|
||||
import type { ExpectResult } from "./duration";
|
||||
|
||||
export function checkTextRules(text: string, rules: TextRule[], phase: string): ExpectResult {
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
const rule = rules[i]!;
|
||||
const path = `${phase}[${i}]`;
|
||||
if (!applyOperator(text, rule)) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure(phase, path, rule, text, `${phase} rule at index ${i} mismatch`),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
20
src/server/checker/runner/types.ts
Normal file
20
src/server/checker/runner/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { CheckResult } from "../types";
|
||||
import type { DefaultsConfig, ResolvedTarget, TargetConfig } from "../types";
|
||||
|
||||
export interface CheckerContext {
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ResolveContext {
|
||||
defaults: DefaultsConfig;
|
||||
configDir: string;
|
||||
defaultIntervalMs: number;
|
||||
defaultTimeoutMs: number;
|
||||
}
|
||||
|
||||
export interface Checker {
|
||||
readonly type: string;
|
||||
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget;
|
||||
execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult>;
|
||||
serialize(target: ResolvedTarget): { target: string; config: string };
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Database } from "bun:sqlite";
|
||||
import { mkdirSync as fsMkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import type { CheckFailure, ResolvedTarget, StoredCheckResult, StoredTarget } from "./types";
|
||||
import { checkerRegistry } from "./runner";
|
||||
|
||||
const CREATE_TARGETS_TABLE = `
|
||||
CREATE TABLE IF NOT EXISTS targets (
|
||||
@@ -297,30 +298,11 @@ export class ProbeStore {
|
||||
}
|
||||
|
||||
function buildTargetDisplay(t: ResolvedTarget): string {
|
||||
if (t.type === "http") {
|
||||
return t.http.url;
|
||||
}
|
||||
const parts = [t.command.exec, ...t.command.args];
|
||||
return `exec ${parts.join(" ")}`;
|
||||
return checkerRegistry.get(t.type).serialize(t).target;
|
||||
}
|
||||
|
||||
function buildTargetConfig(t: ResolvedTarget): string {
|
||||
if (t.type === "http") {
|
||||
return JSON.stringify({
|
||||
url: t.http.url,
|
||||
method: t.http.method,
|
||||
headers: t.http.headers,
|
||||
body: t.http.body,
|
||||
maxBodyBytes: t.http.maxBodyBytes,
|
||||
});
|
||||
}
|
||||
return JSON.stringify({
|
||||
exec: t.command.exec,
|
||||
args: t.command.args,
|
||||
cwd: t.command.cwd,
|
||||
env: t.command.env,
|
||||
maxOutputBytes: t.command.maxOutputBytes,
|
||||
});
|
||||
return checkerRegistry.get(t.type).serialize(t).config;
|
||||
}
|
||||
|
||||
function ensureDir(dir: string): void {
|
||||
|
||||
Reference in New Issue
Block a user