diff --git a/apps/config-builder/src/lib/validation.test.ts b/apps/config-builder/src/lib/validation.test.ts new file mode 100644 index 000000000000..3d814dc3060a --- /dev/null +++ b/apps/config-builder/src/lib/validation.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { validateConfigDraft } from "./validation.ts"; + +describe("validateConfigDraft", () => { + it("accepts empty drafts", () => { + const result = validateConfigDraft({}); + expect(result.valid).toBe(true); + expect(result.issues).toHaveLength(0); + }); + + it("collects issues by path and section", () => { + const result = validateConfigDraft({ + gateway: { + auth: { + token: 123, + }, + }, + }); + + expect(result.valid).toBe(false); + expect(result.issues.length).toBeGreaterThan(0); + expect(result.issuesByPath["gateway.auth.token"]?.length).toBeGreaterThan(0); + expect(result.sectionErrorCounts.gateway).toBeGreaterThan(0); + }); + + it("tracks root-level schema issues", () => { + const result = validateConfigDraft({ + __unexpected__: true, + }); + + expect(result.valid).toBe(false); + expect(result.sectionErrorCounts.root).toBeGreaterThan(0); + }); +}); diff --git a/apps/config-builder/src/lib/validation.ts b/apps/config-builder/src/lib/validation.ts new file mode 100644 index 000000000000..38198f46dee1 --- /dev/null +++ b/apps/config-builder/src/lib/validation.ts @@ -0,0 +1,73 @@ +import { OpenClawSchema } from "@openclaw/config/zod-schema.ts"; +import type { ConfigDraft } from "./config-store.ts"; + +export type ValidationIssue = { + path: string; + section: string; + message: string; +}; + +export type ValidationResult = { + valid: boolean; + issues: ValidationIssue[]; + issuesByPath: Record; + sectionErrorCounts: Record; +}; + +function issuePath(path: Array): string { + if (path.length === 0) { + return ""; + } + return path + .map((segment) => (typeof segment === "number" ? String(segment) : segment)) + .join("."); +} + +function issueSection(path: string): string { + if (!path) { + return "root"; + } + const [section] = path.split("."); + return section?.trim() || "root"; +} + +export function validateConfigDraft(config: ConfigDraft): ValidationResult { + const parsed = OpenClawSchema.safeParse(config); + if (parsed.success) { + return { + valid: true, + issues: [], + issuesByPath: {}, + sectionErrorCounts: {}, + }; + } + + const issues: ValidationIssue[] = parsed.error.issues.map((issue) => { + const path = issuePath(issue.path); + return { + path, + section: issueSection(path), + message: issue.message, + }; + }); + + const issuesByPath: Record = {}; + const sectionErrorCounts: Record = {}; + + for (const issue of issues) { + const key = issue.path; + if (!issuesByPath[key]) { + issuesByPath[key] = []; + } + issuesByPath[key].push(issue.message); + + sectionErrorCounts[issue.section] = (sectionErrorCounts[issue.section] ?? 0) + 1; + } + + return { + valid: false, + issues, + issuesByPath, + sectionErrorCounts, + }; +}