1
0

test: 重构测试体系 — 建立组件测试层、补充后端测试、清理低质量测试

- 新增 jsdom + @testing-library/react 组件测试环境
- 新增 12 个组件测试,覆盖所有前端组件
- 补充后端 middleware 和 helpers 单元测试
- 删除伪测试 use-target-detail-logic.test.ts
- 精简过度枚举的 color-threshold.test.ts
- 新增 bunfig.toml 配置测试 preload
- 更新 DEVELOPMENT.md 测试章节
- 安装 @types/jsdom 修复类型声明
This commit is contained in:
2026-05-15 18:31:33 +08:00
parent 2b08f81a0d
commit 8793fbd786
24 changed files with 1392 additions and 143 deletions

View File

@@ -0,0 +1,109 @@
import { describe, expect, test } from "bun:test";
import { createApiError, createHeaders, formatDuration, jsonResponse } from "../../src/server/helpers";
describe("createApiError", () => {
test("创建错误响应对象", () => {
const result = createApiError("Not found", 404);
expect(result).toEqual({ error: "Not found", status: 404 });
});
test("支持不同的错误消息和状态码", () => {
const badRequest = createApiError("Bad request", 400);
const internalError = createApiError("Internal error", 500);
expect(badRequest).toEqual({ error: "Bad request", status: 400 });
expect(internalError).toEqual({ error: "Internal error", status: 500 });
});
});
describe("createHeaders", () => {
test("生产模式添加安全 headers", () => {
const headers = createHeaders("production", { "Content-Type": "application/json" });
expect(headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin");
expect(headers.get("Content-Type")).toBe("application/json");
});
test("非生产模式不添加安全 headers", () => {
const headers = createHeaders("test", { "Content-Type": "application/json" });
expect(headers.get("X-Content-Type-Options")).toBeNull();
expect(headers.get("Referrer-Policy")).toBeNull();
expect(headers.get("Content-Type")).toBe("application/json");
});
test("保留传入的自定义 headers", () => {
const headers = createHeaders("production", { "X-Custom-Header": "custom-value" });
expect(headers.get("X-Custom-Header")).toBe("custom-value");
});
});
describe("jsonResponse", () => {
test("创建 JSON 响应", () => {
const body = { message: "Hello" };
const response = jsonResponse(body, { mode: "test" });
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("application/json; charset=utf-8");
});
test("生产模式响应包含安全 headers", () => {
const response = jsonResponse({ data: "test" }, { mode: "production" });
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(response.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin");
});
test("支持自定义状态码", () => {
const response = jsonResponse({ error: "Not found" }, { mode: "test", status: 404 });
expect(response.status).toBe(404);
});
test("支持自定义 headers", () => {
const response = jsonResponse(
{ data: "test" },
{
headers: { "X-Custom": "value" },
mode: "test",
},
);
expect(response.headers.get("X-Custom")).toBe("value");
});
test("响应 body 可以被解析为 JSON", async () => {
const body = { count: 42, message: "Hello" };
const response = jsonResponse(body, { mode: "test" });
const parsed = (await response.json()) as { count: number; message: string };
expect(parsed).toEqual(body);
});
});
describe("formatDuration", () => {
test("毫秒格式化", () => {
expect(formatDuration(100)).toBe("100ms");
expect(formatDuration(999)).toBe("999ms");
});
test("秒格式化(整秒)", () => {
expect(formatDuration(1000)).toBe("1s");
expect(formatDuration(5000)).toBe("5s");
expect(formatDuration(59000)).toBe("59s");
});
test("分钟格式化(整分钟)", () => {
expect(formatDuration(60000)).toBe("1m");
expect(formatDuration(120000)).toBe("2m");
expect(formatDuration(300000)).toBe("5m");
});
test("非整秒/整分钟保持毫秒", () => {
expect(formatDuration(1500)).toBe("1500ms");
expect(formatDuration(61123)).toBe("61123ms");
});
});

View File

@@ -0,0 +1,171 @@
import { describe, expect, test } from "bun:test";
import {
validateDashboardWindow,
validateMetricsBucket,
validatePagination,
validateRecentLimit,
validateTargetId,
validateTimeRange,
} from "../../src/server/middleware";
describe("validateTargetId", () => {
test("有效的 target ID 返回数字", () => {
const result = validateTargetId("123", "production");
expect(result).not.toHaveProperty("status");
expect((result as { id: number }).id).toBe(123);
});
test("无效的 target ID 返回 400", () => {
const invalid = ["0", "-1", "abc", "1.5", ""];
for (const id of invalid) {
const result = validateTargetId(id, "production");
expect(result).toHaveProperty("status", 400);
}
});
});
describe("validateTimeRange", () => {
test("有效的 from/to 返回 ISO 字符串", () => {
const result = validateTimeRange("2024-01-01T00:00:00.000Z", "2024-01-02T00:00:00.000Z", "production");
expect(result).not.toHaveProperty("status");
expect((result as { from: string; to: string }).from).toBe("2024-01-01T00:00:00.000Z");
expect((result as { from: string; to: string }).to).toBe("2024-01-02T00:00:00.000Z");
});
test("缺失 from 或 to 返回 400", () => {
const missingFrom = validateTimeRange(null, "2024-01-02T00:00:00.000Z", "production");
const missingTo = validateTimeRange("2024-01-01T00:00:00.000Z", null, "production");
const missingBoth = validateTimeRange(null, null, "production");
expect(missingFrom).toHaveProperty("status", 400);
expect(missingTo).toHaveProperty("status", 400);
expect(missingBoth).toHaveProperty("status", 400);
});
test("空字符串 from 或 to 返回 400", () => {
const emptyFrom = validateTimeRange("", "2024-01-02T00:00:00.000Z", "production");
const emptyTo = validateTimeRange("2024-01-01T00:00:00.000Z", "", "production");
expect(emptyFrom).toHaveProperty("status", 400);
expect(emptyTo).toHaveProperty("status", 400);
});
test("无效的日期格式返回 400", () => {
const result = validateTimeRange("invalid-date", "2024-01-02T00:00:00.000Z", "production");
expect(result).toHaveProperty("status", 400);
});
test("from 晚于 to 返回 400", () => {
const result = validateTimeRange("2024-01-02T00:00:00.000Z", "2024-01-01T00:00:00.000Z", "production");
expect(result).toHaveProperty("status", 400);
});
});
describe("validatePagination", () => {
test("默认值page=1, pageSize=20", () => {
const result = validatePagination(null, null, "production");
expect(result).toEqual({ page: 1, pageSize: 20 });
});
test("有效的 page 和 pageSize 参数", () => {
const result = validatePagination("2", "50", "production");
expect(result).toEqual({ page: 2, pageSize: 50 });
});
test("无效的 page 参数返回 400", () => {
const invalidPage = ["0", "-1", "abc", "1.5"];
for (const page of invalidPage) {
const result = validatePagination(page, "20", "production");
expect(result).toHaveProperty("status", 400);
}
});
test("无效的 pageSize 参数返回 400", () => {
const invalidPageSize = ["0", "-1", "abc", "1.5"];
for (const pageSize of invalidPageSize) {
const result = validatePagination("1", pageSize, "production");
expect(result).toHaveProperty("status", 400);
}
});
test("pageSize 超过上限返回 400", () => {
const result = validatePagination("1", "201", "production");
expect(result).toHaveProperty("status", 400);
});
test("pageSize 等于上限 200 返回成功", () => {
const result = validatePagination("1", "200", "production");
expect(result).toEqual({ page: 1, pageSize: 200 });
});
});
describe("validateRecentLimit", () => {
test("默认值recentLimit=30", () => {
const result = validateRecentLimit(null, "production");
expect(result).toEqual({ recentLimit: 30 });
});
test("有效的 recentLimit 参数", () => {
const result = validateRecentLimit("50", "production");
expect(result).toEqual({ recentLimit: 50 });
});
test("无效的 recentLimit 参数返回 400", () => {
const invalid = ["0", "-1", "abc", "1.5"];
for (const limit of invalid) {
const result = validateRecentLimit(limit, "production");
expect(result).toHaveProperty("status", 400);
}
});
test("recentLimit 超过上限返回 400", () => {
const result = validateRecentLimit("201", "production");
expect(result).toHaveProperty("status", 400);
});
test("recentLimit 等于上限 200 返回成功", () => {
const result = validateRecentLimit("200", "production");
expect(result).toEqual({ recentLimit: 200 });
});
});
describe("validateDashboardWindow", () => {
test("默认值window=24h", () => {
const result = validateDashboardWindow(null, "production");
expect(result).not.toHaveProperty("status");
expect((result as { label: string }).label).toBe("24h");
});
test("window=24h 返回成功", () => {
const result = validateDashboardWindow("24h", "production");
expect(result).not.toHaveProperty("status");
expect((result as { label: string }).label).toBe("24h");
});
test("不支持的 window 参数返回 400", () => {
const result = validateDashboardWindow("7d", "production");
expect(result).toHaveProperty("status", 400);
});
});
describe("validateMetricsBucket", () => {
test("默认值bucket=1h", () => {
const result = validateMetricsBucket(null, "production");
expect(result).toEqual({ bucket: "1h" });
});
test("bucket=1h 返回成功", () => {
const result = validateMetricsBucket("1h", "production");
expect(result).toEqual({ bucket: "1h" });
});
test("不支持的 bucket 参数返回 400", () => {
const result = validateMetricsBucket("5m", "production");
expect(result).toHaveProperty("status", 400);
});
});

81
tests/setup.ts Normal file
View File

@@ -0,0 +1,81 @@
/**
* 全局测试配置
* 主要为后端测试提供基础环境
* 组件测试使用各自的 test-utils.tsx
*/
/* eslint-disable @typescript-eslint/no-empty-function */
// Set up jsdom for ALL tests (both backend and frontend)
import { JSDOM } from "jsdom";
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
pretendToBeVisual: true,
url: "http://localhost",
});
globalThis.document = dom.window.document;
globalThis.window = dom.window as unknown as typeof globalThis & Window;
globalThis.navigator = dom.window.navigator;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Element = dom.window.Element;
globalThis.getComputedStyle = dom.window.getComputedStyle;
// Ensure document.body exists
if (!globalThis.document.body) {
const body = globalThis.document.createElement("body");
globalThis.document.documentElement.appendChild(body);
}
// CRITICAL: Set up polyfills BEFORE any other imports
// This ensures @testing-library/react sees these when it loads
// IE-style event handling polyfill (React fallback)
const nodeProto = dom.window.Node.prototype;
const elementProto = dom.window.Element.prototype;
const htmlElementProto = dom.window.HTMLElement.prototype;
const attachEventFn = () => {};
const detachEventFn = () => {};
Object.defineProperty(nodeProto, "attachEvent", { configurable: true, value: attachEventFn, writable: true });
Object.defineProperty(nodeProto, "detachEvent", { configurable: true, value: detachEventFn, writable: true });
Object.defineProperty(elementProto, "attachEvent", { configurable: true, value: attachEventFn, writable: true });
Object.defineProperty(elementProto, "detachEvent", { configurable: true, value: detachEventFn, writable: true });
Object.defineProperty(htmlElementProto, "attachEvent", { configurable: true, value: attachEventFn, writable: true });
Object.defineProperty(htmlElementProto, "detachEvent", { configurable: true, value: detachEventFn, writable: true });
// Other polyfills
globalThis.ResizeObserver = class {
disconnect() {}
observe() {}
unobserve() {}
};
globalThis.IntersectionObserver = class {
disconnect() {}
observe() {}
takeRecords() {
return [];
}
unobserve() {}
} as unknown as typeof IntersectionObserver;
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => setTimeout(cb, 16);
globalThis.cancelAnimationFrame = (id: number) => clearTimeout(id);
Object.defineProperty(dom.window, "matchMedia", {
value: (query: string) => ({
addEventListener: () => {},
addListener: () => {},
dispatchEvent: () => true,
matches: false,
media: query,
onchange: null,
removeEventListener: () => {},
removeListener: () => {},
}),
writable: true,
});
dom.window.Element.prototype.scrollTo = () => {};
dom.window.Element.prototype.scrollIntoView = () => {};

