1
0

feat: 配置变量系统与 target id/name 双字段标识

- 新增顶层 variables 段支持 string/number/boolean 字面量
- target 字符串字段支持 、、{...} 转义语法
- 变量解析优先级: variables -> process.env -> 默认值 -> 报错
- 完整引用保留原始类型,部分引用拼接为字符串
- 变量替换在 YAML 解析后、AJV 校验前执行
- 替换仅作用于 targets,跳过 id/type 字段
- target 新增必填 id 字段作为唯一标识,name 改为可选展示名称
- 数据库存储/API/前端全面迁移到 id 标识
- 统一 checker 运行时类型检查为 es-toolkit predicates
- 同步 delta specs 到主 specs,归档 config-variables 变更
This commit is contained in:
2026-05-17 00:37:54 +08:00
parent 366b3211c8
commit 7926514986
53 changed files with 1538 additions and 333 deletions

View File

@@ -0,0 +1,313 @@
import { describe, expect, test } from "bun:test";
import { extractVariables, resolveVariables } from "../../../src/server/checker/variables";
describe("config variables", () => {
test("提取合法 variables 类型", () => {
const result = extractVariables({ variables: { enabled: true, host: "example.com", port: 5432 } });
expect(result.issues).toHaveLength(0);
expect(Object.fromEntries(result.variables)).toEqual({ enabled: true, host: "example.com", port: 5432 });
});
test("拒绝非法 variables value 和 key", () => {
const result = extractVariables({
variables: {
"123start": "value",
empty: null,
list: [1, 2, 3],
obj: { a: 1 },
},
});
expect(result.issues.map((item) => [item.code, item.path, item.message])).toEqual([
["invalid-format", "variables.123start", "变量名不符合命名规则"],
["invalid-type", "variables.empty", "变量值不允许为 null"],
["invalid-type", "variables.list", "变量值不允许为 array"],
["invalid-type", "variables.obj", "变量值不允许为 object"],
]);
});
test("解析简单引用、默认值、转义、多变量拼接和无引用字符串", () => {
const result = resolveVariables({
targets: [
{
http: {
body: "Hello $${name}",
headers: {
Authorization: "Bearer ${token}",
Pattern: "${PATTERN|foo|bar}",
},
url: "${protocol}://${host}:${port}/api",
},
id: "api-health",
name: "API",
type: "http",
},
],
variables: { host: "example.com", port: 443, protocol: "https", token: "abc" },
});
expect(result.issues).toHaveLength(0);
const target = (
result.config as { targets: Array<{ http: { body: string; headers: Record<string, string>; url: string } }> }
).targets[0]!;
expect(target.http.url).toBe("https://example.com:443/api");
expect(target.http.headers["Authorization"]).toBe("Bearer abc");
expect(target.http.headers["Pattern"]).toBe("foo|bar");
expect(target.http.body).toBe("Hello ${name}");
});
test("完整引用保留类型,部分引用强制为字符串", () => {
const result = resolveVariables({
targets: [
{
http: {
body: "port: ${port}",
ignoreSSL: "${ssl}",
maxRedirects: "${port}",
url: "${host}",
},
id: "typed-http",
type: "http",
},
],
variables: { host: "https://example.com", port: 5, ssl: true },
});
expect(result.issues).toHaveLength(0);
const http = (result.config as { targets: Array<{ http: Record<string, unknown> }> }).targets[0]!.http;
expect(http["url"]).toBe("https://example.com");
expect(http["maxRedirects"]).toBe(5);
expect(http["ignoreSSL"]).toBe(true);
expect(http["body"]).toBe("port: 5");
});
test("环境变量和默认值在完整引用时做类型推断", () => {
const originalMaxRedirects = process.env["MAX_REDIRECTS"];
const originalIgnoreSsl = process.env["IGNORE_SSL"];
process.env["MAX_REDIRECTS"] = "5";
process.env["IGNORE_SSL"] = "false";
try {
const result = resolveVariables({
targets: [
{
http: {
ignoreSSL: "${IGNORE_SSL}",
maxRedirects: "${MAX_REDIRECTS}",
url: "${HOST|localhost}",
},
id: "env-http",
type: "http",
},
],
});
expect(result.issues).toHaveLength(0);
const http = (result.config as { targets: Array<{ http: Record<string, unknown> }> }).targets[0]!.http;
expect(http["maxRedirects"]).toBe(5);
expect(http["ignoreSSL"]).toBe(false);
expect(http["url"]).toBe("localhost");
} finally {
restoreEnv("MAX_REDIRECTS", originalMaxRedirects);
restoreEnv("IGNORE_SSL", originalIgnoreSsl);
}
});
test("解析优先级为 variables、环境变量、默认值", () => {
const originalPort = process.env["PORT"];
const originalHost = process.env["HOST"];
process.env["PORT"] = "3000";
process.env["HOST"] = "env-host";
try {
const result = resolveVariables({
targets: [
{
http: {
body: "${MISSING|fallback}",
headers: { Host: "${HOST}" },
maxRedirects: "${PORT}",
url: "${HOST_FROM_VARIABLES}",
},
id: "priority-http",
type: "http",
},
],
variables: { HOST_FROM_VARIABLES: "config-host", PORT: 5432 },
});
expect(result.issues).toHaveLength(0);
const http = (result.config as { targets: Array<{ http: Record<string, unknown> }> }).targets[0]!.http;
expect(http["maxRedirects"]).toBe(5432);
expect((http["headers"] as Record<string, string>)["Host"]).toBe("env-host");
expect(http["body"]).toBe("fallback");
expect(http["url"]).toBe("config-host");
} finally {
restoreEnv("PORT", originalPort);
restoreEnv("HOST", originalHost);
}
});
test("替换范围仅 targets且跳过 id 和 type 字段", () => {
const result = resolveVariables({
defaults: { interval: "${interval}" },
server: { host: "${host}" },
targets: [
{
cmd: {
args: ["--host", "${host}"],
env: { TOKEN: "${token}" },
exec: "echo",
},
id: "${id}",
type: "${type}",
},
],
variables: { host: "localhost", id: "resolved", interval: "30s", token: "abc", type: "cmd" },
});
expect(result.issues).toHaveLength(0);
const config = result.config as {
defaults: { interval: string };
server: { host: string };
targets: Array<{ cmd: { args: string[]; env: Record<string, string> }; id: string; type: string }>;
};
expect(config.server.host).toBe("${host}");
expect(config.defaults.interval).toBe("${interval}");
expect(config.targets[0]!.id).toBe("${id}");
expect(config.targets[0]!.type).toBe("${type}");
expect(config.targets[0]!.cmd.args[1]).toBe("localhost");
expect(config.targets[0]!.cmd.env["TOKEN"]).toBe("abc");
});
test("默认值推断为 booleantrue/false", () => {
const result = resolveVariables({
targets: [
{
http: {
ignoreSSL: "${DUMMY_SSL|false}",
url: "${HOST|localhost}",
},
id: "default-bool",
type: "http",
},
],
});
expect(result.issues).toHaveLength(0);
const http = (result.config as { targets: Array<{ http: Record<string, unknown> }> }).targets[0]!.http;
expect(http["ignoreSSL"]).toBe(false);
expect(typeof http["ignoreSSL"]).toBe("boolean");
const result2 = resolveVariables({
targets: [
{
http: {
ignoreSSL: "${DUMMY_SSL|true}",
url: "${HOST|localhost}",
},
id: "default-bool-true",
type: "http",
},
],
});
expect(result2.issues).toHaveLength(0);
const http2 = (result2.config as { targets: Array<{ http: Record<string, unknown> }> }).targets[0]!.http;
expect(http2["ignoreSSL"]).toBe(true);
expect(typeof http2["ignoreSSL"]).toBe("boolean");
});
test("runtime 段不替换", () => {
const result = resolveVariables({
runtime: { maxConcurrentChecks: 10, retention: "${retention}" },
targets: [
{
http: { url: "${host}" },
id: "rt-no-replace",
type: "http",
},
],
variables: { host: "https://example.com", retention: "24h" },
});
expect(result.issues).toHaveLength(0);
const config = result.config as { runtime: { retention: string } };
expect(config.runtime.retention).toBe("${retention}");
});
test("variables 段为非对象时报错", () => {
const strResult = extractVariables({ variables: "invalid" });
expect(strResult.issues).toHaveLength(1);
expect(strResult.issues[0]!.code).toBe("invalid-type");
const numResult = extractVariables({ variables: 123 });
expect(numResult.issues).toHaveLength(1);
expect(numResult.issues[0]!.code).toBe("invalid-type");
const nullResult = extractVariables({ variables: null });
expect(nullResult.issues).toHaveLength(1);
expect(nullResult.issues[0]!.code).toBe("invalid-type");
});
test("无 variables 段时环境变量仍可引用", () => {
const original = process.env["DIAL_TEST_ENV_ONLY"];
process.env["DIAL_TEST_ENV_ONLY"] = "env-value";
try {
const result = resolveVariables({
targets: [
{
http: { url: "${DIAL_TEST_ENV_ONLY}" },
id: "env-only",
type: "http",
},
],
});
expect(result.issues).toHaveLength(0);
const http = (result.config as { targets: Array<{ http: { url: string } }> }).targets[0]!.http;
expect(http.url).toBe("env-value");
} finally {
restoreEnv("DIAL_TEST_ENV_ONLY", original);
}
});
test("缺失变量收集所有 unresolved-variable issue", () => {
const result = resolveVariables({
targets: [
{
http: {
headers: { Authorization: "${missing_token}" },
url: "${missing_base_url}/health/${missing_path}",
},
id: "api-health",
type: "http",
},
],
});
expect(result.issues).toHaveLength(3);
expect(result.issues.map((item) => item.code)).toEqual([
"unresolved-variable",
"unresolved-variable",
"unresolved-variable",
]);
expect(result.issues.map((item) => item.path)).toEqual([
"targets[0].http.headers.Authorization",
"targets[0].http.url",
"targets[0].http.url",
]);
expect(result.issues.every((item) => item.targetId === "api-health")).toBe(true);
expect(result.issues.map((item) => item.message).join("\n")).toContain('变量 "missing_base_url"');
});
});
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key];
return;
}
process.env[key] = value;
}