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; 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 }> }).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 }> }).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 }> }).targets[0]!.http; expect(http["maxRedirects"]).toBe(5432); expect((http["headers"] as Record)["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 }; 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("默认值推断为 boolean(true/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 }> }).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 }> }).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; }