View File

@@ -0,0 +1,127 @@
/* eslint-disable @typescript-eslint/no-require-imports */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import "../../../tests/web/test-utils";
import { render } from "@testing-library/react";
import { describe, expect, test, vi } from "bun:test";
import { App } from "../../../src/web/app";
// Mock hooks
void vi.mock("../../../src/web/hooks/use-queries", () => ({
useDashboard: vi.fn(() => ({
data: {
summary: {
down: 0,
incidents: 0,
lastCheckTime: "2025-01-15T10:00:00.000Z",
total: 0,
up: 0,
window: {
from: "2025-01-14T10:00:00.000Z",
label: "24h",
to: "2025-01-15T10:00:00.000Z",
},
},
targets: [],
},
dataUpdatedAt: Date.now(),
error: null,
isFetching: false,
isLoading: false,
refetch: vi.fn(),
})),
useMeta: vi.fn(() => ({
data: { checkerTypes: ["http", "cmd"] },
})),
}));
void vi.mock("../../../src/web/hooks/use-target-detail", () => ({
useTargetDetail: vi.fn(() => ({
activeTab: "overview",
closeDrawer: vi.fn(),
handlePageChange: vi.fn(),
handleTabChange: vi.fn(),
handleTimeChange: vi.fn(),
historyData: {
items: [],
page: 1,
pageSize: 20,
total: 0,
},
historyLoading: false,
metricsData: null,
metricsLoading: false,
openDrawer: vi.fn(),
selectedTarget: null,
timeFrom: "",
timeTo: "",
})),
}));
describe("App", () => {
test("渲染不崩溃", () => {
const { container } = render(<App />);
expect(container.firstChild).not.toBeNull();
});
test("loading 状态不崩溃", () => {
const { useDashboard } = require("../../../src/web/hooks/use-queries");
useDashboard.mockReturnValue({
data: null,
dataUpdatedAt: 0,
error: null,
isFetching: true,
isLoading: true,
refetch: vi.fn(),
});
const { container } = render(<App />);
expect(container.firstChild).not.toBeNull();
});
test("错误状态不崩溃", () => {
const { useDashboard } = require("../../../src/web/hooks/use-queries");
useDashboard.mockReturnValue({
data: null,
dataUpdatedAt: 0,
error: { message: "Network error" },
isFetching: false,
isLoading: false,
refetch: vi.fn(),
});
const { container } = render(<App />);
expect(container.firstChild).not.toBeNull();
});
test("有数据状态不崩溃", () => {
const { useDashboard } = require("../../../src/web/hooks/use-queries");
useDashboard.mockReturnValue({
data: {
summary: {
down: 1,
incidents: 0,
lastCheckTime: "2025-01-15T10:00:00.000Z",
total: 2,
up: 1,
window: {
from: "2025-01-14T10:00:00.000Z",
label: "24h",
to: "2025-01-15T10:00:00.000Z",
},
},
targets: [],
},
dataUpdatedAt: Date.now(),
error: null,
isFetching: false,
isLoading: false,
refetch: vi.fn(),
});
const { container } = render(<App />);
expect(container.firstChild).not.toBeNull();
});
});

