Initial commit

This commit is contained in:
2026-05-20 00:18:07 +08:00
commit e2bf594719
58 changed files with 5885 additions and 0 deletions

13
tests/helpers.ts Normal file
View File

@@ -0,0 +1,13 @@
import { rm } from "node:fs/promises";
export async function rmRetry(dir: string, retries = 10, delayMs = 500) {
for (let i = 0; i < retries; i++) {
try {
await rm(dir, { force: true, recursive: true });
return;
} catch (e) {
if (i === retries - 1) throw e;
await new Promise((r) => setTimeout(r, delayMs));
}
}
}

View File

@@ -0,0 +1,71 @@
/* eslint-disable @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars, @typescript-eslint/require-await, @typescript-eslint/unbound-method */
import { describe, expect, test } from "bun:test";
import type { StartServerOptions } from "../../src/server/server";
import { bootstrap, type BootstrapDependencies } from "../../src/server/bootstrap";
const origExit = process.exit;
describe("bootstrap", () => {
test("使用默认依赖启动", async () => {
let started = false;
let signalRegistered = false;
const mockLoadConfig = (async () => ({
host: "127.0.0.1",
port: 0,
})) as unknown as BootstrapDependencies["loadConfig"];
const mockLogError = () => {};
const mockOnSignal = (_signal: string, _handler: () => void) => {
signalRegistered = true;
};
const mockStartServer = (_options: StartServerOptions) => {
started = true;
return {};
};
const deps: BootstrapDependencies = {
loadConfig: mockLoadConfig,
logError: mockLogError,
onSignal: mockOnSignal,
startServer: mockStartServer,
};
await bootstrap({ mode: "production" }, deps);
expect(started).toBe(true);
expect(signalRegistered).toBe(true);
});
test("启动失败时调用 logError", async () => {
let errorLogged = false;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
process.exit = ((code?: number) => {
throw new Error("process.exit called");
}) as unknown as typeof process.exit;
const deps: BootstrapDependencies = {
loadConfig: async () => {
throw new Error("test config error");
},
logError: () => {
errorLogged = true;
},
startServer: () => {
throw new Error("should not reach");
},
};
try {
await bootstrap({ mode: "production" }, deps);
} catch {
// process.exit throws to interrupt flow
}
process.exit = origExit;
expect(errorLogged).toBe(true);
});
});

View File

@@ -0,0 +1,95 @@
import { describe, expect, test } from "bun:test";
import { rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadServerConfig, parseRuntimeArgs } from "../../src/server/config";
describe("parseRuntimeArgs", () => {
test("无参数返回空对象", () => {
const result = parseRuntimeArgs([]);
expect(result).toEqual({});
});
test("有参数返回 configPath", () => {
const result = parseRuntimeArgs(["config.yaml"]);
expect(result).toEqual({ configPath: "config.yaml" });
});
});
describe("loadServerConfig", () => {
test("无 configPath 使用默认值", async () => {
const config = await loadServerConfig();
expect(config.host).toBe("127.0.0.1");
expect(config.port).toBe(3000);
});
test("环境变量 HOST 覆盖默认值", async () => {
const prev = process.env["HOST"];
process.env["HOST"] = "0.0.0.0";
try {
const config = await loadServerConfig();
expect(config.host).toBe("0.0.0.0");
} finally {
if (prev === undefined) {
delete process.env["HOST"];
} else {
process.env["HOST"] = prev;
}
}
});
test("环境变量 PORT 覆盖默认值", async () => {
const prev = process.env["PORT"];
process.env["PORT"] = "8080";
try {
const config = await loadServerConfig();
expect(config.port).toBe(8080);
} finally {
if (prev === undefined) {
delete process.env["PORT"];
} else {
process.env["PORT"] = prev;
}
}
});
test("YAML 配置文件不存在时报错", async () => {
try {
await loadServerConfig("/nonexistent/path/config.yaml");
expect.unreachable();
} catch (error) {
expect((error as Error).message).toContain("配置文件不存在");
}
});
test("YAML 配置文件加载 server 配置", async () => {
const temp = tmpdir();
const yamlPath = join(temp, "test-config.yaml");
const yamlContent = 'server:\n host: "0.0.0.0"\n port: 9999\n';
await writeFile(yamlPath, yamlContent);
try {
const config = await loadServerConfig(yamlPath);
expect(config.host).toBe("0.0.0.0");
expect(config.port).toBe(9999);
} finally {
await rm(yamlPath, { force: true });
}
});
test("YAML 缺少 server 字段时使用默认值", async () => {
const temp = tmpdir();
const yamlPath = join(temp, "test-empty.yaml");
const yamlContent = "runtime:\n debug: true\n";
await writeFile(yamlPath, yamlContent);
try {
const config = await loadServerConfig(yamlPath);
expect(config.host).toBe("127.0.0.1");
expect(config.port).toBe(3000);
} finally {
await rm(yamlPath, { force: true });
}
});
});

