- 新增 src/server/config/ 模块(types、issues、variables、normalizer、schema)
- 配置布局从 server.host/server.port 切换为 server.listen.host/server.listen.port
- 移除 HOST/PORT 隐式环境变量覆盖,改为 YAML 显式 ${KEY} 变量引用
- 支持 ${KEY}、${KEY|default}、${KEY|}、$${KEY} 变量语法
- 使用 @sinclair/typebox + ajv 实现运行时严格契约校验和 JSON Schema 导出
- 新增 scripts/generate-config-schema.ts 和 config.schema.json
- 新增 bun run schema / schema:check 命令,check 先执行 schema:check
- 更新 README.md 和 DEVELOPMENT.md 匹配新配置体系
- 新增变量解析、schema 校验和 schema 同步测试
205 lines
6.1 KiB
TypeScript
205 lines
6.1 KiB
TypeScript
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" });
|
|
});
|
|
|
|
test("--help 输出用法并退出", () => {
|
|
const logs: string[] = [];
|
|
let exitCode: number | undefined;
|
|
const originalLog = console.log;
|
|
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
|
Object.defineProperty(process, "exit", {
|
|
configurable: true,
|
|
value: (code: number) => {
|
|
exitCode = code;
|
|
},
|
|
writable: true,
|
|
});
|
|
try {
|
|
parseRuntimeArgs(["--help"]);
|
|
} finally {
|
|
Object.defineProperty(process, "exit", {
|
|
configurable: true,
|
|
value: process.exit.bind(process),
|
|
writable: true,
|
|
});
|
|
console.log = originalLog;
|
|
}
|
|
expect(exitCode).toBe(0);
|
|
expect(logs.some((l) => l.includes("用法"))).toBe(true);
|
|
});
|
|
|
|
test("-h 输出用法并退出", () => {
|
|
const logs: string[] = [];
|
|
let exitCode: number | undefined;
|
|
const originalLog = console.log;
|
|
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
|
Object.defineProperty(process, "exit", {
|
|
configurable: true,
|
|
value: (code: number) => {
|
|
exitCode = code;
|
|
},
|
|
writable: true,
|
|
});
|
|
try {
|
|
parseRuntimeArgs(["-h"]);
|
|
} finally {
|
|
Object.defineProperty(process, "exit", {
|
|
configurable: true,
|
|
value: process.exit.bind(process),
|
|
writable: true,
|
|
});
|
|
console.log = originalLog;
|
|
}
|
|
expect(exitCode).toBe(0);
|
|
expect(logs.some((l) => l.includes("用法"))).toBe(true);
|
|
});
|
|
});
|
|
|
|
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("127.0.0.1");
|
|
} 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(3000);
|
|
} 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("新布局 server.listen 加载成功", async () => {
|
|
const temp = tmpdir();
|
|
const yamlPath = join(temp, "test-listen.yaml");
|
|
const yamlContent = 'server:\n listen:\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("旧布局 server.host/server.port 被拒绝", async () => {
|
|
const temp = tmpdir();
|
|
const yamlPath = join(temp, "test-old-layout.yaml");
|
|
const yamlContent = 'server:\n host: "0.0.0.0"\n port: 9999\n';
|
|
await writeFile(yamlPath, yamlContent);
|
|
|
|
try {
|
|
await loadServerConfig(yamlPath);
|
|
expect.unreachable();
|
|
} catch (error) {
|
|
expect((error as Error).message).toContain("未知字段");
|
|
} finally {
|
|
await rm(yamlPath, { force: true });
|
|
}
|
|
});
|
|
|
|
test("非法端口被拒绝", async () => {
|
|
const temp = tmpdir();
|
|
const yamlPath = join(temp, "test-bad-port.yaml");
|
|
const yamlContent = "server:\n listen:\n port: 99999\n";
|
|
await writeFile(yamlPath, yamlContent);
|
|
|
|
try {
|
|
await loadServerConfig(yamlPath);
|
|
expect.unreachable();
|
|
} catch (error) {
|
|
expect((error as Error).message).toBeTruthy();
|
|
} finally {
|
|
await rm(yamlPath, { force: true });
|
|
}
|
|
});
|
|
|
|
test("显式变量引用环境变量生效", async () => {
|
|
const prevHost = process.env["HOST"];
|
|
const prevPort = process.env["PORT"];
|
|
process.env["HOST"] = "10.0.0.1";
|
|
process.env["PORT"] = "4000";
|
|
|
|
const temp = tmpdir();
|
|
const yamlPath = join(temp, "test-env-var.yaml");
|
|
const yamlContent = 'server:\n listen:\n host: "${HOST}"\n port: ${PORT}\n';
|
|
await writeFile(yamlPath, yamlContent);
|
|
|
|
try {
|
|
const config = await loadServerConfig(yamlPath);
|
|
expect(config.host).toBe("10.0.0.1");
|
|
expect(config.port).toBe(4000);
|
|
} finally {
|
|
await rm(yamlPath, { force: true });
|
|
if (prevHost === undefined) delete process.env["HOST"];
|
|
else process.env["HOST"] = prevHost;
|
|
if (prevPort === undefined) delete process.env["PORT"];
|
|
else process.env["PORT"] = prevPort;
|
|
}
|
|
});
|
|
|
|
test("变量带默认值生效", async () => {
|
|
delete process.env["MY_HOST"];
|
|
const temp = tmpdir();
|
|
const yamlPath = join(temp, "test-default.yaml");
|
|
const yamlContent = 'server:\n listen:\n host: "${MY_HOST|0.0.0.0}"\n port: ${MY_PORT|5000}\n';
|
|
await writeFile(yamlPath, yamlContent);
|
|
|
|
try {
|
|
const config = await loadServerConfig(yamlPath);
|
|
expect(config.host).toBe("0.0.0.0");
|
|
expect(config.port).toBe(5000);
|
|
} finally {
|
|
await rm(yamlPath, { force: true });
|
|
}
|
|
});
|
|
});
|