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/gateway-checker", import.meta.url)); await assertExecutableExists(executablePath); const tempDir = mkdtempSync(join(tmpdir(), "gc-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(`${baseUrl}/health`, 200); assert(health.ok === true, "健康检查响应缺少 ok=true"); assertSecurityHeaders(healthResponse, "/health"); const { body: summary } = await expectJson(`${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("Gateway Checker"), "前端根页面缺少标题"); assert(rootResponse.headers.get("cache-control") === "no-cache", "前端根页面应使用 no-cache"); 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`); const missingAsset = await expectText(`${baseUrl}/assets/not-found.js`, 404); assert(!missingAsset.body.includes("Gateway Checker"), "未知静态资源不应返回前端入口页面"); 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 { 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(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 | null): Promise { if (!stream) return ""; return new Response(stream).text(); }