import Ajv from "ajv"; import { describe, expect, test } from "bun:test"; import { normalizeAuthoringConfig } from "../../../../src/server/checker/normalizer"; import { createDefaultCheckerRegistry } from "../../../../src/server/checker/runner"; import { createProbeConfigJsonSchema } from "../../../../src/server/checker/schema/export"; import { formatConfigIssues, issue } from "../../../../src/server/checker/schema/issues"; import { validateProbeConfigContract } from "../../../../src/server/checker/schema/validate"; describe("config contract", () => { test("导出的 probe-config.schema.json 与 fragments 生成结果一致", async () => { const expected = `${JSON.stringify(createProbeConfigJsonSchema(createDefaultCheckerRegistry()), null, 2)}\n`; const actual = await Bun.file("probe-config.schema.json").text(); expect(actual).toBe(expected); }); test("导出 schema 拒绝未知字段和小写 HTTP method", () => { const ajv = new Ajv({ allErrors: true, coerceTypes: false, removeAdditional: false, strict: true, useDefaults: false, }); const validate = ajv.compile(createProbeConfigJsonSchema(createDefaultCheckerRegistry())); expect( validate({ targets: [ { http: { method: "get", unknownHttpField: true, url: "https://example.com" }, id: "api", name: "api", type: "http", }, ], }), ).toBe(false); }); test("导出 schema 支持 variables 且要求 target id", () => { const ajv = new Ajv({ allErrors: true, coerceTypes: false, removeAdditional: false, strict: true, useDefaults: false, }); const validate = ajv.compile(createProbeConfigJsonSchema(createDefaultCheckerRegistry())); expect( validate({ targets: [{ http: { url: "https://example.com" }, id: "api", type: "http" }], variables: { base_url: "https://example.com", enabled: true, port: 443 }, }), ).toBe(true); expect( validate({ targets: [{ http: { url: "https://example.com" }, type: "http" }], variables: { bad: null }, }), ).toBe(false); }); test("导出 schema 支持 ValueMatcher primitive 简写且拒绝数组对象简写", () => { const ajv = new Ajv({ allErrors: true, coerceTypes: false, removeAdditional: false, strict: true, useDefaults: false, }); const validate = ajv.compile(createProbeConfigJsonSchema(createDefaultCheckerRegistry())); const target = (durationMs: unknown) => ({ targets: [{ expect: { durationMs }, http: { url: "https://example.com" }, id: "api", type: "http" }], }); expect(validate(target(5000))).toBe(true); expect(validate(target("5000"))).toBe(true); expect(validate(target(null))).toBe(true); expect(validate(target([1, 2]))).toBe(false); expect(validate(target({ foo: "bar" }))).toBe(false); expect(validate(target({ equals: [1, 2] }))).toBe(true); expect(validate(target({ equals: { status: "ok" } }))).toBe(true); }); test("Authoring schema 接受变量引用,Normalized schema 拒绝变量引用和 primitive 简写", () => { const ajv = new Ajv({ allErrors: true, coerceTypes: false, removeAdditional: false, strict: true, useDefaults: false, }); const registry = createDefaultCheckerRegistry(); const authoring = ajv.compile(createProbeConfigJsonSchema(registry)); const normalizedConfig = { targets: [ { expect: { durationMs: 5000 }, http: { maxRedirects: "${MAX|5}", url: "https://example.com" }, id: "api", type: "http", }, ], }; expect( authoring({ targets: [ { expect: { durationMs: 5000, status: ["${STATUS}"] }, http: { ignoreSSL: "${SSL|false}", maxRedirects: "${MAX|5}", url: "https://example.com" }, id: "api", type: "http", }, ], }), ).toBe(false); expect( authoring({ targets: [ { expect: { durationMs: 5000 }, http: { ignoreSSL: "${SSL|false}", maxRedirects: "${MAX|5}", method: "${METHOD|GET}", url: "https://example.com", }, id: "api", type: "http", }, { id: "llm-api", llm: { model: "gpt-4o-mini", prompt: "ping", provider: "${P|openai}", url: "https://example.com/v1/chat/completions", }, type: "llm", }, ], }), ).toBe(true); expect(validateProbeConfigContract(normalizedConfig, registry).config).toBeNull(); }); test("导出 schema 拒绝 KeyedExpectations 的数组和对象简写", () => { const ajv = new Ajv({ allErrors: true, coerceTypes: false, removeAdditional: false, strict: true, useDefaults: false, }); const validate = ajv.compile(createProbeConfigJsonSchema(createDefaultCheckerRegistry())); const target = (headerValue: unknown) => ({ targets: [ { expect: { headers: { "x-test": headerValue } }, http: { url: "https://example.com" }, id: "api", type: "http", }, ], }); expect(validate(target("ok"))).toBe(true); expect(validate(target({ contains: "ok" }))).toBe(true); expect(validate(target(["ok"]))).toBe(false); expect(validate(target({ nested: "ok" }))).toBe(false); expect(validate(target({ equals: { nested: "ok" } }))).toBe(true); }); test("Ajv 错误转换为中文结构化 issue", () => { const result = validateProbeConfigContract( { targets: [ { group: 123, http: { extra: true }, id: "api", name: "api", type: "http", }, ], unknownRoot: true, }, createDefaultCheckerRegistry(), ); expect(result.config).toBeNull(); const message = formatConfigIssues(result.issues); expect(message).toContain("unknownRoot 是未知字段"); expect(message).toContain('target "api" 的 group 类型不合法'); expect(message).toContain('target "api" 的 http.url 缺少必填字段'); expect(message).toContain('target "api" 的 http.extra 是未知字段'); }); test("ConfigValidationIssue 聚合渲染保留契约和语义错误", () => { const message = formatConfigIssues([ issue("unknown-field", "targets[0].http.extra", "是未知字段", "api"), issue("invalid-regex", "targets[0].expect.body[0].regex", "正则不合法", "api"), ]); expect(message).toBe('target "api" 的 http.extra 是未知字段\ntarget "api" 的 expect.body[0].regex 正则不合法'); }); test("Authoring schema 接受 server.listen.port、llm.options.maxOutputTokens 变量引用", () => { const ajv = new Ajv({ allErrors: true, coerceTypes: false, removeAdditional: false, strict: true, useDefaults: false, }); const validate = ajv.compile(createProbeConfigJsonSchema(createDefaultCheckerRegistry())); expect( validate({ server: { listen: { port: "${SERVER_PORT|3000}" } }, targets: [ { id: "llm", llm: { model: "gpt-4o-mini", options: { maxOutputTokens: "${MAX_TOKENS|100}" }, prompt: "ping", provider: "openai", url: "https://example.com/v1/chat/completions", }, type: "llm", }, ], }), ).toBe(true); }); test("Authoring schema 拒绝 expect.status 和 expect.exitCode 数组内的变量引用", () => { const ajv = new Ajv({ allErrors: true, coerceTypes: false, removeAdditional: false, strict: true, useDefaults: false, }); const validate = ajv.compile(createProbeConfigJsonSchema(createDefaultCheckerRegistry())); expect( validate({ targets: [ { expect: { status: ["${STATUS}"] }, http: { url: "https://example.com" }, id: "http", type: "http", }, ], }), ).toBe(false); expect( validate({ targets: [ { cmd: { exec: "true" }, expect: { exitCode: ["${CODE}"] }, id: "cmd", type: "cmd", }, ], }), ).toBe(false); }); test("所有 checker 的 authoring ValueMatcher 简写经 normalize 后通过 normalized contract 校验", () => { const authoringShorthandExamples: Record = { cmd: { targets: [ { cmd: { exec: "echo hello" }, expect: { durationMs: 1000 }, id: "cmd-test", type: "cmd", }, ], }, db: { targets: [ { db: { url: "sqlite://:memory:" }, expect: { durationMs: 2000 }, id: "db-test", type: "db", }, ], }, dns: { targets: [ { dns: { name: "example.com", resolver: "system" }, expect: { durationMs: 500 }, id: "dns-test", type: "dns", }, ], }, http: { targets: [ { expect: { durationMs: 5000 }, http: { url: "https://example.com" }, id: "http-test", type: "http", }, ], }, icmp: { targets: [ { expect: { packetLossPercent: 0 }, icmp: { host: "example.com" }, id: "icmp-test", type: "icmp", }, ], }, llm: { targets: [ { expect: { durationMs: 10000 }, id: "llm-test", llm: { model: "gpt-4o-mini", prompt: "ping", provider: "openai", url: "https://example.com/v1/chat/completions", }, type: "llm", }, ], }, tcp: { targets: [ { expect: { durationMs: 3000 }, id: "tcp-test", tcp: { host: "example.com", port: 80 }, type: "tcp", }, ], }, udp: { targets: [ { expect: { durationMs: 1000 }, id: "udp-test", type: "udp", udp: { host: "example.com", port: 53 }, }, ], }, ws: { targets: [ { expect: { durationMs: 5000 }, id: "ws-test", type: "ws", ws: { url: "wss://example.com/ws" }, }, ], }, }; for (const [type, config] of Object.entries(authoringShorthandExamples)) { const normalizeResult = normalizeAuthoringConfig(config, createDefaultCheckerRegistry()); expect(normalizeResult.issues).toHaveLength(0); const contract = validateProbeConfigContract(normalizeResult.config, createDefaultCheckerRegistry()); expect(contract.config).not.toBeNull(); expect( contract.issues, `Checker "${type}" authoring shorthand should pass normalized contract, got issues: ${JSON.stringify(contract.issues.map((i) => `${i.path}: ${i.message}`))}`, ).toHaveLength(0); } }); });