View File

@@ -0,0 +1,97 @@
import { describe, expect, test } from "bun:test";
import { validateIdParam, validatePagination, validateTimeRange } from "../../src/server/middleware";
describe("validateIdParam", () => {
test("有效的 ID 返回字符串", () => {
const result = validateIdParam("api-health_01", "production");
expect(result).not.toHaveProperty("status");
expect((result as { id: string }).id).toBe("api-health_01");
});
test("无效的 ID 返回 400", () => {
const invalid = ["-1", "_abc", "has space", "1.5", ""];
for (const id of invalid) {
const result = validateIdParam(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 });
});
});

127
tests/server/static.test.ts Normal file
View File

@@ -0,0 +1,127 @@
import { describe, expect, test } from "bun:test";
import {
contentTypeFor,
hasFileExtension,
htmlResponse,
serveStaticAsset,
type StaticAssets,
} from "../../src/server/static";
function createTestAssets(): StaticAssets {
return {
files: {
"/assets/index-a1b2c3.css": new Blob([".app{}"], { type: "text/css" }),
"/assets/index-a1b2c3.js": new Blob(["console.log(1)"], { type: "text/javascript" }),
"/assets/vendor-react-x9y8z7.js": new Blob(["react"], { type: "text/javascript" }),
"/favicon.svg": new Blob(["<svg/>"], { type: "image/svg+xml" }),
},
indexHtml: new Blob(["<!doctype html><html></html>"], { type: "text/html" }),
};
}
describe("contentTypeFor", () => {
test("JavaScript 文件", () => {
expect(contentTypeFor("/assets/index-a1b2c3.js")).toBe("text/javascript; charset=utf-8");
});
test("mjs 文件", () => {
expect(contentTypeFor("/assets/chunk.mjs")).toBe("text/javascript; charset=utf-8");
});
test("CSS 文件", () => {
expect(contentTypeFor("/assets/style.css")).toBe("text/css; charset=utf-8");
});
test("SVG 文件", () => {
expect(contentTypeFor("/icon.svg")).toBe("image/svg+xml");
});
test("未知扩展名返回 octet-stream", () => {
expect(contentTypeFor("/file.xyz")).toBe("application/octet-stream");
});
test("无扩展名返回 octet-stream", () => {
expect(contentTypeFor("/noext")).toBe("application/octet-stream");
});
});
describe("hasFileExtension", () => {
test("有扩展名", () => {
expect(hasFileExtension("/assets/index.js")).toBe(true);
expect(hasFileExtension("/favicon.svg")).toBe(true);
});
test("无扩展名", () => {
expect(hasFileExtension("/dashboard")).toBe(false);
expect(hasFileExtension("/")).toBe(false);
expect(hasFileExtension("/api/targets")).toBe(false);
});
});
describe("htmlResponse", () => {
test("返回 HTML 响应带正确 headers", async () => {
const blob = new Blob(["<html></html>"]);
const response = htmlResponse(blob);
expect(response.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
expect(response.headers.get("Cache-Control")).toBe("no-cache");
expect(await response.text()).toBe("<html></html>");
});
});
describe("serveStaticAsset", () => {
test("根路径返回 indexHtml", async () => {
const assets = createTestAssets();
const response = serveStaticAsset("/", assets);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
expect(response.headers.get("Cache-Control")).toBe("no-cache");
expect(await response.text()).toBe("<!doctype html><html></html>");
});
test("已知资源返回对应文件和 immutable 缓存", async () => {
const assets = createTestAssets();
const response = serveStaticAsset("/assets/index-a1b2c3.js", assets);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/javascript; charset=utf-8");
expect(response.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
expect(await response.text()).toBe("console.log(1)");
});
test("未知带扩展名路径返回 404", () => {
const assets = createTestAssets();
const response = serveStaticAsset("/assets/missing.js", assets);
expect(response.status).toBe(404);
});
test("SPA fallback — 无扩展名路径返回 indexHtml", async () => {
const assets = createTestAssets();
const response = serveStaticAsset("/dashboard", assets);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
expect(response.headers.get("Cache-Control")).toBe("no-cache");
expect(await response.text()).toBe("<!doctype html><html></html>");
});
test("SVG 资源返回正确 Content-Type", () => {
const assets = createTestAssets();
const response = serveStaticAsset("/favicon.svg", assets);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("image/svg+xml");
expect(response.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
});
test("CSS 资源返回正确 Content-Type", () => {
const assets = createTestAssets();
const response = serveStaticAsset("/assets/index-a1b2c3.css", assets);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/css; charset=utf-8");
});
});

106
tests/setup.ts Normal file
View File

@@ -0,0 +1,106 @@
/**
* 全局测试配置
* 主要为后端测试提供基础环境
* 组件测试使用各自的 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.MutationObserver = class {
disconnect() {}
observe() {}
takeRecords() {
return [];
}
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 = () => {};
Object.defineProperty(dom.window, "customElements", {
value: {
define: () => {},
get: () => undefined,
},
writable: true,
});
globalThis.customElements = dom.window.customElements;
import { afterEach } from "bun:test";
afterEach(() => {
document.body.innerHTML = "";
});

54
tests/web/App.test.tsx Normal file
View File

@@ -0,0 +1,54 @@
/* eslint-disable @typescript-eslint/require-await */
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement, StrictMode } from "react";
import { App } from "../../src/web/app";
import { ErrorBoundary } from "../../src/web/components/ErrorBoundary";
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: 0,
},
},
});
}
function renderApp() {
const queryClient = createTestQueryClient();
return render(
createElement(
StrictMode,
null,
createElement(
ErrorBoundary,
null,
createElement(QueryClientProvider, { client: queryClient }, createElement(App)),
),
),
);
}
describe("App", () => {
test("渲染 Layout 骨架和品牌名", () => {
// mock /health fetch 避免网络错误
window.fetch = (async () => {
return new Response(JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString() }), {
headers: { "Content-Type": "application/json" },
status: 200,
});
}) as unknown as typeof fetch;
renderApp();
expect(screen.getByText("{{app-name}}")).not.toBeNull();
expect(screen.getByText("系统")).not.toBeNull();
expect(screen.getByText("明亮")).not.toBeNull();
expect(screen.getByText("黑暗")).not.toBeNull();
});
});

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

