feat: 将 demo 项目转化为 HTTP 拨测监控工具
新增 YAML 配置解析(Bun 内置 YAML)、SQLite 数据存储(bun:sqlite)、按 interval 分组并发拨测引擎、REST API(/api/summary、/api/targets、/api/targets/:id/history、/api/targets/:id/trend)、React 前端 Dashboard(统计卡片、目标表格、可展开详情面板、recharts 趋势图)。CLI 简化为仅接受配置文件路径。移除 /api/demo 路由和相关 demo 代码。保留 /health、静态资源服务和 SPA fallback。
This commit is contained in:
@@ -1,21 +1,35 @@
|
||||
import { access } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { DemoResponse, HealthResponse } from "../src/shared/api";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { HealthResponse, SummaryResponse } from "../src/shared/api";
|
||||
|
||||
const executablePath = process.argv[2] ?? fileURLToPath(new URL("../dist/gateway-checker", import.meta.url));
|
||||
|
||||
await assertExecutableExists(executablePath);
|
||||
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "gc-smoke-"));
|
||||
const configPath = join(tempDir, "probes.yaml");
|
||||
|
||||
writeFileSync(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "httpbin"
|
||||
url: "https://httpbin.org/get"
|
||||
interval: "5m"
|
||||
timeout: "15s"
|
||||
expect:
|
||||
status: [200]
|
||||
`,
|
||||
);
|
||||
|
||||
const port = await getFreePort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const app = Bun.spawn([executablePath, "--host", "127.0.0.1", "--port", String(port)], {
|
||||
const app = Bun.spawn([executablePath, configPath], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: {
|
||||
...process.env,
|
||||
HOST: "127.0.0.1",
|
||||
PORT: String(port),
|
||||
},
|
||||
env: { ...process.env },
|
||||
});
|
||||
const stdout = readStream(app.stdout);
|
||||
const stderr = readStream(app.stderr);
|
||||
@@ -27,37 +41,36 @@ try {
|
||||
assert(health.ok === true, "健康检查响应缺少 ok=true");
|
||||
assertSecurityHeaders(healthResponse, "/health");
|
||||
|
||||
const { body: demo, response: demoResponse } = await expectJson<DemoResponse>(`${baseUrl}/api/demo`, 200);
|
||||
assert(demo.message.includes("/api/demo"), "demo 响应未包含预期 message");
|
||||
assert(demo.runtime.mode === "production", "demo 响应 runtime mode 应为 production");
|
||||
assertSecurityHeaders(demoResponse, "/api/demo");
|
||||
const { body: summary } = await expectJson<SummaryResponse>(`${baseUrl}/api/summary`, 200);
|
||||
assert(summary.total === 1, "总览统计: total 应为 1");
|
||||
assertSecurityHeaders((await fetch(`${baseUrl}/api/summary`)), "/api/summary");
|
||||
|
||||
const { body: targets } = await expectJson(`${baseUrl}/api/targets`, 200);
|
||||
assert(Array.isArray(targets), "/api/targets 应返回数组");
|
||||
assert(targets.length === 1, "/api/targets 应有 1 个目标");
|
||||
assert(targets[0].name === "httpbin", "目标名称应为 httpbin");
|
||||
|
||||
const missingApi = await fetch(`${baseUrl}/api/not-found`);
|
||||
assert(missingApi.status === 404, "未知 API 应返回 404");
|
||||
assert(missingApi.headers.get("content-type")?.includes("application/json") === true, "未知 API 应返回 JSON");
|
||||
assertSecurityHeaders(missingApi, "/api/not-found");
|
||||
|
||||
const missingTarget = await fetch(`${baseUrl}/api/targets/99999/history`);
|
||||
assert(missingTarget.status === 404, "不存在的目标应返回 404");
|
||||
|
||||
const { body: rootHtml, response: rootResponse } = await expectText(`${baseUrl}/`, 200);
|
||||
assert(rootHtml.includes("Gateway Checker Demo"), "前端根页面缺少 demo 标题");
|
||||
assert(rootHtml.includes("Gateway Checker"), "前端根页面缺少标题");
|
||||
assert(rootResponse.headers.get("cache-control") === "no-cache", "前端根页面应使用 no-cache");
|
||||
assertSecurityHeaders(rootResponse, "/");
|
||||
|
||||
const { body: fallbackHtml, response: fallbackResponse } = await expectText(`${baseUrl}/dashboard`, 200);
|
||||
assert(fallbackHtml.includes("Gateway Checker Demo"), "SPA fallback 未返回前端入口页面");
|
||||
assert(fallbackResponse.headers.get("cache-control") === "no-cache", "SPA fallback 应使用 no-cache");
|
||||
assertSecurityHeaders(fallbackResponse, "/dashboard");
|
||||
const { body: fallbackHtml } = await expectText(`${baseUrl}/dashboard`, 200);
|
||||
assert(fallbackHtml.includes("Gateway Checker"), "SPA fallback 未返回前端入口页面");
|
||||
|
||||
const assetPath = rootHtml.match(/(?:src|href)="(\/assets\/[^"]+)"/)?.[1];
|
||||
assert(assetPath !== undefined, "前端入口页面未引用 /assets/* 资源");
|
||||
|
||||
const asset = await fetch(`${baseUrl}${assetPath}`);
|
||||
assert(asset.status === 200, `静态资源 ${assetPath} 未返回 200`);
|
||||
assert(asset.headers.get("cache-control") === "public, max-age=31536000, immutable", "静态资源应使用长缓存");
|
||||
assertSecurityHeaders(asset, assetPath);
|
||||
|
||||
const missingAsset = await expectText(`${baseUrl}/assets/not-found.js`, 404);
|
||||
assert(!missingAsset.body.includes("Gateway Checker Demo"), "未知静态资源不应返回前端入口页面");
|
||||
assertSecurityHeaders(missingAsset.response, "/assets/not-found.js");
|
||||
assert(!missingAsset.body.includes("Gateway Checker"), "未知静态资源不应返回前端入口页面");
|
||||
|
||||
console.log(`Smoke test passed: ${baseUrl}`);
|
||||
} catch (error) {
|
||||
@@ -68,6 +81,7 @@ try {
|
||||
throw new Error(`executable smoke test 失败: ${message}\nstdout:\n${out}\nstderr:\n${err}`, { cause: error });
|
||||
} finally {
|
||||
app.kill();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertExecutableExists(path: string) {
|
||||
@@ -111,7 +125,7 @@ async function waitForServer(url: string) {
|
||||
throw new Error(`服务未在超时时间内启动: ${url}`);
|
||||
}
|
||||
|
||||
async function expectJson<T>(url: string, status: number): Promise<{ body: T; response: Response }> {
|
||||
async function expectJson<T = unknown>(url: string, status: number): Promise<{ body: T; response: Response }> {
|
||||
const response = await fetch(url);
|
||||
|
||||
assert(response.status === status, `${url} 应返回 ${status},实际为 ${response.status}`);
|
||||
|
||||
Reference in New Issue
Block a user