1
0
Files
DiAL/tests/server/checker/variables.test.ts
lanyuanxiaoyao cf847ccd7a feat: 重构配置生命周期为 Authoring/Normalized/Resolved 三层
将变量替换和 expect 简写展开统一放入 Normalized 阶段,
运行时 AJV 使用 Normalized schema,导出 schema 面向 Authoring Config。

主要变更:
- 新增 normalizer.ts 实现 normalizeAuthoringConfig()
- 拆分 Authoring/Normalized 双 schema,checker 接口支持 authoring/normalized 片段
- config-loader 流程:normalize → Normalized AJV → semantic → resolve
- validator 兼容层自动分派 raw/normalized expect 形态
- 删除 rawExpect,store.expect 列写入 null
- Authoring schema 对 integer/boolean/enum 字段接受变量引用
- 修复 DB/HTTP validate 入口守卫和 LLM options integer 变量引用
- 优化 compact() 避免 undefined 覆盖隐患
- 移除 content.ts 恒为 true 的前置条件
- 同步 5 个主规范并归档 change
2026-05-22 14:00:47 +08:00

436 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, test } from "bun:test";
import { normalizeAuthoringConfig } from "../../../src/server/checker/normalizer";
import { extractVariables, resolveVariables } from "../../../src/server/checker/variables";
describe("config variables", () => {
test("normalizeAuthoringConfig 替换变量、展开 expect 简写并移除 variables", () => {
const result = normalizeAuthoringConfig({
targets: [
{
expect: { durationMs: "${maxMs}" },
http: { url: "${url}" },
id: "api",
type: "http",
},
],
variables: { maxMs: 1000, url: "https://example.com" },
});
expect(result.issues).toHaveLength(0);
expect(result.config).toEqual({
targets: [
{
expect: { durationMs: { equals: 1000 } },
http: { url: "https://example.com" },
id: "api",
type: "http",
},
],
});
});
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("替换范围覆盖 server、probes、targets并跳过 variables 和 target 顶层 id/type", () => {
const result = resolveVariables({
defaults: { interval: "${interval}" },
probes: { execution: { maxConcurrentChecks: "${maxConcurrentChecks}" } },
server: { host: "${host}", logging: { level: "${logLevel}" }, storage: { retention: "${retention}" } },
targets: [
{
cmd: {
args: ["--host", "${host}"],
env: { "${TOKEN_KEY}": "${token}" },
exec: "echo",
},
expect: { rows: [{ id: { equals: "${expectedId}" }, type: { equals: "${expectedType}" } }] },
id: "${id}",
type: "${type}",
},
],
variables: {
expectedId: 1,
expectedType: "service",
host: "localhost",
id: "resolved",
interval: "30s",
logLevel: "debug",
maxConcurrentChecks: 20,
retention: "24h",
token: "abc",
TOKEN_KEY: "TOKEN",
type: "cmd",
},
});
expect(result.issues).toHaveLength(0);
const config = result.config as {
defaults: { interval: string };
probes: { execution: { maxConcurrentChecks: number } };
server: { host: string; logging: { level: string }; storage: { retention: string } };
targets: Array<{
cmd: { args: string[]; env: Record<string, string> };
expect: { rows: Array<{ id: { equals: number }; type: { equals: string } }> };
id: string;
type: string;
}>;
variables: { host: string };
};
expect(config.server.host).toBe("localhost");
expect(config.server.logging.level).toBe("debug");
expect(config.server.storage.retention).toBe("24h");
expect(config.probes.execution.maxConcurrentChecks).toBe(20);
expect(config.variables.host).toBe("localhost");
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_KEY}"]).toBe("abc");
expect(config.targets[0]!.expect.rows[0]!.id.equals).toBe(1);
expect(config.targets[0]!.expect.rows[0]!.type.equals).toBe("service");
});
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("空默认值和空环境变量保持字符串空值", () => {
const original = process.env["EMPTY_ENV_VALUE"];
process.env["EMPTY_ENV_VALUE"] = "";
try {
const result = resolveVariables({
server: { listen: { host: "${EMPTY_ENV_VALUE}" }, logging: { file: { path: "${OPTIONAL_LOG_PATH|}" } } },
targets: [
{
http: { body: "${OPTIONAL_BODY|}", url: "${HOST|localhost}" },
id: "empty-values",
type: "http",
},
],
});
expect(result.issues).toHaveLength(0);
const config = result.config as {
server: { listen: { host: unknown }; logging: { file: { path: unknown } } };
targets: Array<{ http: { body: unknown } }>;
};
expect(config.server.listen.host).toBe("");
expect(typeof config.server.listen.host).toBe("string");
expect(config.server.logging.file.path).toBe("");
expect(typeof config.server.logging.file.path).toBe("string");
expect(config.targets[0]!.http.body).toBe("");
expect(typeof config.targets[0]!.http.body).toBe("string");
} finally {
restoreEnv("EMPTY_ENV_VALUE", original);
}
});
test("server.storage 段支持替换", () => {
const result = resolveVariables({
server: { storage: { 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 { server: { storage: { retention: string } } };
expect(config.server.storage.retention).toBe("24h");
});
test("probes 段支持替换", () => {
const result = resolveVariables({
probes: { execution: { maxConcurrentChecks: "${maxConcurrentChecks}" } },
targets: [
{
http: { url: "${host}" },
id: "probes-no-replace",
type: "http",
},
],
variables: { host: "https://example.com", maxConcurrentChecks: 20 },
});
expect(result.issues).toHaveLength(0);
const config = result.config as { probes: { execution: { maxConcurrentChecks: number } } };
expect(config.probes.execution.maxConcurrentChecks).toBe(20);
});
test("server.logging 段支持替换", () => {
const result = resolveVariables({
server: { logging: { level: "${logLevel}" } },
targets: [
{
http: { url: "${host}" },
id: "logging-no-replace",
type: "http",
},
],
variables: { host: "https://example.com", logLevel: "debug" },
});
expect(result.issues).toHaveLength(0);
const config = result.config as { server: { logging: { level: string } } };
expect(config.server.logging.level).toBe("debug");
});
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;
}