View File

@@ -0,0 +1,67 @@
import "../../../tests/web/test-utils";
import { render } from "@testing-library/react";
import { beforeEach, describe, expect, test, vi } from "bun:test";
import { ErrorBoundary } from "../../../src/web/components/ErrorBoundary";
// 一个正常组件
function NormalComponent() {
return <div>Normal content</div>;
}
// 一个会抛错的组件
function ThrowError() {
throw new Error("Test error");
// TypeScript 需要返回值,虽然这里永远不会执行
return null;
}
describe("ErrorBoundary", () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {
// Mock console.error to suppress error output during tests
});
});
test("捕获子组件渲染错误并显示 fallback", () => {
const { container } = render(
<ErrorBoundary>
<ThrowError />
</ErrorBoundary>,
);
expect(container.firstChild).not.toBeNull();
});
test("正常渲染子组件", () => {
const { container } = render(
<ErrorBoundary>
<NormalComponent />
</ErrorBoundary>,
);
expect(container.firstChild).not.toBeNull();
});
test("刷新按钮不崩溃", () => {
const { container } = render(
<ErrorBoundary>
<ThrowError />
</ErrorBoundary>,
);
expect(container.firstChild).not.toBeNull();
});
test("错误时调用 console.error", () => {
render(
<ErrorBoundary>
<ThrowError />
</ErrorBoundary>,
);
expect(consoleErrorSpy).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,42 @@
import "../../../tests/web/test-utils";
import { render } from "@testing-library/react";
import { describe, expect, test, vi } from "bun:test";
import type { HistoryResponse } from "../../../src/shared/api";
import { HistoryTab } from "../../../src/web/components/HistoryTab";
describe("HistoryTab", () => {
const historyData: HistoryResponse = {
items: [],
page: 1,
pageSize: 20,
total: 0,
};
const onPageChange = vi.fn();
test("渲染不崩溃", () => {
const { container } = render(
<HistoryTab historyData={historyData} historyLoading={false} onPageChange={onPageChange} />,
);
expect(container.firstChild).not.toBeNull();
});
test("loading 状态不崩溃", () => {
const { container } = render(
<HistoryTab historyData={historyData} historyLoading={true} onPageChange={onPageChange} />,
);
expect(container.firstChild).not.toBeNull();
});
test("空数据不崩溃", () => {
const { container } = render(
<HistoryTab historyData={historyData} historyLoading={false} onPageChange={onPageChange} />,
);
expect(container.firstChild).not.toBeNull();
});
});

View File

@@ -0,0 +1,67 @@
import "../../../tests/web/test-utils";
import { render } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import type { TargetMetricsResponse, TargetStatus } from "../../../src/shared/api";
import { OverviewTab } from "../../../src/web/components/OverviewTab";
describe("OverviewTab", () => {
const target: TargetStatus = {
currentStreak: null,
group: "default",
id: 1,
interval: "30s",
latestCheck: {
durationMs: 100,
failure: null,
matched: true,
statusDetail: "200 OK",
timestamp: "2025-01-15T10:00:00.000Z",
},
name: "test-target",
recentSamples: [],
stats: { availability: 100, downChecks: 0, totalChecks: 10, upChecks: 10 },
target: "https://example.com",
type: "http",
};
const metricsData: TargetMetricsResponse = {
stats: {
availability: 95,
avgDurationMs: 150,
currentStreak: { count: 5, up: true },
downChecks: 1,
incidentCount: 1,
longestOutage: 60000,
mttr: 30000,
p95DurationMs: 200,
p99DurationMs: 250,
totalChecks: 20,
upChecks: 19,
},
targetId: 1,
trend: [],
window: { bucket: "1h", from: "", to: "" },
};
test("有数据不崩溃", () => {
const { container } = render(<OverviewTab metricsData={metricsData} metricsLoading={false} target={target} />);
expect(container.firstChild).not.toBeNull();
});
test("loading 状态不崩溃", () => {
const { container } = render(<OverviewTab metricsData={null} metricsLoading={true} target={target} />);
expect(container.firstChild).not.toBeNull();
});
test("无指标数据不崩溃", () => {
const { container } = render(<OverviewTab metricsData={null} metricsLoading={false} target={target} />);
expect(container.firstChild).not.toBeNull();
});
test("显示趋势图表不崩溃", () => {
const { container } = render(<OverviewTab metricsData={metricsData} metricsLoading={false} target={target} />);
expect(container.firstChild).not.toBeNull();
});
});

View File

@@ -0,0 +1,64 @@
import "../../../tests/web/test-utils";
import { render } from "@testing-library/react";
import { describe, expect, test, vi } from "bun:test";
import { RefreshCountdown } from "../../../src/web/components/RefreshCountdown";
describe("RefreshCountdown", () => {
test("手动模式不崩溃", () => {
const { container } = render(
<RefreshCountdown
dashboardUpdatedAt={0}
isFetching={false}
isManualRefresh={true}
onRefresh={vi.fn()}
refreshInterval={30000}
/>,
);
expect(container.firstChild).not.toBeNull();
});
test("自动模式不崩溃", () => {
const now = Date.now();
const { container } = render(
<RefreshCountdown
dashboardUpdatedAt={now - 10000}
isFetching={false}
isManualRefresh={false}
onRefresh={vi.fn()}
refreshInterval={30000}
/>,
);
expect(container.firstChild).not.toBeNull();
});
test("fetching 状态不崩溃", () => {
const { container } = render(
<RefreshCountdown
dashboardUpdatedAt={1000}
isFetching={true}
isManualRefresh={false}
onRefresh={vi.fn()}
refreshInterval={30000}
/>,
);
expect(container.firstChild).not.toBeNull();
});
test("未刷新状态不崩溃", () => {
const { container } = render(
<RefreshCountdown
dashboardUpdatedAt={0}
isFetching={false}
isManualRefresh={false}
onRefresh={vi.fn()}
refreshInterval={30000}
/>,
);
expect(container.firstChild).not.toBeNull();
});
});

View File

@@ -0,0 +1,31 @@
import "../../../tests/web/test-utils";
import { render } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import type { RecentSample } from "../../../src/shared/api";
import { StatusBar } from "../../../src/web/components/StatusBar";
describe("StatusBar", () => {
const now = new Date().toISOString();
const samples: RecentSample[] = [
{ durationMs: 100, timestamp: now, up: true },
{ durationMs: 150, timestamp: new Date(Date.now() - 60000).toISOString(), up: false },
];
test("渲染不崩溃", () => {
const { container } = render(<StatusBar maxSlots={5} samples={samples} />);
expect(container.firstChild).not.toBeNull();
});
test("默认 maxSlots 不崩溃", () => {
const { container } = render(<StatusBar samples={samples} />);
expect(container.firstChild).not.toBeNull();
});
test("空 samples 不崩溃", () => {
const { container } = render(<StatusBar maxSlots={3} samples={[]} />);
expect(container.firstChild).not.toBeNull();
});
});

View File

@@ -0,0 +1,17 @@
import "../../../tests/web/test-utils";
import { render } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { StatusDot } from "../../../src/web/components/StatusDot";
describe("StatusDot", () => {
test("up=true 不崩溃", () => {
const { container } = render(<StatusDot up={true} />);
expect(container.firstChild).not.toBeNull();
});
test("up=false 不崩溃", () => {
const { container } = render(<StatusDot up={false} />);
expect(container.firstChild).not.toBeNull();
});
});

View File

@@ -0,0 +1,33 @@
import "../../../tests/web/test-utils";
import { render } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import type { DashboardResponse } from "../../../src/shared/api";
import { SummaryCards } from "../../../src/web/components/SummaryCards";
describe("SummaryCards", () => {
const summary: DashboardResponse["summary"] = {
down: 2,
incidents: 1,
lastCheckTime: "2025-01-15T10:00:00.000Z",
total: 10,
up: 8,
window: {
from: "2025-01-14T10:00:00.000Z",
label: "24h",
to: "2025-01-15T10:00:00.000Z",
},
};
test("summary 为 null 时不渲染", () => {
const { container } = render(<SummaryCards summary={null} />);
expect(container.firstChild).toBeNull();
});
test("有数据不崩溃", () => {
const { container } = render(<SummaryCards summary={summary} />);
expect(container.firstChild).not.toBeNull();
});
});

View File

@@ -0,0 +1,55 @@
import "../../../tests/web/test-utils";
import { render } from "@testing-library/react";
import { describe, expect, test, vi } from "bun:test";
import type { TargetStatus } from "../../../src/shared/api";
import { TargetBoard } from "../../../src/web/components/TargetBoard";
// Mock useMeta hook
void vi.mock("../../../src/web/hooks/use-queries", () => ({
useMeta: () => ({
data: { checkerTypes: ["http", "cmd"] },
}),
}));
describe("TargetBoard", () => {
const onTargetClick = vi.fn();
const targets: TargetStatus[] = [
{
currentStreak: null,
group: "default",
id: 1,
interval: "30s",
latestCheck: null,
name: "target-1",
recentSamples: [],
stats: { availability: 100, downChecks: 0, totalChecks: 0, upChecks: 0 },
target: "https://example.com",
type: "http",
},
{
currentStreak: null,
group: "production",
id: 2,
interval: "30s",
latestCheck: null,
name: "target-2",
recentSamples: [],
stats: { availability: 100, downChecks: 0, totalChecks: 0, upChecks: 0 },
target: "https://example.org",
type: "http",
},
];
test("有 targets 时不崩溃", () => {
const { container } = render(<TargetBoard onTargetClick={onTargetClick} targets={targets} />);
expect(container.firstChild).not.toBeNull();
});
test("空 targets 列表不崩溃", () => {
const { container } = render(<TargetBoard onTargetClick={onTargetClick} targets={[]} />);
expect(container.firstChild).not.toBeNull();
});
});

View File

@@ -0,0 +1,88 @@
import "../../../tests/web/test-utils";
import { render } from "@testing-library/react";
import { describe, expect, test, vi } from "bun:test";
import type { HistoryResponse, TargetMetricsResponse, TargetStatus } from "../../../src/shared/api";
import { TargetDetailDrawer } from "../../../src/web/components/TargetDetailDrawer";
describe("TargetDetailDrawer", () => {
const target: TargetStatus = {
currentStreak: null,
group: "default",
id: 1,
interval: "30s",
latestCheck: {
durationMs: 100,
failure: null,
matched: true,
statusDetail: "200 OK",
timestamp: "2025-01-15T10:00:00.000Z",
},
name: "test-target",
recentSamples: [],
stats: { availability: 100, downChecks: 0, totalChecks: 10, upChecks: 10 },
target: "https://example.com",
type: "http",
};
const metricsData: TargetMetricsResponse = {
stats: {
availability: 95,
avgDurationMs: 150,
currentStreak: null,
downChecks: 1,
incidentCount: 1,
longestOutage: null,
mttr: null,
p95DurationMs: 200,
p99DurationMs: 250,
totalChecks: 20,
upChecks: 19,
},
targetId: 1,
trend: [],
window: { bucket: "1h", from: "", to: "" },
};
const historyData: HistoryResponse = {
items: [],
page: 1,
pageSize: 20,
total: 0,
};
const defaultProps = {
activeTab: "overview",
historyData,
historyLoading: false,
metricsData,
metricsLoading: false,
onClose: vi.fn(),
onPageChange: vi.fn(),
onTabChange: vi.fn(),
onTimeChange: vi.fn(),
target,
timeFrom: "2025-01-15T00:00:00.000Z",
timeTo: "2025-01-15T23:59:59.999Z",
};
test("target 为 null 时不崩溃", () => {
const { container } = render(<TargetDetailDrawer {...defaultProps} target={null} />);
// When target is null, the drawer might not render, which is expected behavior
expect(container).not.toBeNull();
});
test("target 存在时不崩溃", () => {
const { asFragment } = render(<TargetDetailDrawer {...defaultProps} />);
// Just verify rendering doesn't throw
expect(asFragment()).not.toBeNull();
});
test("关闭按钮不崩溃", () => {
const onClose = vi.fn();
const { asFragment } = render(<TargetDetailDrawer {...defaultProps} onClose={onClose} />);
// Just verify rendering doesn't throw
expect(asFragment()).not.toBeNull();
});
});

View File

@@ -0,0 +1,76 @@
import "../../../tests/web/test-utils";
import { render } from "@testing-library/react";
import { describe, expect, test, vi } from "bun:test";
import type { TargetStatus } from "../../../src/shared/api";
import { TargetGroup } from "../../../src/web/components/TargetGroup";
describe("TargetGroup", () => {
const columns = [
{ colKey: "name", title: "名称" },
{ colKey: "target", title: "目标" },
];
const targets: TargetStatus[] = [
{
currentStreak: null,
group: "default",
id: 1,
interval: "30s",
latestCheck: {
durationMs: 100,
failure: null,
matched: true,
statusDetail: "200 OK",
timestamp: "2025-01-15T10:00:00.000Z",
},
name: "target-1",
recentSamples: [],
stats: { availability: 100, downChecks: 0, totalChecks: 10, upChecks: 10 },
target: "https://example.com",
type: "http",
},
{
currentStreak: null,
group: "default",
id: 2,
interval: "30s",
latestCheck: {
durationMs: 100,
failure: { kind: "error", message: "Failed", path: "$", phase: "status" },
matched: false,
statusDetail: "500 Internal Server Error",
timestamp: "2025-01-15T10:00:00.000Z",
},
name: "target-2",
recentSamples: [],
stats: { availability: 50, downChecks: 1, totalChecks: 2, upChecks: 1 },
target: "https://example.org",
type: "http",
},
];
const onTargetClick = vi.fn();
test("default 分组不崩溃", () => {
const { container } = render(
<TargetGroup columns={columns} name="default" onTargetClick={onTargetClick} targets={targets} />,
);
expect(container.firstChild).not.toBeNull();
});
test("非 default 分组不崩溃", () => {
const { container } = render(
<TargetGroup columns={columns} name="production" onTargetClick={onTargetClick} targets={targets} />,
);
expect(container.firstChild).not.toBeNull();
});
test("空 targets 不崩溃", () => {
const { container } = render(
<TargetGroup columns={columns} name="default" onTargetClick={onTargetClick} targets={[]} />,
);
expect(container.firstChild).not.toBeNull();
});
});

View File

@@ -0,0 +1,53 @@
import "../../../tests/web/test-utils";
import { render } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import type { TrendPoint } from "../../../src/shared/api";
import { TrendChart } from "../../../src/web/components/TrendChart";
describe("TrendChart", () => {
const data: TrendPoint[] = [
{
availability: 100,
avgDurationMs: 100,
bucketStart: "2025-01-15T10:00:00.000Z",
downChecks: 0,
maxDurationMs: 150,
minDurationMs: 50,
totalChecks: 10,
upChecks: 10,
},
{
availability: 95,
avgDurationMs: 120,
bucketStart: "2025-01-15T11:00:00.000Z",
downChecks: 1,
maxDurationMs: 200,
minDurationMs: 80,
totalChecks: 20,
upChecks: 19,
},
];
test("有数据时不崩溃", () => {
const { container } = render(<TrendChart data={data} />);
expect(container.firstChild).not.toBeNull();
});
test("空数据显示占位", () => {
const { container } = render(<TrendChart data={[]} />);
// 应该显示占位文本
const element = container.querySelector(".trend-empty");
expect(element).not.toBeNull();
});
test("包含 trend-chart className", () => {
const { container } = render(<TrendChart data={data} />);
const element = container.querySelector(".trend-chart");
expect(element).not.toBeNull();
});
});

View File

@@ -4,66 +4,32 @@ import { getAvailabilityProgressColor } from "../../../src/web/constants/color-t
describe("color-threshold", () => {
describe("getAvailabilityProgressColor", () => {
test("0-10% 返回第一档 CSS 变量", () => {
test("首档(0-10%和末档90-100%", () => {
expect(getAvailabilityProgressColor(0)).toBe("var(--avail-0)");
expect(getAvailabilityProgressColor(5)).toBe("var(--avail-0)");
expect(getAvailabilityProgressColor(9.99)).toBe("var(--avail-0)");
});
test("10-20% 返回第二档 CSS 变量", () => {
expect(getAvailabilityProgressColor(10)).toBe("var(--avail-1)");
expect(getAvailabilityProgressColor(15)).toBe("var(--avail-1)");
expect(getAvailabilityProgressColor(19.99)).toBe("var(--avail-1)");
});
test("20-30% 返回第三档 CSS 变量", () => {
expect(getAvailabilityProgressColor(20)).toBe("var(--avail-2)");
expect(getAvailabilityProgressColor(25)).toBe("var(--avail-2)");
});
test("30-40% 返回第四档 CSS 变量", () => {
expect(getAvailabilityProgressColor(30)).toBe("var(--avail-3)");
expect(getAvailabilityProgressColor(35)).toBe("var(--avail-3)");
});
test("40-50% 返回第五档 CSS 变量", () => {
expect(getAvailabilityProgressColor(40)).toBe("var(--avail-4)");
expect(getAvailabilityProgressColor(45)).toBe("var(--avail-4)");
});
test("50-60% 返回第六档 CSS 变量", () => {
expect(getAvailabilityProgressColor(50)).toBe("var(--avail-5)");
expect(getAvailabilityProgressColor(55)).toBe("var(--avail-5)");
});
test("60-70% 返回第七档 CSS 变量", () => {
expect(getAvailabilityProgressColor(60)).toBe("var(--avail-6)");
expect(getAvailabilityProgressColor(65)).toBe("var(--avail-6)");
});
test("70-80% 返回第八档 CSS 变量", () => {
expect(getAvailabilityProgressColor(70)).toBe("var(--avail-7)");
expect(getAvailabilityProgressColor(75)).toBe("var(--avail-7)");
});
test("80-90% 返回第九档 CSS 变量", () => {
expect(getAvailabilityProgressColor(80)).toBe("var(--avail-8)");
expect(getAvailabilityProgressColor(85)).toBe("var(--avail-8)");
});
test("90-100% 返回第十档 CSS 变量", () => {
expect(getAvailabilityProgressColor(90)).toBe("var(--avail-9)");
expect(getAvailabilityProgressColor(95)).toBe("var(--avail-9)");
expect(getAvailabilityProgressColor(99.9)).toBe("var(--avail-9)");
expect(getAvailabilityProgressColor(100)).toBe("var(--avail-9)");
});
test("边界值", () => {
expect(getAvailabilityProgressColor(9.999)).toBe("var(--avail-0)");
test("所有边界值(每档切换点)", () => {
expect(getAvailabilityProgressColor(9.99)).toBe("var(--avail-0)");
expect(getAvailabilityProgressColor(10)).toBe("var(--avail-1)");
expect(getAvailabilityProgressColor(19.999)).toBe("var(--avail-1)");
expect(getAvailabilityProgressColor(19.99)).toBe("var(--avail-1)");
expect(getAvailabilityProgressColor(20)).toBe("var(--avail-2)");
expect(getAvailabilityProgressColor(89.999)).toBe("var(--avail-8)");
expect(getAvailabilityProgressColor(29.99)).toBe("var(--avail-2)");
expect(getAvailabilityProgressColor(30)).toBe("var(--avail-3)");
expect(getAvailabilityProgressColor(39.99)).toBe("var(--avail-3)");
expect(getAvailabilityProgressColor(40)).toBe("var(--avail-4)");
expect(getAvailabilityProgressColor(49.99)).toBe("var(--avail-4)");
expect(getAvailabilityProgressColor(50)).toBe("var(--avail-5)");
expect(getAvailabilityProgressColor(59.99)).toBe("var(--avail-5)");
expect(getAvailabilityProgressColor(60)).toBe("var(--avail-6)");
expect(getAvailabilityProgressColor(69.99)).toBe("var(--avail-6)");
expect(getAvailabilityProgressColor(70)).toBe("var(--avail-7)");
expect(getAvailabilityProgressColor(79.99)).toBe("var(--avail-7)");
expect(getAvailabilityProgressColor(80)).toBe("var(--avail-8)");
expect(getAvailabilityProgressColor(89.99)).toBe("var(--avail-8)");
expect(getAvailabilityProgressColor(90)).toBe("var(--avail-9)");
});
});

View File

@@ -1,81 +0,0 @@
import { describe, expect, test } from "bun:test";
function shouldEnableHistory(
selectedTargetId: null | number,
timeFrom: string,
timeTo: string,
activeTab: string,
): boolean {
return selectedTargetId !== null && !!timeFrom && !!timeTo && activeTab === "history";
}
function shouldEnableMetrics(selectedTargetId: null | number, timeFrom: string, timeTo: string): boolean {
return selectedTargetId !== null && !!timeFrom && !!timeTo;
}
describe("metrics enabled 条件", () => {
test("未选中目标时不启用", () => {
expect(shouldEnableMetrics(null, "", "")).toBe(false);
});
test("选中目标但无时间范围时不启用", () => {
expect(shouldEnableMetrics(1, "", "")).toBe(false);
});
test("选中目标且有时间范围时启用", () => {
expect(shouldEnableMetrics(1, "2025-01-01T00:00:00.000Z", "2025-01-02T00:00:00.000Z")).toBe(true);
});
});
describe("history enabled 条件", () => {
test("未选中目标时不启用", () => {
expect(shouldEnableHistory(null, "from", "to", "history")).toBe(false);
});
test("选中目标但概览 Tab 时不启用", () => {
expect(shouldEnableHistory(1, "2025-01-01T00:00:00.000Z", "2025-01-02T00:00:00.000Z", "overview")).toBe(false);
});
test("选中目标且记录 Tab 激活但无时间范围时不启用", () => {
expect(shouldEnableHistory(1, "", "", "history")).toBe(false);
});
test("选中目标、有时间范围且记录 Tab 激活时启用", () => {
expect(shouldEnableHistory(1, "2025-01-01T00:00:00.000Z", "2025-01-02T00:00:00.000Z", "history")).toBe(true);
});
test("打开 Drawer 默认概览 Tab 时不启用 history", () => {
expect(shouldEnableHistory(1, "2025-01-01T00:00:00.000Z", "2025-01-02T00:00:00.000Z", "overview")).toBe(false);
});
test("概览 Tab 时间变化时不启用 history", () => {
expect(shouldEnableHistory(1, "2025-01-01T00:00:00.000Z", "2025-01-02T00:00:00.000Z", "overview")).toBe(false);
});
test("记录 Tab 时间变化时启用 history", () => {
expect(shouldEnableHistory(1, "2025-01-01T00:00:00.000Z", "2025-01-02T00:00:00.000Z", "history")).toBe(true);
});
});
describe("默认概览 Tab 行为", () => {
test("打开 Drawer 时 activeTab 应为 overview", () => {
const resetTab = "overview";
expect(resetTab).toBe("overview");
});
test("切换目标时 activeTab 应重置为 overview", () => {
const previousTab = "history";
const resetTab = "overview";
expect(previousTab).not.toBe(resetTab);
expect(resetTab).toBe("overview");
});
});
describe("history 页码重置", () => {
test("时间变化时 historyPage 应重置为 1", () => {
const previousPage = 3;
const resetPage = 1;
expect(previousPage).not.toBe(resetPage);
expect(resetPage).toBe(1);
});
});

52
tests/web/test-utils.tsx Normal file
View File

@@ -0,0 +1,52 @@
import { mock } from "bun:test";
// Note: jsdom and polyfills are now set up in tests/setup.ts
// This file only contains component-specific mocks
// Mock recharts BEFORE any component imports
void mock.module("recharts", () => ({
Area: () => null,
CartesianGrid: () => null,
Line: () => null,
LineChart: ({ children }: { children: unknown }) => children,
ResponsiveContainer: ({ children }: { children: unknown }) => children,
Tooltip: () => null,
XAxis: () => null,
YAxis: () => null,
}));
// Custom test helpers (替代 jest-dom matchers)
export const testHelpers = {
toBeInTheDocument: (element: Element | null) => {
const pass = element !== null && document.contains(element);
return {
message: () => (pass ? "Expected element not to be in document" : "Expected element to be in document"),
pass,
};
},
toHaveAttribute: (element: Element | null, attr: string, value?: string) => {
const pass = value === undefined ? (element?.hasAttribute(attr) ?? false) : element?.getAttribute(attr) === value;
return {
message: () =>
pass ? `Expected element not to have attribute "${attr}"` : `Expected element to have attribute "${attr}"`,
pass,
};
},
toHaveClass: (element: Element | null, className: string) => {
const pass = element?.classList.contains(className) ?? false;
return {
message: () =>
pass ? `Expected element not to have class "${className}"` : `Expected element to have class "${className}"`,
pass,
};
},
toHaveTextContent: (element: Element | null, text: RegExp | string) => {
const pass =
element?.textContent !== null &&
(typeof text === "string" ? element.textContent.includes(text) : text.test(element.textContent));
return {
message: () => (pass ? `Expected element not to have text "${text}"` : `Expected element to have text "${text}"`),
pass,
};
},
};