chore: 强化代码质量与风格检查体系
ESLint 升级到 recommended-type-checked + stylistic-type-checked, 引入 perfectionist 导入排序和 import 插件导入验证。 Prettier 显式声明全部格式化参数,消除跨环境差异。 TypeScript 启用 noUnusedLocals 和 noPropertyAccessFromIndexSignature。 完善 ignore 列表,排除 .agents/、bun.lock、data/ 等。 引入 husky + lint-staged(pre-commit)+ commitlint(commit-msg)。 更新 DEVELOPMENT.md 代码质量章节。 修复所有新增规则检测到的类型和风格违规。
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { createFetchHandler, type StaticAssets } from "../../src/server/app";
|
||||
import { ProbeStore } from "../../src/server/checker/store";
|
||||
import type { HistoryResponse, SummaryResponse, TargetStatus, HealthResponse } from "../../src/shared/api";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { HealthResponse, HistoryResponse, SummaryResponse, TargetStatus } from "../../src/shared/api";
|
||||
|
||||
import { createFetchHandler, type StaticAssets } from "../../src/server/app";
|
||||
import { checkerRegistry } from "../../src/server/checker/runner";
|
||||
import { HttpChecker } from "../../src/server/checker/runner/http/runner";
|
||||
import { CommandChecker } from "../../src/server/checker/runner/command/runner";
|
||||
import { HttpChecker } from "../../src/server/checker/runner/http/runner";
|
||||
import { ProbeStore } from "../../src/server/checker/store";
|
||||
|
||||
function ensureRegistered() {
|
||||
if (!checkerRegistry.supportedTypes.includes("http")) {
|
||||
@@ -21,12 +23,12 @@ beforeAll(() => {
|
||||
});
|
||||
|
||||
const staticAssets: StaticAssets = {
|
||||
indexHtml: new Blob(['<!doctype html><title>DiAL</title><div id="root"></div>'], {
|
||||
type: "text/html",
|
||||
}),
|
||||
files: {
|
||||
"/assets/app.js": new Blob(["console.log('app');"], { type: "text/javascript" }),
|
||||
},
|
||||
indexHtml: new Blob(['<!doctype html><title>DiAL</title><div id="root"></div>'], {
|
||||
type: "text/html",
|
||||
}),
|
||||
};
|
||||
|
||||
describe("API 路由", () => {
|
||||
@@ -40,57 +42,57 @@ describe("API 路由", () => {
|
||||
store = new ProbeStore(join(tempDir, "test.db"));
|
||||
store.syncTargets([
|
||||
{
|
||||
type: "http",
|
||||
name: "test-a",
|
||||
group: "default",
|
||||
http: {
|
||||
url: "http://a.com",
|
||||
method: "GET",
|
||||
headers: {},
|
||||
maxBodyBytes: 104857600,
|
||||
method: "GET",
|
||||
url: "http://a.com",
|
||||
},
|
||||
intervalMs: 30000,
|
||||
name: "test-a",
|
||||
timeoutMs: 10000,
|
||||
type: "http",
|
||||
},
|
||||
{
|
||||
type: "command",
|
||||
name: "test-b",
|
||||
group: "default",
|
||||
command: {
|
||||
exec: "echo",
|
||||
args: ["hello"],
|
||||
cwd: "/tmp",
|
||||
env: {},
|
||||
exec: "echo",
|
||||
maxOutputBytes: 104857600,
|
||||
},
|
||||
group: "default",
|
||||
intervalMs: 60000,
|
||||
name: "test-b",
|
||||
timeoutMs: 5000,
|
||||
type: "command",
|
||||
},
|
||||
]);
|
||||
|
||||
const targets = store.getTargets();
|
||||
store.insertCheckResult({
|
||||
durationMs: 150,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
targetId: targets[0]!.id,
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
matched: true,
|
||||
durationMs: 150,
|
||||
statusDetail: "200 OK",
|
||||
failure: null,
|
||||
});
|
||||
store.insertCheckResult({
|
||||
durationMs: null,
|
||||
failure: {
|
||||
actual: 500,
|
||||
expected: 200,
|
||||
kind: "error",
|
||||
message: "状态码不匹配",
|
||||
path: "$.status",
|
||||
phase: "status",
|
||||
},
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetId: targets[0]!.id,
|
||||
timestamp: "2025-01-01T00:00:30.000Z",
|
||||
matched: false,
|
||||
durationMs: null,
|
||||
statusDetail: null,
|
||||
failure: {
|
||||
kind: "error",
|
||||
phase: "status",
|
||||
path: "$.status",
|
||||
expected: 200,
|
||||
actual: 500,
|
||||
message: "状态码不匹配",
|
||||
},
|
||||
});
|
||||
|
||||
fetchHandler = createFetchHandler({ mode: "test", staticAssets, store });
|
||||
@@ -98,11 +100,11 @@ describe("API 路由", () => {
|
||||
|
||||
afterAll(async () => {
|
||||
store.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
await rm(tempDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
test("/health 返回健康检查", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/health"));
|
||||
const response = fetchHandler(new Request("http://localhost/health"));
|
||||
const body = (await response.json()) as HealthResponse;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -111,7 +113,7 @@ describe("API 路由", () => {
|
||||
});
|
||||
|
||||
test("/api/summary 返回总览统计", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/api/summary"));
|
||||
const response = fetchHandler(new Request("http://localhost/api/summary"));
|
||||
const body = (await response.json()) as SummaryResponse;
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.total).toBe(2);
|
||||
@@ -122,7 +124,7 @@ describe("API 路由", () => {
|
||||
});
|
||||
|
||||
test("/api/targets 返回目标列表", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/api/targets"));
|
||||
const response = fetchHandler(new Request("http://localhost/api/targets"));
|
||||
const body = (await response.json()) as TargetStatus[];
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -150,7 +152,7 @@ describe("API 路由", () => {
|
||||
const targets = store.getTargets();
|
||||
const from = "2024-01-01T00:00:00.000Z";
|
||||
const to = "2026-12-31T23:59:59.999Z";
|
||||
const response = await fetchHandler(
|
||||
const response = fetchHandler(
|
||||
new Request(`http://localhost/api/targets/${targets[0]!.id}/history?from=${from}&to=${to}`),
|
||||
);
|
||||
const body = (await response.json()) as HistoryResponse;
|
||||
@@ -168,7 +170,7 @@ describe("API 路由", () => {
|
||||
const targets = store.getTargets();
|
||||
const from = "2024-01-01T00:00:00.000Z";
|
||||
const to = "2026-12-31T23:59:59.999Z";
|
||||
const response = await fetchHandler(
|
||||
const response = fetchHandler(
|
||||
new Request(`http://localhost/api/targets/${targets[0]!.id}/history?from=${from}&to=${to}&pageSize=1`),
|
||||
);
|
||||
const body = (await response.json()) as HistoryResponse;
|
||||
@@ -182,90 +184,92 @@ describe("API 路由", () => {
|
||||
const targets = store.getTargets();
|
||||
const from = "2024-01-01T00:00:00.000Z";
|
||||
const to = "2026-12-31T23:59:59.999Z";
|
||||
const response = await fetchHandler(
|
||||
const response = fetchHandler(
|
||||
new Request(`http://localhost/api/targets/${targets[0]!.id}/trend?from=${from}&to=${to}`),
|
||||
);
|
||||
const body = await response.json();
|
||||
const body = (await response.json()) as unknown[];
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
});
|
||||
|
||||
test("查询不存在的目标返回 404", async () => {
|
||||
const response = await fetchHandler(
|
||||
const response = fetchHandler(
|
||||
new Request(
|
||||
"http://localhost/api/targets/99999/history?from=2024-01-01T00:00:00.000Z&to=2026-12-31T23:59:59.999Z",
|
||||
),
|
||||
);
|
||||
const body = await response.json();
|
||||
const body = (await response.json()) as Record<string, unknown>;
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(body.error).toBe("Target not found");
|
||||
expect(body["error"]).toBe("Target not found");
|
||||
});
|
||||
|
||||
test("history 缺少 from/to 参数返回 400", async () => {
|
||||
const targets = store.getTargets();
|
||||
const response = await fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/history`));
|
||||
const body = await response.json();
|
||||
const response = fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/history`));
|
||||
const body = (await response.json()) as Record<string, unknown>;
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toContain("from and to");
|
||||
expect(body["error"]).toContain("from and to");
|
||||
});
|
||||
|
||||
test("trend 缺少 from/to 参数返回 400", async () => {
|
||||
const targets = store.getTargets();
|
||||
const response = await fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/trend`));
|
||||
const body = await response.json();
|
||||
const response = fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/trend`));
|
||||
const body = (await response.json()) as Record<string, unknown>;
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toContain("from and to");
|
||||
expect(body["error"]).toContain("from and to");
|
||||
});
|
||||
|
||||
test("无效目标 ID 返回 400", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/api/targets/abc/history"));
|
||||
const body = await response.json();
|
||||
test("trend 无效 targetId 返回 400", async () => {
|
||||
const response = fetchHandler(
|
||||
new Request("http://localhost/api/targets/invalid/trend?from=2024-01-01T00:00:00Z&to=2024-01-02T00:00:00Z"),
|
||||
);
|
||||
const body = (await response.json()) as Record<string, unknown>;
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toBe("Invalid target ID");
|
||||
expect(body["error"]).toBe("Invalid target ID");
|
||||
});
|
||||
|
||||
test("未知 /api/* 返回 404", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/api/missing"));
|
||||
test("未知 /api/* 返回 404", () => {
|
||||
const response = fetchHandler(new Request("http://localhost/api/missing"));
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
test("HEAD 请求返回 headers 无 body", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/api/summary", { method: "HEAD" }));
|
||||
const response = fetchHandler(new Request("http://localhost/api/summary", { method: "HEAD" }));
|
||||
const body = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toBe("");
|
||||
});
|
||||
|
||||
test("不支持的 method 返回 405", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/api/summary", { method: "POST" }));
|
||||
test("不支持的 method 返回 405", () => {
|
||||
const response = fetchHandler(new Request("http://localhost/api/summary", { method: "POST" }));
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.get("allow")).toBe("GET, HEAD");
|
||||
});
|
||||
|
||||
test("生产响应包含安全 headers", async () => {
|
||||
test("生产响应包含安全 headers", () => {
|
||||
const prodHandler = createFetchHandler({ mode: "production", staticAssets, store });
|
||||
const response = await prodHandler(new Request("http://localhost/api/summary"));
|
||||
const response = prodHandler(new Request("http://localhost/api/summary"));
|
||||
|
||||
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin");
|
||||
});
|
||||
|
||||
test("静态资源和 SPA fallback 正常工作", async () => {
|
||||
const root = await fetchHandler(new Request("http://localhost/"));
|
||||
test("静态资源和 SPA fallback 正常工作", () => {
|
||||
const root = fetchHandler(new Request("http://localhost/"));
|
||||
expect(root.status).toBe(200);
|
||||
|
||||
const fallback = await fetchHandler(new Request("http://localhost/dashboard"));
|
||||
const fallback = fetchHandler(new Request("http://localhost/dashboard"));
|
||||
expect(fallback.status).toBe(200);
|
||||
|
||||
const asset = await fetchHandler(new Request("http://localhost/assets/app.js"));
|
||||
const asset = fetchHandler(new Request("http://localhost/assets/app.js"));
|
||||
expect(asset.status).toBe(200);
|
||||
});
|
||||
|
||||
@@ -274,12 +278,12 @@ describe("API 路由", () => {
|
||||
const t1Id = targets[0]!.id;
|
||||
|
||||
store.insertCheckResult({
|
||||
durationMs: 100,
|
||||
failure: { kind: "error", message: "test", path: "$", phase: "body" },
|
||||
matched: false,
|
||||
statusDetail: "200 OK",
|
||||
targetId: t1Id,
|
||||
timestamp: "2025-06-01T00:00:00.000Z",
|
||||
matched: false,
|
||||
durationMs: 100,
|
||||
statusDetail: "200 OK",
|
||||
failure: { kind: "error", phase: "body", path: "$", message: "test" },
|
||||
});
|
||||
|
||||
(store as unknown as { db: { prepare: (sql: string) => { run: (...args: unknown[]) => void } } }).db
|
||||
@@ -288,9 +292,7 @@ describe("API 路由", () => {
|
||||
|
||||
const from = "2025-06-01T00:00:00.000Z";
|
||||
const to = "2025-06-01T23:59:59.999Z";
|
||||
const response = await fetchHandler(
|
||||
new Request(`http://localhost/api/targets/${t1Id}/history?from=${from}&to=${to}`),
|
||||
);
|
||||
const response = fetchHandler(new Request(`http://localhost/api/targets/${t1Id}/history?from=${from}&to=${to}`));
|
||||
const body = (await response.json()) as HistoryResponse;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { beforeAll, afterAll, describe, expect, test } from "bun:test";
|
||||
import { loadConfig, parseDuration } from "../../../src/server/checker/config-loader";
|
||||
import { readRuntimeConfig } from "../../../src/server/config";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { loadConfig, parseDuration } from "../../../src/server/checker/config-loader";
|
||||
import { checkerRegistry } from "../../../src/server/checker/runner";
|
||||
import { HttpChecker } from "../../../src/server/checker/runner/http/runner";
|
||||
import { CommandChecker } from "../../../src/server/checker/runner/command/runner";
|
||||
import { HttpChecker } from "../../../src/server/checker/runner/http/runner";
|
||||
import { readRuntimeConfig } from "../../../src/server/config";
|
||||
|
||||
function ensureRegistered() {
|
||||
if (!checkerRegistry.supportedTypes.includes("http")) {
|
||||
@@ -66,7 +67,7 @@ describe("loadConfig", () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
await rm(tempDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
test("解析最简 HTTP 配置", async () => {
|
||||
@@ -125,7 +126,7 @@ describe("loadConfig", () => {
|
||||
expect(t.command.args).toEqual(["nginx"]);
|
||||
expect(t.command.cwd).toBe(subdir);
|
||||
expect(t.command.maxOutputBytes).toBe(104857600);
|
||||
expect(t.command.env.PATH).toBeDefined();
|
||||
expect(t.command.env["PATH"]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -230,6 +231,7 @@ targets:
|
||||
});
|
||||
|
||||
test("配置文件不存在抛出错误", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await expect(loadConfig("/nonexistent/file.yaml")).rejects.toThrow("配置文件不存在");
|
||||
});
|
||||
|
||||
@@ -243,6 +245,7 @@ targets:
|
||||
url: "http://example.com"
|
||||
`,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("缺少 name 字段");
|
||||
});
|
||||
|
||||
@@ -256,6 +259,7 @@ targets:
|
||||
url: "http://example.com"
|
||||
`,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("缺少 type 字段");
|
||||
});
|
||||
|
||||
@@ -269,6 +273,7 @@ targets:
|
||||
http: {}
|
||||
`,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("缺少 http.url 字段");
|
||||
});
|
||||
|
||||
@@ -282,6 +287,7 @@ targets:
|
||||
command: {}
|
||||
`,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("缺少 command.exec 字段");
|
||||
});
|
||||
|
||||
@@ -294,6 +300,7 @@ targets:
|
||||
type: dns
|
||||
`,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("不支持的 type");
|
||||
});
|
||||
|
||||
@@ -312,12 +319,14 @@ targets:
|
||||
url: "http://b.com"
|
||||
`,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("target name 重复");
|
||||
});
|
||||
|
||||
test("targets 为空数组抛出错误", async () => {
|
||||
const configPath = join(tempDir, "empty-targets.yaml");
|
||||
await writeFile(configPath, `targets: []`);
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("至少一个 target");
|
||||
});
|
||||
|
||||
@@ -334,6 +343,7 @@ targets:
|
||||
url: "http://a.com"
|
||||
`,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("无效端口号");
|
||||
});
|
||||
|
||||
@@ -350,6 +360,7 @@ targets:
|
||||
url: "http://a.com"
|
||||
`,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("maxConcurrentChecks 必须为正整数");
|
||||
});
|
||||
|
||||
@@ -367,6 +378,7 @@ targets:
|
||||
url: "http://a.com"
|
||||
`,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("无效的 size 格式");
|
||||
});
|
||||
|
||||
@@ -382,6 +394,7 @@ targets:
|
||||
url: "http://a.com"
|
||||
`,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("无效的时长格式");
|
||||
});
|
||||
|
||||
@@ -409,9 +422,9 @@ targets:
|
||||
const t = config.targets[0]!;
|
||||
if (t.type === "http") {
|
||||
expect(t.expect).toEqual({
|
||||
status: [200, 201],
|
||||
body: [{ contains: "ok" }, { json: { path: "$.status", equals: "ok" } }],
|
||||
body: [{ contains: "ok" }, { json: { equals: "ok", path: "$.status" } }],
|
||||
maxDurationMs: 3000,
|
||||
status: [200, 201],
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -441,9 +454,9 @@ targets:
|
||||
if (t.type === "command") {
|
||||
expect(t.expect).toEqual({
|
||||
exitCode: [0, 2],
|
||||
stdout: [{ contains: "ok" }, { match: "done" }],
|
||||
stderr: [{ empty: true }],
|
||||
maxDurationMs: 5000,
|
||||
stderr: [{ empty: true }],
|
||||
stdout: [{ contains: "ok" }, { match: "done" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -488,9 +501,9 @@ targets:
|
||||
const config = await loadConfig(configPath);
|
||||
const t = config.targets[0]!;
|
||||
if (t.type === "command") {
|
||||
expect(t.command.env.LANG).toBe("C");
|
||||
expect(t.command.env.CUSTOM_VAR).toBe("test");
|
||||
expect(t.command.env.PATH).toBeDefined();
|
||||
expect(t.command.env["LANG"]).toBe("C");
|
||||
expect(t.command.env["CUSTOM_VAR"]).toBe("test");
|
||||
expect(t.command.env["PATH"]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -540,6 +553,7 @@ targets:
|
||||
`,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("group 字段必须为字符串");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,38 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { ProbeEngine } from "../../../src/server/checker/engine";
|
||||
|
||||
import type { ProbeStore } from "../../../src/server/checker/store";
|
||||
import type { ResolvedCommandTarget, ResolvedHttpTarget, ResolvedTarget } from "../../../src/server/checker/types";
|
||||
|
||||
import { ProbeEngine } from "../../../src/server/checker/engine";
|
||||
import { checkerRegistry } from "../../../src/server/checker/runner";
|
||||
import { HttpChecker } from "../../../src/server/checker/runner/http/runner";
|
||||
import { CommandChecker } from "../../../src/server/checker/runner/command/runner";
|
||||
import { HttpChecker } from "../../../src/server/checker/runner/http/runner";
|
||||
|
||||
function createMockStore(targetNames: string[]) {
|
||||
let nextId = 1;
|
||||
const targets = targetNames.map((name) => ({ id: nextId++, name }));
|
||||
const results: Array<Record<string, unknown>> = [];
|
||||
|
||||
return {
|
||||
_results: results,
|
||||
getTargets() {
|
||||
return targets.map(({ id, name }) => ({
|
||||
config: "",
|
||||
expect: null,
|
||||
grp: "default",
|
||||
id,
|
||||
interval_ms: 60000,
|
||||
name,
|
||||
target: "",
|
||||
timeout_ms: 5000,
|
||||
type: "command" as const,
|
||||
}));
|
||||
},
|
||||
insertCheckResult(result: Record<string, unknown>) {
|
||||
results.push(result);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function ensureRegistered() {
|
||||
if (!checkerRegistry.supportedTypes.includes("http")) {
|
||||
@@ -13,46 +41,20 @@ function ensureRegistered() {
|
||||
}
|
||||
}
|
||||
|
||||
function createMockStore(targetNames: string[]) {
|
||||
let nextId = 1;
|
||||
const targets = targetNames.map((name) => ({ id: nextId++, name }));
|
||||
const results: Array<Record<string, unknown>> = [];
|
||||
|
||||
return {
|
||||
getTargets() {
|
||||
return targets.map(({ id, name }) => ({
|
||||
id,
|
||||
name,
|
||||
type: "command" as const,
|
||||
target: "",
|
||||
config: "",
|
||||
interval_ms: 60000,
|
||||
timeout_ms: 5000,
|
||||
expect: null,
|
||||
grp: "default",
|
||||
}));
|
||||
},
|
||||
insertCheckResult(result: Record<string, unknown>) {
|
||||
results.push(result);
|
||||
},
|
||||
_results: results,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCommandTarget(name: string, overrides?: Partial<ResolvedCommandTarget>): ResolvedCommandTarget {
|
||||
return {
|
||||
type: "command",
|
||||
name,
|
||||
group: "default",
|
||||
command: {
|
||||
exec: "echo",
|
||||
args: ["hello"],
|
||||
cwd: "/tmp",
|
||||
env: {},
|
||||
exec: "echo",
|
||||
maxOutputBytes: 1024 * 1024,
|
||||
},
|
||||
group: "default",
|
||||
intervalMs: 60000,
|
||||
name,
|
||||
timeoutMs: 5000,
|
||||
type: "command",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -80,16 +82,16 @@ describe("ProbeEngine", () => {
|
||||
|
||||
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]!.matched).toBe(true);
|
||||
expect(results[0]!.statusDetail).toBe("exitCode=0");
|
||||
expect(results[0]!["matched"]).toBe(true);
|
||||
expect(results[0]!["statusDetail"]).toBe("exitCode=0");
|
||||
});
|
||||
|
||||
test("多个目标并发执行", async () => {
|
||||
const targetA = makeCommandTarget("echo-a", {
|
||||
command: { exec: "echo", args: ["a"], cwd: "/tmp", env: {}, maxOutputBytes: 1024 * 1024 },
|
||||
command: { args: ["a"], cwd: "/tmp", env: {}, exec: "echo", maxOutputBytes: 1024 * 1024 },
|
||||
});
|
||||
const targetB = makeCommandTarget("echo-b", {
|
||||
command: { exec: "echo", args: ["b"], cwd: "/tmp", env: {}, maxOutputBytes: 1024 * 1024 },
|
||||
command: { args: ["b"], cwd: "/tmp", env: {}, exec: "echo", maxOutputBytes: 1024 * 1024 },
|
||||
});
|
||||
|
||||
const mockStore = createMockStore(["echo-a", "echo-b"]) as unknown as ProbeStore;
|
||||
@@ -106,7 +108,7 @@ describe("ProbeEngine", () => {
|
||||
|
||||
test("失败目标不阻塞其他目标", async () => {
|
||||
const badTarget = makeCommandTarget("bad-cmd", {
|
||||
command: { exec: "false", args: [], cwd: "/tmp", env: {}, maxOutputBytes: 1024 * 1024 },
|
||||
command: { args: [], cwd: "/tmp", env: {}, exec: "false", maxOutputBytes: 1024 * 1024 },
|
||||
});
|
||||
const goodTarget = makeCommandTarget("good-cmd");
|
||||
|
||||
@@ -121,8 +123,8 @@ describe("ProbeEngine", () => {
|
||||
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
||||
expect(results.length).toBe(2);
|
||||
|
||||
const badResult = results.find((r) => r.matched === false);
|
||||
const goodResult = results.find((r) => r.matched === true);
|
||||
const badResult = results.find((r) => r["matched"] === false);
|
||||
const goodResult = results.find((r) => r["matched"] === true);
|
||||
expect(badResult).toBeDefined();
|
||||
expect(goodResult).toBeDefined();
|
||||
});
|
||||
@@ -130,7 +132,7 @@ describe("ProbeEngine", () => {
|
||||
test("并发限制 maxConcurrentChecks", async () => {
|
||||
const targets = Array.from({ length: 5 }, (_, i) =>
|
||||
makeCommandTarget(`cmd-${i}`, {
|
||||
command: { exec: "echo", args: [String(i)], cwd: "/tmp", env: {}, maxOutputBytes: 1024 * 1024 },
|
||||
command: { args: [String(i)], cwd: "/tmp", env: {}, exec: "echo", maxOutputBytes: 1024 * 1024 },
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -145,7 +147,7 @@ describe("ProbeEngine", () => {
|
||||
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
||||
expect(results.length).toBe(5);
|
||||
for (const r of results) {
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r["matched"]).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -177,25 +179,25 @@ describe("ProbeEngine", () => {
|
||||
|
||||
test("HTTP 目标运行", async () => {
|
||||
const httpServer = Bun.serve({
|
||||
port: 0,
|
||||
fetch() {
|
||||
return new Response("ok");
|
||||
},
|
||||
port: 0,
|
||||
});
|
||||
|
||||
try {
|
||||
const httpTarget: ResolvedHttpTarget = {
|
||||
type: "http",
|
||||
name: "http-test",
|
||||
group: "default",
|
||||
http: {
|
||||
url: `http://localhost:${httpServer.port}/`,
|
||||
method: "GET",
|
||||
headers: {},
|
||||
maxBodyBytes: 1024 * 1024,
|
||||
method: "GET",
|
||||
url: `http://localhost:${httpServer.port}/`,
|
||||
},
|
||||
intervalMs: 60000,
|
||||
name: "http-test",
|
||||
timeoutMs: 5000,
|
||||
type: "http",
|
||||
};
|
||||
|
||||
const mockStore = createMockStore(["http-test"]) as unknown as ProbeStore;
|
||||
@@ -208,10 +210,10 @@ describe("ProbeEngine", () => {
|
||||
|
||||
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]!.matched).toBe(true);
|
||||
expect(results[0]!.statusDetail).toBe("HTTP 200");
|
||||
expect(results[0]!["matched"]).toBe(true);
|
||||
expect(results[0]!["statusDetail"]).toBe("HTTP 200");
|
||||
} finally {
|
||||
httpServer.stop();
|
||||
void httpServer.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { checkExitCode } from "../../../../../src/server/checker/runner/command/expect";
|
||||
|
||||
describe("checkExitCode", () => {
|
||||
|
||||
@@ -1,31 +1,11 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { CommandChecker } from "../../../../../src/server/checker/runner/command/runner";
|
||||
|
||||
import type { CheckerContext } from "../../../../../src/server/checker/runner/types";
|
||||
import type { ResolvedCommandTarget } from "../../../../../src/server/checker/types";
|
||||
|
||||
const checker = new CommandChecker();
|
||||
import { CommandChecker } from "../../../../../src/server/checker/runner/command/runner";
|
||||
|
||||
function makeTarget(
|
||||
command: Partial<ResolvedCommandTarget["command"]>,
|
||||
overrides?: Partial<ResolvedCommandTarget>,
|
||||
): ResolvedCommandTarget {
|
||||
return {
|
||||
type: "command",
|
||||
name: "test-cmd",
|
||||
group: "default",
|
||||
command: {
|
||||
exec: "echo",
|
||||
args: ["hello"],
|
||||
cwd: "/tmp",
|
||||
env: {},
|
||||
maxOutputBytes: 1024 * 1024,
|
||||
...command,
|
||||
},
|
||||
intervalMs: 60000,
|
||||
timeoutMs: 5000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
const checker = new CommandChecker();
|
||||
|
||||
function makeCtx(timeoutMs = 5000): CheckerContext {
|
||||
const controller = new AbortController();
|
||||
@@ -33,23 +13,48 @@ function makeCtx(timeoutMs = 5000): CheckerContext {
|
||||
return { signal: controller.signal };
|
||||
}
|
||||
|
||||
function makeTarget(
|
||||
command: Partial<ResolvedCommandTarget["command"]>,
|
||||
overrides?: Partial<ResolvedCommandTarget>,
|
||||
): ResolvedCommandTarget {
|
||||
return {
|
||||
command: {
|
||||
args: ["hello"],
|
||||
cwd: "/tmp",
|
||||
env: {},
|
||||
exec: "echo",
|
||||
maxOutputBytes: 1024 * 1024,
|
||||
...command,
|
||||
},
|
||||
group: "default",
|
||||
intervalMs: 60000,
|
||||
name: "test-cmd",
|
||||
timeoutMs: 5000,
|
||||
type: "command",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CommandChecker", () => {
|
||||
test("exitCode=0 成功", async () => {
|
||||
const result = await checker.execute(makeTarget({ exec: "true", args: [] }), makeCtx());
|
||||
const result = await checker.execute(makeTarget({ args: [], exec: "true" }), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("exitCode=0");
|
||||
expect(result.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("exitCode=1 不匹配默认 [0]", async () => {
|
||||
const result = await checker.execute(makeTarget({ exec: "false", args: [] }), makeCtx());
|
||||
const result = await checker.execute(makeTarget({ args: [], exec: "false" }), makeCtx());
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.statusDetail).toBe("exitCode=1");
|
||||
expect(result.failure!.phase).toBe("exitCode");
|
||||
});
|
||||
|
||||
test("exitCode=1 匹配自定义 [1]", async () => {
|
||||
const result = await checker.execute(makeTarget({ exec: "false", args: [] }, { expect: { exitCode: [1] } }), makeCtx());
|
||||
const result = await checker.execute(
|
||||
makeTarget({ args: [], exec: "false" }, { expect: { exitCode: [1] } }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("exitCode=1");
|
||||
});
|
||||
@@ -62,54 +67,69 @@ describe("CommandChecker", () => {
|
||||
});
|
||||
|
||||
test("超时返回错误", async () => {
|
||||
const result = await checker.execute(makeTarget({ exec: "sleep", args: ["10"] }, { timeoutMs: 100 }), makeCtx(100));
|
||||
const result = await checker.execute(makeTarget({ args: ["10"], exec: "sleep" }, { timeoutMs: 100 }), makeCtx(100));
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.message).toContain("超时");
|
||||
});
|
||||
|
||||
test("stdout 输出捕获", async () => {
|
||||
const result = await checker.execute(makeTarget({ exec: "echo", args: ["hello world"] }), makeCtx());
|
||||
const result = await checker.execute(makeTarget({ args: ["hello world"], exec: "echo" }), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("stdout 匹配 expect", async () => {
|
||||
const result = await checker.execute(makeTarget({ exec: "echo", args: ["hello"] }, { expect: { stdout: [{ contains: "hello" }] } }), makeCtx());
|
||||
const result = await checker.execute(
|
||||
makeTarget({ args: ["hello"], exec: "echo" }, { expect: { stdout: [{ contains: "hello" }] } }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("stdout 不匹配 expect", async () => {
|
||||
const result = await checker.execute(makeTarget({ exec: "echo", args: ["hello"] }, { expect: { stdout: [{ contains: "nonexistent" }] } }), makeCtx());
|
||||
const result = await checker.execute(
|
||||
makeTarget({ args: ["hello"], exec: "echo" }, { expect: { stdout: [{ contains: "nonexistent" }] } }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.phase).toBe("stdout");
|
||||
});
|
||||
|
||||
test("stderr 匹配 expect", async () => {
|
||||
const result = await checker.execute(makeTarget({ exec: "bash", args: ["-c", "echo error >&2"] }, { expect: { stderr: [{ contains: "error" }] } }), makeCtx());
|
||||
const result = await checker.execute(
|
||||
makeTarget({ args: ["-c", "echo error >&2"], exec: "bash" }, { expect: { stderr: [{ contains: "error" }] } }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("输出超过 maxOutputBytes", async () => {
|
||||
const result = await checker.execute(makeTarget({ exec: "bash", args: ["-c", "yes | head -1000"], maxOutputBytes: 10 }), makeCtx());
|
||||
const result = await checker.execute(
|
||||
makeTarget({ args: ["-c", "yes | head -1000"], exec: "bash", maxOutputBytes: 10 }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.message).toContain("超过限制");
|
||||
});
|
||||
|
||||
test("durationMs 非空", async () => {
|
||||
const result = await checker.execute(makeTarget({ exec: "true", args: [] }), makeCtx());
|
||||
const result = await checker.execute(makeTarget({ args: [], exec: "true" }), makeCtx());
|
||||
expect(result.durationMs).not.toBeNull();
|
||||
expect(result.durationMs!).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test("不使用 shell,通配符不被展开", async () => {
|
||||
const result = await checker.execute(makeTarget({ exec: "echo", args: ["*"] }, { expect: { stdout: [{ contains: "*" }] } }), makeCtx());
|
||||
const result = await checker.execute(
|
||||
makeTarget({ args: ["*"], exec: "echo" }, { expect: { stdout: [{ contains: "*" }] } }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("serialize 返回命令摘要和 config JSON", () => {
|
||||
const target = makeTarget({ exec: "echo", args: ["hello"] });
|
||||
const target = makeTarget({ args: ["hello"], exec: "echo" });
|
||||
const s = checker.serialize(target);
|
||||
expect(s.target).toBe("exec echo hello");
|
||||
const config = JSON.parse(s.config);
|
||||
const config = JSON.parse(s.config) as { args: string[]; exec: string };
|
||||
expect(config.exec).toBe("echo");
|
||||
expect(config.args).toEqual(["hello"]);
|
||||
});
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { checkHttpExpect } from "../../../../../src/server/checker/runner/http/expect";
|
||||
|
||||
function obs(overrides: { statusCode?: number; headers?: Record<string, string>; body?: string | null; durationMs?: number } = {}) {
|
||||
function obs(
|
||||
overrides: { body?: null | string; durationMs?: number; headers?: Record<string, string>; statusCode?: number } = {},
|
||||
) {
|
||||
return {
|
||||
statusCode: overrides.statusCode ?? 200,
|
||||
headers: overrides.headers ?? {},
|
||||
body: overrides.body ?? "",
|
||||
durationMs: overrides.durationMs ?? 100,
|
||||
headers: overrides.headers ?? {},
|
||||
statusCode: overrides.statusCode ?? 200,
|
||||
};
|
||||
}
|
||||
|
||||
describe("checkHttpExpect", () => {
|
||||
test("无 expect 配置时默认检查 status [200] 匹配成功", () => {
|
||||
const r = checkHttpExpect(obs().statusCode, obs().headers, obs().body as string, obs().durationMs);
|
||||
const r = checkHttpExpect(obs().statusCode, obs().headers, obs().body, obs().durationMs);
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
@@ -65,7 +68,9 @@ describe("checkHttpExpect", () => {
|
||||
test("headers 操作符格式检查", () => {
|
||||
const h = { "content-type": "application/json" };
|
||||
expect(checkHttpExpect(200, h, "", 100, { headers: { "content-type": { contains: "json" } } }).matched).toBe(true);
|
||||
expect(checkHttpExpect(200, h, "", 100, { headers: { "content-type": { match: "^application/" } } }).matched).toBe(true);
|
||||
expect(checkHttpExpect(200, h, "", 100, { headers: { "content-type": { match: "^application/" } } }).matched).toBe(
|
||||
true,
|
||||
);
|
||||
expect(checkHttpExpect(200, h, "", 100, { headers: { "content-type": { contains: "xml" } } }).matched).toBe(false);
|
||||
});
|
||||
|
||||
@@ -81,9 +86,9 @@ describe("checkHttpExpect", () => {
|
||||
});
|
||||
|
||||
test("body 规则数组按顺序检查", () => {
|
||||
const body = JSON.stringify({ status: "ok", count: 5 });
|
||||
const body = JSON.stringify({ count: 5, status: "ok" });
|
||||
const r = checkHttpExpect(200, {}, body, 100, {
|
||||
body: [{ contains: "ok" }, { json: { path: "$.count", gte: 1 } }],
|
||||
body: [{ contains: "ok" }, { json: { gte: 1, path: "$.count" } }],
|
||||
});
|
||||
expect(r.matched).toBe(true);
|
||||
});
|
||||
@@ -104,26 +109,26 @@ describe("checkHttpExpect", () => {
|
||||
|
||||
test("完整流水线 status->duration->headers->body 全部通过", () => {
|
||||
const r = checkHttpExpect(200, { "content-type": "application/json" }, JSON.stringify({ status: "healthy" }), 50, {
|
||||
status: [200],
|
||||
maxDurationMs: 100,
|
||||
body: [{ json: { equals: "healthy", path: "$.status" } }],
|
||||
headers: { "content-type": { contains: "json" } },
|
||||
body: [{ json: { path: "$.status", equals: "healthy" } }],
|
||||
maxDurationMs: 100,
|
||||
status: [200],
|
||||
});
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("完整流水线 status 通过但 duration 失败", () => {
|
||||
const r = checkHttpExpect(200, {}, "", 500, { status: [200], maxDurationMs: 100 });
|
||||
const r = checkHttpExpect(200, {}, "", 500, { maxDurationMs: 100, status: [200] });
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("duration");
|
||||
});
|
||||
|
||||
test("完整流水线 status 和 duration 通过但 headers 失败", () => {
|
||||
const r = checkHttpExpect(200, { "x-api": "v1" }, "", 50, {
|
||||
status: [200],
|
||||
maxDurationMs: 100,
|
||||
headers: { "x-api": "v2" },
|
||||
maxDurationMs: 100,
|
||||
status: [200],
|
||||
});
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("headers");
|
||||
@@ -131,10 +136,10 @@ describe("checkHttpExpect", () => {
|
||||
|
||||
test("完整流水线 status/duration/headers 通过但 body 失败", () => {
|
||||
const r = checkHttpExpect(200, { "content-type": "text/plain" }, "error occurred", 50, {
|
||||
status: [200],
|
||||
maxDurationMs: 100,
|
||||
headers: { "content-type": "text/plain" },
|
||||
body: [{ contains: "success" }],
|
||||
headers: { "content-type": "text/plain" },
|
||||
maxDurationMs: 100,
|
||||
status: [200],
|
||||
});
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.phase).toBe("body");
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { HttpChecker } from "../../../../../src/server/checker/runner/http/runner";
|
||||
|
||||
import type { CheckerContext } from "../../../../../src/server/checker/runner/types";
|
||||
import type { ResolvedHttpTarget } from "../../../../../src/server/checker/types";
|
||||
|
||||
import { HttpChecker } from "../../../../../src/server/checker/runner/http/runner";
|
||||
|
||||
const checker = new HttpChecker();
|
||||
|
||||
describe("HttpChecker", () => {
|
||||
@@ -11,61 +13,61 @@ describe("HttpChecker", () => {
|
||||
|
||||
beforeAll(() => {
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
switch (url.pathname) {
|
||||
case "/ok":
|
||||
return new Response("hello world", {
|
||||
headers: { "content-type": "text/plain", "x-custom": "test-value" },
|
||||
case "/echo":
|
||||
return new Response(JSON.stringify({ body: req.body ? "present" : "empty", method: req.method }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
case "/json":
|
||||
return new Response(JSON.stringify({ status: "ok" }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
case "/echo":
|
||||
return new Response(JSON.stringify({ method: req.method, body: req.body ? "present" : "empty" }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
case "/large":
|
||||
return new Response("x".repeat(2000));
|
||||
case "/notfound":
|
||||
return new Response("not found", { status: 404 });
|
||||
case "/ok":
|
||||
return new Response("hello world", {
|
||||
headers: { "content-type": "text/plain", "x-custom": "test-value" },
|
||||
});
|
||||
default:
|
||||
return new Response("ok");
|
||||
}
|
||||
},
|
||||
port: 0,
|
||||
});
|
||||
baseUrl = `http://localhost:${server.port}`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.stop();
|
||||
void server.stop();
|
||||
});
|
||||
|
||||
function makeTarget(overrides: {
|
||||
url?: string;
|
||||
method?: string;
|
||||
body?: string;
|
||||
headers?: Record<string, string>;
|
||||
expect?: Record<string, unknown>;
|
||||
headers?: Record<string, string>;
|
||||
maxBodyBytes?: number;
|
||||
method?: string;
|
||||
timeoutMs?: number;
|
||||
url?: string;
|
||||
}): ResolvedHttpTarget {
|
||||
return {
|
||||
type: "http",
|
||||
name: "test-http",
|
||||
expect: overrides.expect,
|
||||
group: "default",
|
||||
http: {
|
||||
url: overrides.url ?? `${baseUrl}/ok`,
|
||||
method: overrides.method ?? "GET",
|
||||
headers: overrides.headers ?? {},
|
||||
body: overrides.body,
|
||||
headers: overrides.headers ?? {},
|
||||
maxBodyBytes: overrides.maxBodyBytes ?? 1024 * 1024,
|
||||
method: overrides.method ?? "GET",
|
||||
url: overrides.url ?? `${baseUrl}/ok`,
|
||||
},
|
||||
intervalMs: 60000,
|
||||
name: "test-http",
|
||||
timeoutMs: overrides.timeoutMs ?? 5000,
|
||||
expect: overrides.expect as ResolvedHttpTarget["expect"],
|
||||
type: "http",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -91,39 +93,60 @@ describe("HttpChecker", () => {
|
||||
});
|
||||
|
||||
test("404 匹配自定义 status [404]", async () => {
|
||||
const result = await checker.execute(makeTarget({ url: `${baseUrl}/notfound`, expect: { status: [404] } }), makeCtx());
|
||||
const result = await checker.execute(
|
||||
makeTarget({ expect: { status: [404] }, url: `${baseUrl}/notfound` }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("headers 检查通过", async () => {
|
||||
const result = await checker.execute(makeTarget({ url: `${baseUrl}/ok`, expect: { headers: { "x-custom": "test-value" } } }), makeCtx());
|
||||
const result = await checker.execute(
|
||||
makeTarget({ expect: { headers: { "x-custom": "test-value" } }, url: `${baseUrl}/ok` }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("headers 检查失败", async () => {
|
||||
const result = await checker.execute(makeTarget({ url: `${baseUrl}/ok`, expect: { headers: { "x-custom": "wrong-value" } } }), makeCtx());
|
||||
const result = await checker.execute(
|
||||
makeTarget({ expect: { headers: { "x-custom": "wrong-value" } }, url: `${baseUrl}/ok` }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.phase).toBe("headers");
|
||||
});
|
||||
|
||||
test("body contains 检查", async () => {
|
||||
const result = await checker.execute(makeTarget({ url: `${baseUrl}/ok`, expect: { body: [{ contains: "hello" }] } }), makeCtx());
|
||||
const result = await checker.execute(
|
||||
makeTarget({ expect: { body: [{ contains: "hello" }] }, url: `${baseUrl}/ok` }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("body contains 失败", async () => {
|
||||
const result = await checker.execute(makeTarget({ url: `${baseUrl}/ok`, expect: { body: [{ contains: "nonexistent" }] } }), makeCtx());
|
||||
const result = await checker.execute(
|
||||
makeTarget({ expect: { body: [{ contains: "nonexistent" }] }, url: `${baseUrl}/ok` }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.phase).toBe("body");
|
||||
});
|
||||
|
||||
test("body json 检查", async () => {
|
||||
const result = await checker.execute(makeTarget({ url: `${baseUrl}/json`, expect: { body: [{ json: { path: "$.status", equals: "ok" } }] } }), makeCtx());
|
||||
const result = await checker.execute(
|
||||
makeTarget({ expect: { body: [{ json: { equals: "ok", path: "$.status" } }] }, url: `${baseUrl}/json` }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("响应体超过 maxBodyBytes", async () => {
|
||||
const result = await checker.execute(makeTarget({ url: `${baseUrl}/large`, maxBodyBytes: 100, expect: { body: [{ contains: "x" }] } }), makeCtx());
|
||||
const result = await checker.execute(
|
||||
makeTarget({ expect: { body: [{ contains: "x" }] }, maxBodyBytes: 100, url: `${baseUrl}/large` }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.phase).toBe("body");
|
||||
expect(result.failure!.message).toContain("超过限制");
|
||||
@@ -131,41 +154,59 @@ describe("HttpChecker", () => {
|
||||
|
||||
test("请求超时", async () => {
|
||||
const timeoutServer = Bun.serve({
|
||||
port: 0,
|
||||
async fetch() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10000));
|
||||
return new Response("late");
|
||||
},
|
||||
port: 0,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await checker.execute(makeTarget({ url: `http://localhost:${timeoutServer.port}/`, timeoutMs: 100 }), makeCtx(100));
|
||||
const result = await checker.execute(
|
||||
makeTarget({ timeoutMs: 100, url: `http://localhost:${timeoutServer.port}/` }),
|
||||
makeCtx(100),
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.message).toContain("超时");
|
||||
} finally {
|
||||
timeoutServer.stop();
|
||||
void timeoutServer.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("快速失败:status 失败时不读取 body", async () => {
|
||||
const result = await checker.execute(makeTarget({ url: `${baseUrl}/notfound`, expect: { status: [200], body: [{ contains: "something" }] } }), makeCtx());
|
||||
const result = await checker.execute(
|
||||
makeTarget({ expect: { body: [{ contains: "something" }], status: [200] }, url: `${baseUrl}/notfound` }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.phase).toBe("status");
|
||||
});
|
||||
|
||||
test("status 通过但 body 失败", async () => {
|
||||
const result = await checker.execute(makeTarget({ url: `${baseUrl}/ok`, expect: { status: [200], body: [{ contains: "not-in-body" }] } }), makeCtx());
|
||||
const result = await checker.execute(
|
||||
makeTarget({ expect: { body: [{ contains: "not-in-body" }], status: [200] }, url: `${baseUrl}/ok` }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.phase).toBe("body");
|
||||
});
|
||||
|
||||
test("无 expect 时默认检查 status 200", async () => {
|
||||
const result = await checker.execute(makeTarget({ url: `${baseUrl}/ok`, expect: undefined }), makeCtx());
|
||||
const result = await checker.execute(makeTarget({ expect: undefined, url: `${baseUrl}/ok` }), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("POST 请求携带 body", async () => {
|
||||
const result = await checker.execute(makeTarget({ url: `${baseUrl}/echo`, method: "POST", body: "test-body", headers: { "content-type": "text/plain" }, expect: { status: [200], body: [{ json: { path: "$.body", equals: "present" } }] } }), makeCtx());
|
||||
const result = await checker.execute(
|
||||
makeTarget({
|
||||
body: "test-body",
|
||||
expect: { body: [{ json: { equals: "present", path: "$.body" } }], status: [200] },
|
||||
headers: { "content-type": "text/plain" },
|
||||
method: "POST",
|
||||
url: `${baseUrl}/echo`,
|
||||
}),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
@@ -173,7 +214,7 @@ describe("HttpChecker", () => {
|
||||
const target = makeTarget({});
|
||||
const s = checker.serialize(target);
|
||||
expect(s.target).toBe(target.http.url);
|
||||
const config = JSON.parse(s.config);
|
||||
const config = JSON.parse(s.config) as { method: string; url: string };
|
||||
expect(config.url).toBe(target.http.url);
|
||||
expect(config.method).toBe("GET");
|
||||
});
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { CheckerRegistry } from "../../../../src/server/checker/runner/registry";
|
||||
|
||||
import type { Checker } from "../../../../src/server/checker/runner/types";
|
||||
import type { CheckResult, ResolvedTarget } from "../../../../src/server/checker/types";
|
||||
|
||||
import { CheckerRegistry } from "../../../../src/server/checker/runner/registry";
|
||||
|
||||
function createChecker(type: string): Checker {
|
||||
return {
|
||||
execute: () => Promise.resolve<CheckResult>({} as unknown as CheckResult),
|
||||
resolve: () => ({}) as unknown as ResolvedTarget,
|
||||
serialize: () => ({ config: "", target: "" }),
|
||||
type,
|
||||
resolve: () => ({}) as any,
|
||||
execute: () => Promise.resolve({} as any),
|
||||
serialize: () => ({ target: "", config: "" }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { checkBodyExpect } from "../../../../../src/server/checker/runner/shared/body";
|
||||
|
||||
describe("checkBodyExpect (BodyRule[])", () => {
|
||||
@@ -41,89 +42,89 @@ describe("checkBodyExpect (BodyRule[])", () => {
|
||||
});
|
||||
|
||||
test("json 等值匹配成功", () => {
|
||||
const body = JSON.stringify({ status: "ok", code: 0 });
|
||||
const r = checkBodyExpect(body, [{ json: { path: "$.status", equals: "ok" } }]);
|
||||
const body = JSON.stringify({ code: 0, status: "ok" });
|
||||
const r = checkBodyExpect(body, [{ json: { equals: "ok", path: "$.status" } }]);
|
||||
expect(r.matched).toBe(true);
|
||||
});
|
||||
|
||||
test("json 等值匹配失败", () => {
|
||||
const body = JSON.stringify({ status: "ok" });
|
||||
const r = checkBodyExpect(body, [{ json: { path: "$.status", equals: "error" } }]);
|
||||
const r = checkBodyExpect(body, [{ json: { equals: "error", path: "$.status" } }]);
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.kind).toBe("mismatch");
|
||||
});
|
||||
|
||||
test("json 操作符匹配", () => {
|
||||
const body = JSON.stringify({ count: 42, version: "v2.1.0" });
|
||||
expect(checkBodyExpect(body, [{ json: { path: "$.count", gte: 10 } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(body, [{ json: { path: "$.version", match: "\\d+\\.\\d+\\.\\d+" } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(body, [{ json: { path: "$.count", gte: 100 } }]).matched).toBe(false);
|
||||
expect(checkBodyExpect(body, [{ json: { gte: 10, path: "$.count" } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(body, [{ json: { match: "\\d+\\.\\d+\\.\\d+", path: "$.version" } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(body, [{ json: { gte: 100, path: "$.count" } }]).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("json 路径不存在", () => {
|
||||
const body = JSON.stringify({ status: "ok" });
|
||||
const r = checkBodyExpect(body, [{ json: { path: "$.notExist", equals: "value" } }]);
|
||||
const r = checkBodyExpect(body, [{ json: { equals: "value", path: "$.notExist" } }]);
|
||||
expect(r.matched).toBe(false);
|
||||
});
|
||||
|
||||
test("json 解析失败", () => {
|
||||
const r = checkBodyExpect("not json", [{ json: { path: "$.status", equals: "ok" } }]);
|
||||
const r = checkBodyExpect("not json", [{ json: { equals: "ok", path: "$.status" } }]);
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.kind).toBe("error");
|
||||
});
|
||||
|
||||
test("css 文本内容匹配", () => {
|
||||
const html = "<div id='health'>OK</div><span class='ver'>1.0</span>";
|
||||
expect(checkBodyExpect(html, [{ css: { selector: "div#health", equals: "OK" } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(html, [{ css: { selector: "span.ver", equals: "1.0" } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(html, [{ css: { selector: "div#health", equals: "ERROR" } }]).matched).toBe(false);
|
||||
expect(checkBodyExpect(html, [{ css: { equals: "OK", selector: "div#health" } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(html, [{ css: { equals: "1.0", selector: "span.ver" } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(html, [{ css: { equals: "ERROR", selector: "div#health" } }]).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("css 选择器无匹配元素", () => {
|
||||
const html = "<div>OK</div>";
|
||||
const r = checkBodyExpect(html, [{ css: { selector: "span.missing", equals: "OK" } }]);
|
||||
const r = checkBodyExpect(html, [{ css: { equals: "OK", selector: "span.missing" } }]);
|
||||
expect(r.matched).toBe(false);
|
||||
});
|
||||
|
||||
test("css attr 提取", () => {
|
||||
const html = '<meta name="version" content="2.0.1">';
|
||||
expect(
|
||||
checkBodyExpect(html, [{ css: { selector: 'meta[name="version"]', attr: "content", equals: "2.0.1" } }]).matched,
|
||||
checkBodyExpect(html, [{ css: { attr: "content", equals: "2.0.1", selector: 'meta[name="version"]' } }]).matched,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("css exists 检查", () => {
|
||||
const html = "<div id='test'>OK</div>";
|
||||
expect(checkBodyExpect(html, [{ css: { selector: "div#test", exists: true } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(html, [{ css: { selector: "span#missing", exists: false } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(html, [{ css: { selector: "div#test", exists: false } }]).matched).toBe(false);
|
||||
expect(checkBodyExpect(html, [{ css: { exists: true, selector: "div#test" } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(html, [{ css: { exists: false, selector: "span#missing" } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(html, [{ css: { exists: false, selector: "div#test" } }]).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("xpath 节点文本匹配", () => {
|
||||
const xml = "<root><status>ok</status><code>200</code></root>";
|
||||
expect(checkBodyExpect(xml, [{ xpath: { path: "/root/status/text()", equals: "ok" } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(xml, [{ xpath: { path: "/root/status/text()", equals: "error" } }]).matched).toBe(false);
|
||||
expect(checkBodyExpect(xml, [{ xpath: { equals: "ok", path: "/root/status/text()" } }]).matched).toBe(true);
|
||||
expect(checkBodyExpect(xml, [{ xpath: { equals: "error", path: "/root/status/text()" } }]).matched).toBe(false);
|
||||
});
|
||||
|
||||
test("xpath 无匹配节点", () => {
|
||||
const xml = "<root><status>ok</status></root>";
|
||||
const r = checkBodyExpect(xml, [{ xpath: { path: "/root/missing/text()", equals: "ok" } }]);
|
||||
const r = checkBodyExpect(xml, [{ xpath: { equals: "ok", path: "/root/missing/text()" } }]);
|
||||
expect(r.matched).toBe(false);
|
||||
});
|
||||
|
||||
test("规则数组按顺序检查,第一条失败立即返回", () => {
|
||||
const body = JSON.stringify({ status: "error" });
|
||||
const r = checkBodyExpect(body, [{ contains: "healthy" }, { json: { path: "$.status", equals: "error" } }]);
|
||||
const r = checkBodyExpect(body, [{ contains: "healthy" }, { json: { equals: "error", path: "$.status" } }]);
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.path).toBe("body[0]");
|
||||
});
|
||||
|
||||
test("多条规则全部通过", () => {
|
||||
const body = JSON.stringify({ status: "healthy", count: 5 });
|
||||
const body = JSON.stringify({ count: 5, status: "healthy" });
|
||||
const r = checkBodyExpect(body, [
|
||||
{ contains: "healthy" },
|
||||
{ json: { path: "$.status", equals: "healthy" } },
|
||||
{ json: { path: "$.count", gte: 1 } },
|
||||
{ json: { equals: "healthy", path: "$.status" } },
|
||||
{ json: { gte: 1, path: "$.count" } },
|
||||
]);
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.failure).toBeNull();
|
||||
@@ -131,7 +132,7 @@ describe("checkBodyExpect (BodyRule[])", () => {
|
||||
|
||||
test("第二条规则失败返回正确索引", () => {
|
||||
const body = JSON.stringify({ status: "ok" });
|
||||
const r = checkBodyExpect(body, [{ contains: "ok" }, { json: { path: "$.status", equals: "error" } }]);
|
||||
const r = checkBodyExpect(body, [{ contains: "ok" }, { json: { equals: "error", path: "$.status" } }]);
|
||||
expect(r.matched).toBe(false);
|
||||
expect(r.failure!.path).toContain("body[1]");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { checkDuration } from "../../../../../src/server/checker/runner/shared/duration";
|
||||
|
||||
describe("checkDuration", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { truncateActual, mismatchFailure, errorFailure } from "../../../../../src/server/checker/runner/shared/failure";
|
||||
|
||||
import { errorFailure, mismatchFailure, truncateActual } from "../../../../../src/server/checker/runner/shared/failure";
|
||||
|
||||
describe("truncateActual", () => {
|
||||
test("短字符串不截断", () => {
|
||||
@@ -43,12 +44,12 @@ describe("mismatchFailure", () => {
|
||||
test("返回正确的 mismatch 结构", () => {
|
||||
const f = mismatchFailure("status", "status", [200], 500, "status mismatch");
|
||||
expect(f).toEqual({
|
||||
kind: "mismatch",
|
||||
phase: "status",
|
||||
path: "status",
|
||||
expected: [200],
|
||||
actual: 500,
|
||||
expected: [200],
|
||||
kind: "mismatch",
|
||||
message: "status mismatch",
|
||||
path: "status",
|
||||
phase: "status",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,9 +66,9 @@ describe("errorFailure", () => {
|
||||
const f = errorFailure("body", "body[0].json($.x)", "body is not valid JSON");
|
||||
expect(f).toEqual({
|
||||
kind: "error",
|
||||
phase: "body",
|
||||
path: "body[0].json($.x)",
|
||||
message: "body is not valid JSON",
|
||||
path: "body[0].json($.x)",
|
||||
phase: "body",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { applyOperator, checkExpectValue, evaluateJsonPath } from "../../../../../src/server/checker/runner/shared/operator";
|
||||
|
||||
import {
|
||||
applyOperator,
|
||||
checkExpectValue,
|
||||
evaluateJsonPath,
|
||||
} from "../../../../../src/server/checker/runner/shared/operator";
|
||||
|
||||
describe("evaluateJsonPath", () => {
|
||||
const obj = {
|
||||
status: "ok",
|
||||
code: 0,
|
||||
active: true,
|
||||
error: null,
|
||||
code: 0,
|
||||
data: {
|
||||
count: 42,
|
||||
items: [{ name: "a" }, { name: "b" }],
|
||||
nested: { deep: "value" },
|
||||
},
|
||||
emptyObj: {},
|
||||
emptyArr: [],
|
||||
emptyObj: {},
|
||||
error: null,
|
||||
status: "ok",
|
||||
};
|
||||
|
||||
test("简单字段访问", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { checkTextRules } from "../../../../../src/server/checker/runner/shared/text";
|
||||
|
||||
describe("checkTextRules", () => {
|
||||
@@ -21,7 +22,11 @@ describe("checkTextRules", () => {
|
||||
});
|
||||
|
||||
test("多条规则全部通过", () => {
|
||||
const r = checkTextRules("version: 3.2.1, build: ok", [{ contains: "version" }, { match: "\\d+\\.\\d+\\.\\d+" }], "stdout");
|
||||
const r = checkTextRules(
|
||||
"version: 3.2.1, build: ok",
|
||||
[{ contains: "version" }, { match: "\\d+\\.\\d+\\.\\d+" }],
|
||||
"stdout",
|
||||
);
|
||||
expect(r.matched).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { parseSize } from "../../../src/server/checker/size";
|
||||
|
||||
describe("parseSize", () => {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { ProbeStore } from "../../../src/server/checker/store";
|
||||
import type { CheckFailure, ResolvedTarget } from "../../../src/server/checker/types";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { CheckFailure, ResolvedTarget } from "../../../src/server/checker/types";
|
||||
|
||||
import { checkerRegistry } from "../../../src/server/checker/runner";
|
||||
import { HttpChecker } from "../../../src/server/checker/runner/http/runner";
|
||||
import { CommandChecker } from "../../../src/server/checker/runner/command/runner";
|
||||
import { HttpChecker } from "../../../src/server/checker/runner/http/runner";
|
||||
import { ProbeStore } from "../../../src/server/checker/store";
|
||||
|
||||
function ensureRegistered() {
|
||||
if (!checkerRegistry.supportedTypes.includes("http")) {
|
||||
@@ -20,33 +22,33 @@ beforeAll(() => {
|
||||
});
|
||||
|
||||
const httpTarget: ResolvedTarget = {
|
||||
type: "http",
|
||||
name: "test-http",
|
||||
expect: { maxDurationMs: 3000, status: [200] },
|
||||
group: "default",
|
||||
http: {
|
||||
url: "https://example.com/health",
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
maxBodyBytes: 104857600,
|
||||
method: "GET",
|
||||
url: "https://example.com/health",
|
||||
},
|
||||
intervalMs: 30000,
|
||||
name: "test-http",
|
||||
timeoutMs: 10000,
|
||||
expect: { status: [200], maxDurationMs: 3000 },
|
||||
type: "http",
|
||||
};
|
||||
|
||||
const commandTarget: ResolvedTarget = {
|
||||
type: "command",
|
||||
name: "test-cmd",
|
||||
group: "default",
|
||||
command: {
|
||||
exec: "ping",
|
||||
args: ["-c", "1", "localhost"],
|
||||
cwd: "/tmp",
|
||||
env: {},
|
||||
exec: "ping",
|
||||
maxOutputBytes: 104857600,
|
||||
},
|
||||
group: "default",
|
||||
intervalMs: 60000,
|
||||
name: "test-cmd",
|
||||
timeoutMs: 5000,
|
||||
type: "command",
|
||||
};
|
||||
|
||||
describe("ProbeStore", () => {
|
||||
@@ -61,7 +63,7 @@ describe("ProbeStore", () => {
|
||||
|
||||
afterAll(async () => {
|
||||
store.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
await rm(tempDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
test("初始化后无 targets", () => {
|
||||
@@ -80,21 +82,26 @@ describe("ProbeStore", () => {
|
||||
const t = store.getTargets().find((t) => t.name === "test-http")!;
|
||||
expect(t.type).toBe("http");
|
||||
expect(t.target).toBe("https://example.com/health");
|
||||
const config = JSON.parse(t.config);
|
||||
const config = JSON.parse(t.config) as {
|
||||
headers: Record<string, string>;
|
||||
maxBodyBytes: number;
|
||||
method: string;
|
||||
url: string;
|
||||
};
|
||||
expect(config.url).toBe("https://example.com/health");
|
||||
expect(config.method).toBe("GET");
|
||||
expect(config.headers).toEqual({ Accept: "application/json" });
|
||||
expect(config.maxBodyBytes).toBe(104857600);
|
||||
expect(t.interval_ms).toBe(30000);
|
||||
expect(t.timeout_ms).toBe(10000);
|
||||
expect(JSON.parse(t.expect!)).toEqual({ status: [200], maxDurationMs: 3000 });
|
||||
expect(JSON.parse(t.expect!)).toEqual({ maxDurationMs: 3000, status: [200] });
|
||||
});
|
||||
|
||||
test("command target 字段正确", () => {
|
||||
const t = store.getTargets().find((t) => t.name === "test-cmd")!;
|
||||
expect(t.type).toBe("command");
|
||||
expect(t.target).toBe("exec ping -c 1 localhost");
|
||||
const config = JSON.parse(t.config);
|
||||
const config = JSON.parse(t.config) as { args: string[]; cwd: string; exec: string; maxOutputBytes: number };
|
||||
expect(config.exec).toBe("ping");
|
||||
expect(config.args).toEqual(["-c", "1", "localhost"]);
|
||||
expect(config.cwd).toBe("/tmp");
|
||||
@@ -144,39 +151,39 @@ describe("ProbeStore", () => {
|
||||
const t1Id = targets[0]!.id;
|
||||
|
||||
store.insertCheckResult({
|
||||
durationMs: 150.5,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
targetId: t1Id,
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
matched: true,
|
||||
durationMs: 150.5,
|
||||
statusDetail: "200 OK",
|
||||
failure: null,
|
||||
});
|
||||
|
||||
store.insertCheckResult({
|
||||
durationMs: 300,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
targetId: t1Id,
|
||||
timestamp: "2025-01-01T00:00:30.000Z",
|
||||
matched: true,
|
||||
durationMs: 300,
|
||||
statusDetail: "200 OK",
|
||||
failure: null,
|
||||
});
|
||||
|
||||
const failure: CheckFailure = {
|
||||
kind: "error",
|
||||
phase: "duration",
|
||||
path: "$.maxDurationMs",
|
||||
expected: 3000,
|
||||
actual: 5000,
|
||||
expected: 3000,
|
||||
kind: "error",
|
||||
message: "请求耗时 5000ms 超过限制 3000ms",
|
||||
path: "$.maxDurationMs",
|
||||
phase: "duration",
|
||||
};
|
||||
|
||||
store.insertCheckResult({
|
||||
durationMs: null,
|
||||
failure,
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetId: t1Id,
|
||||
timestamp: "2025-01-01T00:01:00.000Z",
|
||||
matched: false,
|
||||
durationMs: null,
|
||||
statusDetail: null,
|
||||
failure,
|
||||
});
|
||||
|
||||
const history = store.getHistory(t1Id, "2025-01-01T00:00:00.000Z", "2025-12-31T23:59:59.999Z", 1, 10);
|
||||
@@ -198,12 +205,12 @@ describe("ProbeStore", () => {
|
||||
|
||||
for (let i = 0; i < 25; i++) {
|
||||
store.insertCheckResult({
|
||||
durationMs: 100 + i,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
targetId: t1Id,
|
||||
timestamp: `2025-01-01T01:${String(i).padStart(2, "0")}:00.000Z`,
|
||||
matched: true,
|
||||
durationMs: 100 + i,
|
||||
statusDetail: "200 OK",
|
||||
failure: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -274,32 +281,32 @@ describe("ProbeStore", () => {
|
||||
test("删除 target 级联删除 check_results", () => {
|
||||
const cascadeStore = new ProbeStore(join(tempDir, "cascade.db"));
|
||||
const cascadeTarget: ResolvedTarget = {
|
||||
type: "http",
|
||||
name: "cascade-test",
|
||||
group: "default",
|
||||
http: { url: "http://cascade.test", method: "GET", headers: {}, maxBodyBytes: 104857600 },
|
||||
http: { headers: {}, maxBodyBytes: 104857600, method: "GET", url: "http://cascade.test" },
|
||||
intervalMs: 30000,
|
||||
name: "cascade-test",
|
||||
timeoutMs: 10000,
|
||||
type: "http",
|
||||
};
|
||||
|
||||
cascadeStore.syncTargets([cascadeTarget]);
|
||||
const t = cascadeStore.getTargets()[0]!;
|
||||
|
||||
cascadeStore.insertCheckResult({
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
targetId: t.id,
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
matched: true,
|
||||
durationMs: 100,
|
||||
statusDetail: "200 OK",
|
||||
failure: null,
|
||||
});
|
||||
cascadeStore.insertCheckResult({
|
||||
durationMs: null,
|
||||
failure: { kind: "error", message: "fail", path: "$", phase: "status" },
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetId: t.id,
|
||||
timestamp: "2025-01-01T00:01:00.000Z",
|
||||
matched: false,
|
||||
durationMs: null,
|
||||
statusDetail: null,
|
||||
failure: { kind: "error", phase: "status", path: "$", message: "fail" },
|
||||
});
|
||||
|
||||
expect(cascadeStore.getLatestCheck(t.id)).not.toBeNull();
|
||||
@@ -329,12 +336,12 @@ describe("ProbeStore", () => {
|
||||
const freshStore = new ProbeStore(join(tempDir, "fresh-map.db"));
|
||||
freshStore.syncTargets([
|
||||
{
|
||||
type: "http",
|
||||
name: "no-records",
|
||||
group: "default",
|
||||
http: { url: "http://no.records", method: "GET", headers: {}, maxBodyBytes: 104857600 },
|
||||
http: { headers: {}, maxBodyBytes: 104857600, method: "GET", url: "http://no.records" },
|
||||
intervalMs: 30000,
|
||||
name: "no-records",
|
||||
timeoutMs: 10000,
|
||||
type: "http",
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -359,8 +366,8 @@ describe("ProbeStore", () => {
|
||||
|
||||
const stats2 = stats.get(t2Id);
|
||||
if (stats2) {
|
||||
expect(stats2!.totalChecks).toBe(0);
|
||||
expect(stats2!.availability).toBe(0);
|
||||
expect(stats2.totalChecks).toBe(0);
|
||||
expect(stats2.availability).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -368,12 +375,12 @@ describe("ProbeStore", () => {
|
||||
const freshStore = new ProbeStore(join(tempDir, "fresh-stats.db"));
|
||||
freshStore.syncTargets([
|
||||
{
|
||||
type: "http",
|
||||
name: "no-stats",
|
||||
group: "default",
|
||||
http: { url: "http://no.stats", method: "GET", headers: {}, maxBodyBytes: 104857600 },
|
||||
http: { headers: {}, maxBodyBytes: 104857600, method: "GET", url: "http://no.stats" },
|
||||
intervalMs: 30000,
|
||||
name: "no-stats",
|
||||
timeoutMs: 10000,
|
||||
type: "http",
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { readRuntimeConfig } from "../../src/server/config";
|
||||
|
||||
describe("runtime config", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { getAvailabilityProgressColor } from "../../../src/web/constants/color-threshold";
|
||||
|
||||
describe("color-threshold", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { statusFilter, typeFilter } from "../../../src/web/constants/target-table-filters";
|
||||
|
||||
describe("target-table-filters", () => {
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import type { TargetStatus } from "../../../src/shared/api";
|
||||
|
||||
import {
|
||||
statusSorter,
|
||||
availabilitySorter,
|
||||
latencySorter,
|
||||
nameSorter,
|
||||
statusSorter,
|
||||
} from "../../../src/web/constants/target-table-sorters";
|
||||
import type { TargetStatus } from "../../../src/shared/api";
|
||||
|
||||
function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
|
||||
return {
|
||||
id: 1,
|
||||
name: "test",
|
||||
type: "http",
|
||||
target: "https://example.com",
|
||||
group: "default",
|
||||
id: 1,
|
||||
interval: "5s",
|
||||
latestCheck: null,
|
||||
stats: { totalChecks: 0, availability: 100 },
|
||||
name: "test",
|
||||
recentSamples: [],
|
||||
stats: { availability: 100, totalChecks: 0 },
|
||||
target: "https://example.com",
|
||||
type: "http",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -25,10 +27,10 @@ function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
|
||||
describe("statusSorter", () => {
|
||||
test("DOWN 排在 UP 前面", () => {
|
||||
const up = makeTarget({
|
||||
latestCheck: { timestamp: "", matched: true, durationMs: 10, statusDetail: null, failure: null },
|
||||
latestCheck: { durationMs: 10, failure: null, matched: true, statusDetail: null, timestamp: "" },
|
||||
});
|
||||
const down = makeTarget({
|
||||
latestCheck: { timestamp: "", matched: false, durationMs: 10, statusDetail: null, failure: null },
|
||||
latestCheck: { durationMs: 10, failure: null, matched: false, statusDetail: null, timestamp: "" },
|
||||
});
|
||||
expect(statusSorter(down, up)).toBeLessThan(0);
|
||||
expect(statusSorter(up, down)).toBeGreaterThan(0);
|
||||
@@ -36,10 +38,10 @@ describe("statusSorter", () => {
|
||||
|
||||
test("相同状态返回 0", () => {
|
||||
const a = makeTarget({
|
||||
latestCheck: { timestamp: "", matched: true, durationMs: 10, statusDetail: null, failure: null },
|
||||
latestCheck: { durationMs: 10, failure: null, matched: true, statusDetail: null, timestamp: "" },
|
||||
});
|
||||
const b = makeTarget({
|
||||
latestCheck: { timestamp: "", matched: true, durationMs: 20, statusDetail: null, failure: null },
|
||||
latestCheck: { durationMs: 20, failure: null, matched: true, statusDetail: null, timestamp: "" },
|
||||
});
|
||||
expect(statusSorter(a, b)).toBe(0);
|
||||
});
|
||||
@@ -47,7 +49,7 @@ describe("statusSorter", () => {
|
||||
test("无 latestCheck 的目标排在最后", () => {
|
||||
const noCheck = makeTarget();
|
||||
const up = makeTarget({
|
||||
latestCheck: { timestamp: "", matched: true, durationMs: 10, statusDetail: null, failure: null },
|
||||
latestCheck: { durationMs: 10, failure: null, matched: true, statusDetail: null, timestamp: "" },
|
||||
});
|
||||
expect(statusSorter(noCheck, up)).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -55,20 +57,20 @@ describe("statusSorter", () => {
|
||||
|
||||
describe("availabilitySorter", () => {
|
||||
test("低可用率排前面", () => {
|
||||
const low = makeTarget({ stats: { totalChecks: 100, availability: 95 } });
|
||||
const high = makeTarget({ stats: { totalChecks: 100, availability: 99.9 } });
|
||||
const low = makeTarget({ stats: { availability: 95, totalChecks: 100 } });
|
||||
const high = makeTarget({ stats: { availability: 99.9, totalChecks: 100 } });
|
||||
expect(availabilitySorter(low, high)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
test("相同可用率返回 0", () => {
|
||||
const a = makeTarget({ stats: { totalChecks: 100, availability: 99.9 } });
|
||||
const b = makeTarget({ stats: { totalChecks: 50, availability: 99.9 } });
|
||||
const a = makeTarget({ stats: { availability: 99.9, totalChecks: 100 } });
|
||||
const b = makeTarget({ stats: { availability: 99.9, totalChecks: 50 } });
|
||||
expect(availabilitySorter(a, b)).toBe(0);
|
||||
});
|
||||
|
||||
test("无 stats 按 0 处理", () => {
|
||||
const noStats = makeTarget({ stats: undefined as unknown as TargetStatus["stats"] });
|
||||
const high = makeTarget({ stats: { totalChecks: 100, availability: 99.9 } });
|
||||
const high = makeTarget({ stats: { availability: 99.9, totalChecks: 100 } });
|
||||
expect(availabilitySorter(noStats, high)).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
@@ -76,20 +78,20 @@ describe("availabilitySorter", () => {
|
||||
describe("latencySorter", () => {
|
||||
test("低延迟排前面", () => {
|
||||
const fast = makeTarget({
|
||||
latestCheck: { timestamp: "", matched: true, durationMs: 50, statusDetail: null, failure: null },
|
||||
latestCheck: { durationMs: 50, failure: null, matched: true, statusDetail: null, timestamp: "" },
|
||||
});
|
||||
const slow = makeTarget({
|
||||
latestCheck: { timestamp: "", matched: true, durationMs: 200, statusDetail: null, failure: null },
|
||||
latestCheck: { durationMs: 200, failure: null, matched: true, statusDetail: null, timestamp: "" },
|
||||
});
|
||||
expect(latencySorter(fast, slow)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
test("无延迟排最后", () => {
|
||||
const noLatency = makeTarget({
|
||||
latestCheck: { timestamp: "", matched: true, durationMs: null, statusDetail: null, failure: null },
|
||||
latestCheck: { durationMs: null, failure: null, matched: true, statusDetail: null, timestamp: "" },
|
||||
});
|
||||
const hasLatency = makeTarget({
|
||||
latestCheck: { timestamp: "", matched: true, durationMs: 100, statusDetail: null, failure: null },
|
||||
latestCheck: { durationMs: 100, failure: null, matched: true, statusDetail: null, timestamp: "" },
|
||||
});
|
||||
expect(latencySorter(noLatency, hasLatency)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { TARGET_TYPE_DISPLAY, getTargetTypeDisplay } from "../../../src/web/constants/target-type-display";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { getTargetTypeDisplay, TARGET_TYPE_DISPLAY } from "../../../src/web/constants/target-type-display";
|
||||
|
||||
describe("target-type-display", () => {
|
||||
describe("TARGET_TYPE_DISPLAY 常量", () => {
|
||||
|
||||
Reference in New Issue
Block a user