@@ -0,0 +1,51 @@
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 content = element?.textContent ?? "";
const pass = element !== null && (typeof text === "string" ? content.includes(text) : text.test(content));
return {
message: () => (pass ? `Expected element not to have text "${text}"` : `Expected element to have text "${text}"`),
pass,
};
},
};

View File

@@ -0,0 +1,79 @@
import { describe, expect, test } from "bun:test";
import {
formatCountdown,
formatDurationUnit,
formatRelativeTime,
isOlderThan,
subtractHours,
} from "../../../src/web/utils/time";
describe("subtractHours", () => {
test("正常扣减小时", () => {
const result = subtractHours(new Date("2025-01-15T12:00:00.000Z"), 3);
expect(result.toISOString()).toBe("2025-01-15T09:00:00.000Z");
});
test("跨天扣减", () => {
const result = subtractHours(new Date("2025-01-15T02:00:00.000Z"), 6);
expect(result.toISOString()).toBe("2025-01-14T20:00:00.000Z");
});
test("跨月扣减", () => {
const result = subtractHours(new Date("2025-03-01T01:00:00.000Z"), 2);
expect(result.toISOString()).toBe("2025-02-28T23:00:00.000Z");
});
test("扣减 0 小时返回相同时间", () => {
const result = subtractHours(new Date("2025-01-15T12:00:00.000Z"), 0);
expect(result.toISOString()).toBe("2025-01-15T12:00:00.000Z");
});
});
describe("formatRelativeTime", () => {
const now = new Date("2025-01-01T00:02:00.000Z");
test("格式化秒和分钟", () => {
expect(formatRelativeTime("2025-01-01T00:01:45.000Z", now)).toBe("15秒前");
expect(formatRelativeTime("2025-01-01T00:00:00.000Z", now)).toBe("2分钟前");
});
test("无时间返回占位", () => {
expect(formatRelativeTime(null, now)).toBe("尚无检查数据");
expect(formatRelativeTime("invalid", now)).toBe("尚无检查数据");
});
});
describe("formatDurationUnit", () => {
test("按秒、分钟、小时动态格式化", () => {
expect(formatDurationUnit(1500)).toEqual({ suffix: "秒", value: 1.5 });
expect(formatDurationUnit(120000)).toEqual({ suffix: "分钟", value: 2 });
expect(formatDurationUnit(5400000)).toEqual({ suffix: "小时", value: 1.5 });
});
test("空时长返回占位", () => {
expect(formatDurationUnit(null)).toEqual({ suffix: "", value: 0 });
});
});
describe("formatCountdown", () => {
test("格式化秒级和分钟级倒计时", () => {
expect(formatCountdown(0)).toBe("0秒");
expect(formatCountdown(59)).toBe("59秒");
expect(formatCountdown(60)).toBe("1分0秒");
expect(formatCountdown(299)).toBe("4分59秒");
});
});
describe("isOlderThan", () => {
test("判断时间是否超过阈值", () => {
const now = new Date("2025-01-01T00:02:00.000Z");
expect(isOlderThan("2025-01-01T00:00:59.000Z", 60000, now)).toBe(true);
expect(isOlderThan("2025-01-01T00:01:30.000Z", 60000, now)).toBe(false);
});
});