1
0

feat: 扩展配置变量替换范围至 server/probes/targets,支持空默认值语法

This commit is contained in:
2026-05-21 18:42:42 +08:00
parent 79358ba50d
commit 6ca8b36542
6 changed files with 258 additions and 53 deletions

View File

@@ -248,7 +248,7 @@ export function handleMetrics(idStr: string, url: URL, store: ProbeStore, mode:
配置加载流程固定为:`unknown -> 变量替换 -> RawProbeConfig -> ValidatedProbeConfig -> ResolvedConfig`
变量替换阶段由 `variables.ts` 负责,在 YAML 解析之后、AJV 契约校验之前执行。顶层 `variables` 支持 string/number/boolean 字面量target 字符串字段支持 `${key}``${key|default}``$${key}`,解析优先级为 `variables -> process.env -> 默认值`;替换范围仅限 `targets`,且跳过 `id``type` 字段。
变量替换阶段由 `variables.ts` 负责,在 YAML 解析之后、AJV 契约校验之前执行。顶层 `variables` 支持 string/number/boolean 字面量且自身不参与替换;`server``probes``targets` 字符串字段支持 `${key}``${key|default}``${key|}``$${key}`,解析优先级为 `variables -> process.env -> 默认值 -> unresolved-variable 报错`;替换范围不包含对象 key且仅跳过 `targets[].id``targets[].type` 字段。
`config-loader.ts` 只负责 YAML 解析、契约调度、公共语义校验和最终运行期解析checker 专属规则必须下沉到对应 checker 的 `schema.ts``validate.ts`

View File

@@ -119,22 +119,25 @@ dist/release/
server: # 服务配置(均可省略)
listen:
host: "127.0.0.1"
port: 3000
port: "${server_port}"
storage:
dataDir: "/tmp/probes_data"
retention: "7d"
retention: "${retention}"
logging:
level: "info"
level: "${log_level|info}"
file:
path: "<dataDir>/logs/dial.log"
probes: # 拨测运行时配置(可省略)
execution:
maxConcurrentChecks: 20
maxConcurrentChecks: "${max_checks}"
variables: # 配置变量(可省略)
env_name: "生产"
base_url: "https://api.example.com"
server_port: 3000
retention: "7d"
max_checks: 20
default_interval: "30s" # 通过变量在多个 target 间共享常用值
default_timeout: "10s"
@@ -200,17 +203,18 @@ targets: # 拨测目标列表(必填)
- `timeout``10s`(超时时间)
- 各 checker 专属默认值见对应章节
如需在多个 target 间共享相同的配置值,可使用 `variables` 定义变量,然后在 target 中通过 `${var}` 引用。例如在 `variables` 中定义 `default_interval: "30s"`,在多个 target 的 `interval` 字段写 `${default_interval}`
如需在配置文件中共享相同的配置值,可使用 `variables` 定义变量,然后在 `server``probes``targets` 中通过 `${var}` 引用。例如在 `variables` 中定义 `default_interval: "30s"`,在多个 target 的 `interval` 字段写 `${default_interval}`
### variables — 配置变量
`variables` 是顶层动态键值表key 必须符合 `[a-zA-Z_][a-zA-Z0-9_]*`value 仅支持 string、number、boolean。target 中的字符串值可引用变量:
`variables` 是顶层动态键值表key 必须符合 `[a-zA-Z_][a-zA-Z0-9_]*`value 仅支持 string、number、boolean。`server``probes``targets` 中的字符串值可引用变量:
- `${key}`:引用 variables 或环境变量
- `${key|default}`:变量和环境变量都不存在时使用默认值,第一个 `|` 后的内容为默认值
- `${key|}`:变量和环境变量都不存在时使用空字符串作为默认值
- `$${key}`:转义输出字面量 `${key}`
解析优先级为 `variables -> process.env -> 默认值`。字段值完整等于单个变量引用时会保留 number/boolean/string 类型;部分拼接时统一转为字符串。变量替换作用于 `targets`,且不会替换 `id``type` 字段
解析优先级为 `variables -> process.env -> 默认值`,三者均不存在时配置校验失败。字段值完整等于单个变量引用时会保留 number/boolean/string 类型,环境变量和默认值会做类型推断,但空字符串保持为字符串;部分拼接时统一转为字符串。变量替换作用于 `server``probes``targets`,不作用于 `variables` 段自身,且不会替换 `targets[].id``targets[].type` 字段;对象 key 不参与替换
### targets — 拨测目标列表(必填)

View File

@@ -37,10 +37,10 @@
#### Scenario: 不定义 variables 段
- **WHEN** 配置文件不包含 variables 段
- **THEN** 系统 SHALL 正常启动,targets 中的 `${...}` 引用仅从环境变量查找
- **THEN** 系统 SHALL 正常启动,支持变量替换的配置字段中的 `${...}` 引用仅从环境变量和表达式默认值查找
### Requirement: 变量引用语法
targets 中的字符串值 SHALL 支持 `${key}` 语法引用变量。系统 SHALL 支持 `${key|default}` 语法设置默认值,其中第一个 `|` 为分隔符,后续 `|` 属于默认值内容。系统 SHALL 支持 `$${...}` 转义语法输出字面量 `${...}`
支持变量替换的字符串值 SHALL 支持 `${key}` 语法引用变量。系统 SHALL 支持 `${key|default}` 语法设置默认值,其中第一个 `|` 为分隔符,后续 `|` 属于默认值内容。系统 SHALL 支持 `${key|}` 显式设置空字符串默认值。系统 SHALL 支持 `$${...}` 转义语法输出字面量 `${...}`
#### Scenario: 简单变量引用
- **WHEN** target 字段值为 `"${base_url}/health"` 且 variables 中定义 `base_url: "https://api.example.com"`
@@ -54,6 +54,10 @@ targets 中的字符串值 SHALL 支持 `${key}` 语法引用变量。系统 SHA
- **WHEN** target 字段值为 `"${PATTERN|foo|bar}"` 且变量不存在
- **THEN** 系统 SHALL 使用 `"foo|bar"` 作为默认值(第一个 `|` 为分隔符)
#### Scenario: 空默认值
- **WHEN** 支持变量替换的字段值为 `"${OPTIONAL_VALUE|}"` 且 variables 和环境变量中均不存在 OPTIONAL_VALUE
- **THEN** 系统 SHALL 使用空字符串 `""` 作为默认值且不报 unresolved-variable 错误
#### Scenario: 转义语法
- **WHEN** target 字段值为 `"Hello $${name}"`
- **THEN** 系统 SHALL 输出 `"Hello ${name}"`,不进行变量替换
@@ -82,11 +86,11 @@ targets 中的字符串值 SHALL 支持 `${key}` 语法引用变量。系统 SHA
- **THEN** 系统 SHALL 使用默认值(类型推断后为 number 60
#### Scenario: 变量未定义且无默认值报错
- **WHEN** target 字段引用 `${MISSING_VAR}` 且 variables、环境变量中均不存在也未设置默认值
- **WHEN** 支持变量替换的字段引用 `${MISSING_VAR}` 且 variables、环境变量中均不存在也未设置默认值
- **THEN** 系统 SHALL 以配置错误退出,提示该变量未定义
### Requirement: 完整引用类型保留
当字段值仅包含单个变量引用(完整引用)时,系统 SHALL 保留变量的原始类型。完整引用的判定为:字段值去掉首尾空白后严格匹配单个 `${key}``${key|default}` 模式且无其他字符。
当字段值仅包含单个变量引用(完整引用)时,系统 SHALL 保留变量的原始类型。完整引用的判定为:字段值去掉首尾空白后严格匹配单个 `${key}``${key|default}` 模式且无其他字符。环境变量和表达式默认值的完整引用 SHALL 做 number/boolean 类型推断,但空字符串 MUST 保持为 string `""`
#### Scenario: 完整引用 number 变量
- **WHEN** target 字段值为 `"${port}"` 且 variables 中 `port: 5432`
@@ -120,8 +124,16 @@ targets 中的字符串值 SHALL 支持 `${key}` 语法引用变量。系统 SHA
- **WHEN** target 字段值为 `"${HOST|localhost}"` 且变量不存在
- **THEN** 系统 SHALL 将该字段替换为 string 类型的 "localhost"
#### Scenario: 空默认值保持字符串
- **WHEN** 支持变量替换的字段值为 `"${OPTIONAL_VALUE|}"` 且变量不存在
- **THEN** 系统 SHALL 将该字段替换为 string 类型的空字符串 `""`MUST NOT 推断为 number 0
#### Scenario: 空环境变量保持字符串
- **WHEN** 支持变量替换的字段值为 `"${EMPTY_ENV}"` 且环境变量 `EMPTY_ENV` 存在但值为空字符串
- **THEN** 系统 SHALL 将该字段替换为 string 类型的空字符串 `""`MUST NOT 推断为 number 0
### Requirement: 替换范围限制
变量替换 SHALL 作用于 targets 段。`id``type` 字段 MUST NOT 参与变量替换。`server``probes` 段 MUST NOT 参与变量替换。系统 SHALL 递归遍历 target 对象树中所有字符串进行替换包括嵌套对象和数组元素中的字符串。顶层 `defaults` 不再是合法配置段,因此不属于变量替换范围。
变量替换 SHALL 作用于 `server``probes``targets` 段中的字符串值。`variables`自身 MUST NOT 参与变量替换。系统 SHALL 递归遍历支持范围内对象树中所有字符串 value 进行替换包括嵌套对象和数组元素中的字符串。系统 MUST NOT 替换对象 key。`targets[].id``targets[].type` 字段 MUST NOT 参与变量替换target 内部其他路径上名为 `id``type` 的字段 SHALL 正常参与变量替换。顶层 `defaults` 不再是合法配置段,因此不属于变量替换范围。
#### Scenario: target 嵌套对象中的变量替换
- **WHEN** target 配置 `http.headers.Authorization: "${token}"` 且 variables 中定义 `token: "Bearer abc"`
@@ -131,44 +143,76 @@ targets 中的字符串值 SHALL 支持 `${key}` 语法引用变量。系统 SHA
- **WHEN** target 配置 `cmd.args: ["--host", "${host}"]` 且 variables 中定义 `host: "localhost"`
- **THEN** 系统 SHALL 将数组第二个元素替换为 "localhost"
#### Scenario: id 字段不替换
#### Scenario: server 段变量替换
- **WHEN** server 配置 `listen.host: "${server_host}"` 且 variables 中定义 `server_host: "0.0.0.0"`
- **THEN** 系统 SHALL 将 server.listen.host 替换为 "0.0.0.0"
#### Scenario: probes 段变量替换
- **WHEN** probes 配置 `execution.maxConcurrentChecks: "${max_checks}"` 且 variables 中定义 `max_checks: 5`
- **THEN** 系统 SHALL 将 probes.execution.maxConcurrentChecks 替换为 number 5
#### Scenario: variables 段不替换
- **WHEN** variables 配置 `host: "${HOST}"` 且环境变量 HOST 存在
- **THEN** 系统 SHALL 保持 variables.host 为字面量 `"${HOST}"`,不进行替换
#### Scenario: target id 字段不替换
- **WHEN** target 配置 `id: "${my_id}"` 且 variables 中定义 `my_id: "test"`
- **THEN** 系统 SHALL 保持 id 字段值为字面量 `"${my_id}"`,不进行替换
#### Scenario: type 字段不替换
#### Scenario: target type 字段不替换
- **WHEN** target 配置 `type: "${checker_type}"` 且 variables 中定义 `checker_type: "http"`
- **THEN** 系统 SHALL 保持 type 字段值为字面量 `"${checker_type}"`,不进行替换
#### Scenario: server 段不替换
- **WHEN** server 配置 `listen.host: "${server_host}"` 且 variables 中定义 `server_host: "0.0.0.0"`
- **THEN** 系统 SHALL 保持 server.listen.host 为字面量 `"${server_host}"`,不进行替换
#### Scenario: target 嵌套 id 字段参与替换
- **WHEN** target 内部非顶层身份字段配置 `expect.rows[0].id.equals: "${expected_id}"` 且 variables 中定义 `expected_id: 1`
- **THEN** 系统 SHALL 将该嵌套 id 字段替换为 number 1
#### Scenario: probes 段不替换
- **WHEN** probes 配置 `execution.maxConcurrentChecks: "${max_checks}"` 且 variables 中定义 `max_checks: 5`
- **THEN** 系统 SHALL 保持 probes.execution.maxConcurrentChecks 为字面量 `"${max_checks}"`,不进行替换
#### Scenario: target 嵌套 type 字段参与替换
- **WHEN** target 内部非顶层身份字段配置 `expect.body[0].json.path: "$.${field_type}"` 且 variables 中定义 `field_type: "type"`
- **THEN** 系统 SHALL 对该嵌套字段执行变量替换
#### Scenario: 对象 key 不替换
- **WHEN** target 配置 `http.headers."${HEADER_NAME}": "demo"` 且 variables 中定义 `HEADER_NAME: "X-Demo"`
- **THEN** 系统 SHALL 保持 header key 为字面量 `"${HEADER_NAME}"`,不进行替换
#### Scenario: defaults 段被拒绝
- **WHEN** 配置文件声明顶层 `defaults`
- **THEN** 系统 SHALL 在契约校验阶段拒绝该未知字段,而不是尝试变量替换
### Requirement: 变量替换错误报告
变量替换阶段的错误 SHALL 作为 `ConfigValidationIssue` 输出code 为 `unresolved-variable`。错误信息 SHALL 包含 target 索引、target id、字段路径和变量名
变量替换阶段的错误 SHALL 作为 `ConfigValidationIssue` 输出code 为 `unresolved-variable`。错误信息 SHALL 包含字段路径和变量名。对于 `targets[i]` 内的错误,错误信息还 SHALL 包含 target 索引、target id 和 target 展示名上下文
#### Scenario: 单个变量缺失报错
#### Scenario: 单个 target 变量缺失报错
- **WHEN** targets[0] (id: "api-health") 的 http.url 引用 `${base_url}` 但变量不存在
- **THEN** 系统 SHALL 输出包含 target 索引 0、id "api-health"、路径 "http.url"、变量名 "base_url" 的错误信息
#### Scenario: server 变量缺失报错
- **WHEN** server.listen.host 引用 `${server_host}` 但变量不存在
- **THEN** 系统 SHALL 输出包含路径 "server.listen.host" 和变量名 "server_host" 的错误信息
#### Scenario: probes 变量缺失报错
- **WHEN** probes.execution.maxConcurrentChecks 引用 `${max_checks}` 但变量不存在
- **THEN** 系统 SHALL 输出包含路径 "probes.execution.maxConcurrentChecks" 和变量名 "max_checks" 的错误信息
#### Scenario: 多个变量缺失批量报错
- **WHEN** 多个 target 的多个字段引用了不存在的变量
- **WHEN** 多个配置字段引用了不存在的变量
- **THEN** 系统 SHALL 收集所有缺失变量错误后统一输出,而非遇到第一个就退出
### Requirement: 变量替换执行时机
变量替换 SHALL 在 YAML 解析之后、schema 契约校验AJV之前执行。替换完成后的配置对象 SHALL 传入后续校验流程。
#### Scenario: 替换后通过 schema 校验
#### Scenario: target 替换后通过 schema 校验
- **WHEN** target 配置 `http.maxRedirects: "${MAX_REDIRECTS}"` 且环境变量 `MAX_REDIRECTS=5`
- **THEN** 系统 SHALL 先将该字段替换为 number 5再进入 AJV 校验(期望 integer校验通过
#### Scenario: 替换后未通过 schema 校验
#### Scenario: target 替换后未通过 schema 校验
- **WHEN** target 配置 `http.maxRedirects: "${MAX_REDIRECTS}"` 且环境变量 `MAX_REDIRECTS=abc`
- **THEN** 系统 SHALL 先将该字段替换为 string "abc",再进入 AJV 校验(期望 integer校验失败并报错
#### Scenario: server 替换后通过 schema 校验
- **WHEN** server 配置 `listen.port: "${SERVER_PORT}"` 且 variables 中定义 `SERVER_PORT: 3000`
- **THEN** 系统 SHALL 先将该字段替换为 number 3000再进入 AJV 校验(期望 integer校验通过
#### Scenario: probes 替换后通过 schema 校验
- **WHEN** probes 配置 `execution.maxConcurrentChecks: "${MAX_CHECKS}"` 且 variables 中定义 `MAX_CHECKS: 20`
- **THEN** 系统 SHALL 先将该字段替换为 number 20再进入 AJV 校验(期望 integer校验通过

View File

@@ -63,14 +63,8 @@ export function resolveVariables(config: unknown): { config: unknown; issues: Co
if (!isPlainObject(config)) {
return { config, issues };
}
const configRecord = config as Record<string, unknown>;
const rawTargets: unknown = configRecord["targets"];
if (!Array.isArray(rawTargets)) {
return { config, issues };
}
const targets = rawTargets.map((target, index) => resolveTargetVariables(target, index, variables, issues));
return { config: { ...config, targets }, issues };
return { config: resolveConfigValue(config, "", variables, issues), issues };
}
function describeInvalidVariableValue(value: unknown): string {
@@ -80,6 +74,7 @@ function describeInvalidVariableValue(value: unknown): string {
}
function inferStringValue(value: string): VariableValue {
if (value === "") return value;
const numberValue = Number(value);
if (Number.isFinite(numberValue)) return numberValue;
if (value === "true") return true;
@@ -126,6 +121,31 @@ function replaceStringValue(
return escaped.reduce((result, literal, index) => result.replace(`\u0000${index}\u0000`, literal), replaced);
}
function resolveConfigValue(
value: unknown,
path: string,
variables: Map<string, VariableValue>,
issues: ConfigValidationIssue[],
): unknown {
if (!isPlainObject(value)) return value;
const result: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
const itemPath = joinPath(path, key);
if (key === "variables") {
result[key] = item;
continue;
}
if (key === "targets" && Array.isArray(item)) {
result[key] = item.map((target, index) => resolveTargetVariables(target, index, variables, issues));
continue;
}
result[key] =
key === "server" || key === "probes" ? resolveValue(item, itemPath, variables, issues, { path: itemPath }) : item;
}
return result;
}
function resolveTargetVariables(
target: unknown,
index: number,
@@ -165,10 +185,9 @@ function resolveValue(
const result: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
const itemPath = joinPath(path, key);
result[key] =
key === "id" || key === "type"
? item
: resolveValue(item, itemPath, variables, issues, { ...context, path: itemPath });
result[key] = shouldSkipVariableResolution(itemPath)
? item
: resolveValue(item, itemPath, variables, issues, { ...context, path: itemPath });
}
return result;
}
@@ -202,3 +221,7 @@ function resolveVariableReference(
);
return undefined;
}
function shouldSkipVariableResolution(path: string): boolean {
return /^targets\[\d+\]\.(?:id|type)$/.test(path);
}

View File

@@ -436,6 +436,49 @@ targets:
expect(target.http.maxRedirects).toBe(5);
});
test("server 和 probes 在 schema 校验前完成变量替换", async () => {
const configPath = join(tempDir, "variables-server-probes.yaml");
await writeFile(
configPath,
`variables:
server_host: "0.0.0.0"
server_port: 3100
retention: "24h"
log_level: "debug"
rotation_max_files: 30
max_checks: 5
server:
listen:
host: "\${server_host}"
port: "\${server_port}"
storage:
retention: "\${retention}"
logging:
level: "\${log_level}"
file:
rotation:
maxFiles: "\${rotation_max_files}"
probes:
execution:
maxConcurrentChecks: "\${max_checks}"
targets:
- id: "server-vars"
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.host).toBe("0.0.0.0");
expect(config.port).toBe(3100);
expect(config.retentionMs).toBe(86400000);
expect(config.logging.consoleLevel).toBe("debug");
expect(config.logging.fileLevel).toBe("debug");
expect(config.logging.rotationMaxFiles).toBe(30);
expect(config.maxConcurrentChecks).toBe(5);
});
test("变量替换后类型不匹配导致 schema 校验失败", async () => {
const configPath = join(tempDir, "bad-var-type.yaml");
await writeFile(
@@ -494,6 +537,38 @@ targets:
await expectConfigLoadError(configPath, "未定义的变量");
});
test("server 和 probes 中未定义变量阻止启动并输出字段路径", async () => {
await expectConfigError(
"unresolved-server-probes.yaml",
`server:
listen:
host: "\${MISSING_SERVER_HOST}"
probes:
execution:
maxConcurrentChecks: "\${MISSING_MAX_CHECKS}"
targets:
- id: "unresolved-root"
type: http
http:
url: "http://example.com"
`,
"server.listen.host",
);
await expectConfigError(
"unresolved-probes.yaml",
`probes:
execution:
maxConcurrentChecks: "\${MISSING_MAX_CHECKS}"
targets:
- id: "unresolved-probes"
type: http
http:
url: "http://example.com"
`,
"probes.execution.maxConcurrentChecks",
);
});
test("绝对 dataDir 保持不变", async () => {
const dataDir = join(tempDir, "absolute-data");
const configPath = join(tempDir, "absolute-data-dir.yaml");

View File

@@ -150,36 +150,63 @@ describe("config variables", () => {
}
});
test("替换范围targets跳过 id 和 type 字段", () => {
test("替换范围覆盖 server、probes、targets跳过 variables 和 target 顶层 id/type", () => {
const result = resolveVariables({
defaults: { interval: "${interval}" },
server: { host: "${host}" },
probes: { execution: { maxConcurrentChecks: "${maxConcurrentChecks}" } },
server: { host: "${host}", logging: { level: "${logLevel}" }, storage: { retention: "${retention}" } },
targets: [
{
cmd: {
args: ["--host", "${host}"],
env: { TOKEN: "${token}" },
env: { "${TOKEN_KEY}": "${token}" },
exec: "echo",
},
expect: { rows: [{ id: { equals: "${expectedId}" }, type: { equals: "${expectedType}" } }] },
id: "${id}",
type: "${type}",
},
],
variables: { host: "localhost", id: "resolved", interval: "30s", token: "abc", type: "cmd" },
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 };
server: { host: string };
targets: Array<{ cmd: { args: string[]; env: Record<string, string> }; id: string; type: 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("${host}");
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"]).toBe("abc");
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", () => {
@@ -220,7 +247,39 @@ describe("config variables", () => {
expect(typeof http2["ignoreSSL"]).toBe("boolean");
});
test("server.storage 段不替换", () => {
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: [
@@ -235,10 +294,10 @@ describe("config variables", () => {
expect(result.issues).toHaveLength(0);
const config = result.config as { server: { storage: { retention: string } } };
expect(config.server.storage.retention).toBe("${retention}");
expect(config.server.storage.retention).toBe("24h");
});
test("probes 段替换", () => {
test("probes 段支持替换", () => {
const result = resolveVariables({
probes: { execution: { maxConcurrentChecks: "${maxConcurrentChecks}" } },
targets: [
@@ -248,15 +307,15 @@ describe("config variables", () => {
type: "http",
},
],
variables: { host: "https://example.com", maxConcurrentChecks: "20" },
variables: { host: "https://example.com", maxConcurrentChecks: 20 },
});
expect(result.issues).toHaveLength(0);
const config = result.config as { probes: { execution: { maxConcurrentChecks: string } } };
expect(config.probes.execution.maxConcurrentChecks).toBe("${maxConcurrentChecks}");
const config = result.config as { probes: { execution: { maxConcurrentChecks: number } } };
expect(config.probes.execution.maxConcurrentChecks).toBe(20);
});
test("server.logging 段替换", () => {
test("server.logging 段支持替换", () => {
const result = resolveVariables({
server: { logging: { level: "${logLevel}" } },
targets: [
@@ -271,7 +330,7 @@ describe("config variables", () => {
expect(result.issues).toHaveLength(0);
const config = result.config as { server: { logging: { level: string } } };
expect(config.server.logging.level).toBe("${logLevel}");
expect(config.server.logging.level).toBe("debug");
});
test("variables 段为非对象时报错", () => {