refactor: 重命名 command checker 为 cmd checker 并适配跨平台测试
将 type/configKey 从 "command" 统一为 "cmd",源码目录 runner/command/ → runner/cmd/, spec 目录 command-checker/ → cmd-checker/,测试全部改用 bun -e 替代 Unix 系统命令, 归档 cmd-checker-enhancement 变更并同步 delta spec 到主 spec。
This commit is contained in:
265
src/server/checker/runner/cmd/execute.ts
Normal file
265
src/server/checker/runner/cmd/execute.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { isError } from "es-toolkit";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type { CommandExpectConfig, CommandTargetConfig, ResolvedCommandTarget } from "./types";
|
||||
|
||||
import { checkDuration } from "../../expect/duration";
|
||||
import { errorFailure } from "../../expect/failure";
|
||||
import { parseSize } from "../../utils";
|
||||
import { checkExitCode } from "./expect";
|
||||
import { commandCheckerSchemas } from "./schema";
|
||||
import { checkTextRules } from "./text";
|
||||
import { validateCommandConfig } from "./validate";
|
||||
|
||||
export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget> {
|
||||
readonly configKey = "cmd";
|
||||
|
||||
readonly schemas = commandCheckerSchemas;
|
||||
|
||||
readonly type = "cmd";
|
||||
|
||||
async execute(t: ResolvedCommandTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const start = performance.now();
|
||||
|
||||
let proc: ReturnType<typeof Bun.spawn>;
|
||||
|
||||
try {
|
||||
proc = Bun.spawn([t.cmd.exec, ...t.cmd.args], {
|
||||
cwd: t.cmd.cwd,
|
||||
env: t.cmd.env,
|
||||
stderr: "pipe",
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
durationMs,
|
||||
failure: errorFailure("exitCode", "spawn", isError(error) ? error.message : String(error)),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
ctx.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
try {
|
||||
proc.kill();
|
||||
} catch {
|
||||
/* best-effort kill */
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
let outputResult: { exceeded: boolean; stderr: string; stdout: string };
|
||||
|
||||
try {
|
||||
outputResult = await readOutput(
|
||||
proc.stdout as ReadableStream<Uint8Array>,
|
||||
proc.stderr as ReadableStream<Uint8Array>,
|
||||
() => proc.kill(),
|
||||
t.cmd.maxOutputBytes,
|
||||
);
|
||||
} catch {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
durationMs,
|
||||
failure: errorFailure("exitCode", "execution", "输出读取失败"),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
await proc.exited;
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const exitCode = proc.exitCode ?? 1;
|
||||
|
||||
if (outputResult.exceeded) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: errorFailure("exitCode", "output", `输出超过限制 ${t.cmd.maxOutputBytes} 字节`),
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
if (ctx.signal.aborted) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: errorFailure("exitCode", "timeout", `命令执行超时 (${t.timeoutMs}ms)`),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const exitCodeResult = checkExitCode(exitCode, t.expect?.exitCode ?? [0]);
|
||||
if (!exitCodeResult.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: exitCodeResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const durationResult = checkDuration(durationMs, t.expect?.maxDurationMs);
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: durationResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
if (t.expect?.stdout && t.expect.stdout.length > 0) {
|
||||
const stdoutResult = checkTextRules(outputResult.stdout, t.expect.stdout, "stdout");
|
||||
if (!stdoutResult.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: stdoutResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (t.expect?.stderr && t.expect.stderr.length > 0) {
|
||||
const stderrResult = checkTextRules(outputResult.stderr, t.expect.stderr, "stderr");
|
||||
if (!stderrResult.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: stderrResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedCommandTarget {
|
||||
const t = target as RawTargetConfig & { cmd: CommandTargetConfig; type: "cmd" };
|
||||
const cmdDefaults = context.defaults["cmd"] as undefined | { cwd?: string; maxOutputBytes?: string };
|
||||
|
||||
const cwd = t.cmd.cwd ?? cmdDefaults?.cwd ?? ".";
|
||||
const resolvedCwd = resolve(context.configDir, cwd);
|
||||
|
||||
const maxOutputBytes = parseSize(t.cmd.maxOutputBytes ?? cmdDefaults?.maxOutputBytes ?? "100MB");
|
||||
|
||||
const env = { ...process.env, ...(t.cmd.env ?? {}) } as Record<string, string>;
|
||||
|
||||
return {
|
||||
cmd: {
|
||||
args: t.cmd.args ?? [],
|
||||
cwd: resolvedCwd,
|
||||
env,
|
||||
exec: t.cmd.exec,
|
||||
maxOutputBytes,
|
||||
},
|
||||
expect: target.expect as CommandExpectConfig | undefined,
|
||||
group: target.group ?? "default",
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
name: t.name,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
type: "cmd",
|
||||
} satisfies ResolvedCommandTarget;
|
||||
}
|
||||
|
||||
serialize(t: ResolvedCommandTarget): { config: string; target: string } {
|
||||
const parts = [t.cmd.exec, ...t.cmd.args];
|
||||
return {
|
||||
config: JSON.stringify({
|
||||
args: t.cmd.args,
|
||||
cwd: t.cmd.cwd,
|
||||
env: t.cmd.env,
|
||||
exec: t.cmd.exec,
|
||||
maxOutputBytes: t.cmd.maxOutputBytes,
|
||||
}),
|
||||
target: `exec ${parts.join(" ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
validate(input: CheckerValidationInput) {
|
||||
return validateCommandConfig(input);
|
||||
}
|
||||
}
|
||||
|
||||
async function readOutput(
|
||||
stdout: ReadableStream<Uint8Array>,
|
||||
stderr: ReadableStream<Uint8Array>,
|
||||
kill: () => void,
|
||||
maxBytes: number,
|
||||
): Promise<{ exceeded: boolean; stderr: string; stdout: string }> {
|
||||
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 { exceeded, stderr: err, stdout: out };
|
||||
}
|
||||
Reference in New Issue
Block a user