将 gateway-checker/Gateway Checker 统一替换为 dial-server/DiAL - 包名、可执行文件名、API service 标识改为 dial-server - UI 标题改为 DiAL,副标题改为统一拨测平台 - 同步更新测试断言、构建脚本、示例配置和文档
168 lines
5.5 KiB
TypeScript
168 lines
5.5 KiB
TypeScript
import { access } from "node:fs/promises";
|
|
import { fileURLToPath } from "node:url";
|
|
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/dial-server", import.meta.url));
|
|
|
|
await assertExecutableExists(executablePath);
|
|
|
|
const tempDir = mkdtempSync(join(tmpdir(), "dial-smoke-"));
|
|
const configPath = join(tempDir, "probes.yaml");
|
|
|
|
const port = await getFreePort();
|
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
|
|
writeFileSync(
|
|
configPath,
|
|
`server:
|
|
port: ${port}
|
|
targets:
|
|
- name: "httpbin"
|
|
type: http
|
|
http:
|
|
url: "https://httpbin.org/get"
|
|
interval: "5m"
|
|
timeout: "15s"
|
|
expect:
|
|
status: [200]
|
|
`,
|
|
);
|
|
const app = Bun.spawn([executablePath, configPath], {
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: { ...process.env },
|
|
});
|
|
const stdout = readStream(app.stdout);
|
|
const stderr = readStream(app.stderr);
|
|
|
|
try {
|
|
await waitForServer(`${baseUrl}/health`);
|
|
|
|
const { body: health, response: healthResponse } = await expectJson<HealthResponse>(`${baseUrl}/health`, 200);
|
|
assert(health.ok === true, "健康检查响应缺少 ok=true");
|
|
assertSecurityHeaders(healthResponse, "/health");
|
|
|
|
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");
|
|
|
|
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("DiAL"), "前端根页面缺少标题");
|
|
assert(rootResponse.headers.get("cache-control") === "no-cache", "前端根页面应使用 no-cache");
|
|
|
|
const { body: fallbackHtml } = await expectText(`${baseUrl}/dashboard`, 200);
|
|
assert(fallbackHtml.includes("DiAL"), "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`);
|
|
|
|
const missingAsset = await expectText(`${baseUrl}/assets/not-found.js`, 404);
|
|
assert(!missingAsset.body.includes("DiAL"), "未知静态资源不应返回前端入口页面");
|
|
|
|
console.log(`Smoke test passed: ${baseUrl}`);
|
|
} catch (error) {
|
|
app.kill();
|
|
const [out, err] = await Promise.all([stdout, stderr]);
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
|
|
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) {
|
|
try {
|
|
await access(path);
|
|
} catch (error) {
|
|
throw new Error(`找不到 executable: ${path},请先运行 bun run build`, { cause: error });
|
|
}
|
|
}
|
|
|
|
async function getFreePort(): Promise<number> {
|
|
const server = Bun.serve({
|
|
hostname: "127.0.0.1",
|
|
port: 0,
|
|
fetch: () => new Response("ok"),
|
|
});
|
|
const port = server.port;
|
|
|
|
server.stop(true);
|
|
|
|
if (port === undefined) {
|
|
throw new Error("无法分配 smoke test 端口");
|
|
}
|
|
|
|
return port;
|
|
}
|
|
|
|
async function waitForServer(url: string) {
|
|
const deadline = Date.now() + 8_000;
|
|
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const response = await fetch(url);
|
|
|
|
if (response.ok) return;
|
|
} catch {
|
|
await Bun.sleep(100);
|
|
}
|
|
}
|
|
|
|
throw new Error(`服务未在超时时间内启动: ${url}`);
|
|
}
|
|
|
|
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}`);
|
|
assert(response.headers.get("content-type")?.includes("application/json") === true, `${url} 应返回 JSON`);
|
|
|
|
return { body: (await response.json()) as T, response };
|
|
}
|
|
|
|
async function expectText(url: string, status: number): Promise<{ body: string; response: Response }> {
|
|
const response = await fetch(url);
|
|
|
|
assert(response.status === status, `${url} 应返回 ${status},实际为 ${response.status}`);
|
|
|
|
return { body: await response.text(), response };
|
|
}
|
|
|
|
function assertSecurityHeaders(response: Response, label: string) {
|
|
assert(response.headers.get("x-content-type-options") === "nosniff", `${label} 缺少 nosniff 安全头`);
|
|
assert(
|
|
response.headers.get("referrer-policy") === "strict-origin-when-cross-origin",
|
|
`${label} 缺少 Referrer-Policy 安全头`,
|
|
);
|
|
}
|
|
|
|
function assert(condition: boolean, message: string): asserts condition {
|
|
if (!condition) {
|
|
throw new Error(message);
|
|
}
|
|
}
|
|
|
|
async function readStream(stream: ReadableStream<Uint8Array> | null): Promise<string> {
|
|
if (!stream) return "";
|
|
|
|
return new Response(stream).text();
|
|
}
|