From 7bc216394a19db95fe5e7e6256ba3ce4fe0f334e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 11:15:21 +0800 Subject: [PATCH] fix(ui): preserve mixed schema union values (#116602) --- src/config/schema-control-ui-contract.test.ts | 128 ++++++++++++++++++ ...form-composition-integrity.browser.test.ts | 22 +++ ui/src/components/config-form.analyze.ts | 6 + 3 files changed, 156 insertions(+) create mode 100644 src/config/schema-control-ui-contract.test.ts diff --git a/src/config/schema-control-ui-contract.test.ts b/src/config/schema-control-ui-contract.test.ts new file mode 100644 index 000000000000..3a1f6128927a --- /dev/null +++ b/src/config/schema-control-ui-contract.test.ts @@ -0,0 +1,128 @@ +import { Value } from "typebox/value"; +import { describe, expect, it } from "vitest"; +import { analyzeConfigSchema } from "../../ui/src/components/config-form.analyze.js"; +import type { JsonSchema } from "../../ui/src/components/config-form.shared.js"; +import { isSupportedConfigValueValid } from "../../ui/src/components/config-form.validation.js"; +import { computeBaseConfigSchemaResponse } from "./schema-base.js"; + +type MixedUnion = { + path: string; + schema: JsonSchema; + literals: unknown[]; +}; + +function pathKey(path: string[]): string { + return path.join("."); +} + +function unionLiterals(schema: JsonSchema): unknown[] { + const union = schema.anyOf ?? schema.oneOf; + if (!union) { + return []; + } + return union.flatMap((entry) => { + if (Array.isArray(entry.enum)) { + return entry.enum; + } + if (Object.hasOwn(entry, "const")) { + return [entry.const]; + } + return entry.type === "null" ? [null] : []; + }); +} + +function isLiteralOnlyBranch(schema: JsonSchema): boolean { + return Array.isArray(schema.enum) || Object.hasOwn(schema, "const") || schema.type === "null"; +} + +function collectMixedUnions( + schema: JsonSchema, + path: string[] = [], + result: MixedUnion[] = [], +): MixedUnion[] { + const union = schema.anyOf ?? schema.oneOf; + const literals = unionLiterals(schema); + if (union && literals.length > 0 && union.some((entry) => !isLiteralOnlyBranch(entry))) { + result.push({ path: pathKey(path) || "", schema, literals }); + } + + for (const [key, child] of Object.entries(schema.properties ?? {})) { + collectMixedUnions(child, [...path, key], result); + } + const items = schema.items; + if (Array.isArray(items)) { + items.forEach((item, index) => collectMixedUnions(item, [...path, String(index)], result)); + } else if (items) { + collectMixedUnions(items, [...path, "*"], result); + } + if (schema.additionalProperties && typeof schema.additionalProperties === "object") { + collectMixedUnions(schema.additionalProperties, [...path, "*"], result); + } + for (const branch of [ + ...(schema.allOf ?? []), + ...(schema.anyOf ?? []), + ...(schema.oneOf ?? []), + ]) { + collectMixedUnions(branch, path, result); + } + return result; +} + +function schemaAtPath(schema: JsonSchema, path: string): JsonSchema | undefined { + let current: JsonSchema | undefined = schema; + for (const segment of path === "" ? [] : path.split(".")) { + if (!current) { + return undefined; + } + if (segment === "*") { + current = Array.isArray(current.items) + ? undefined + : current.items || + (current.additionalProperties && typeof current.additionalProperties === "object" + ? current.additionalProperties + : undefined); + continue; + } + if (/^\d+$/u.test(segment) && Array.isArray(current.items)) { + current = current.items[Number(segment)]; + continue; + } + current = current.properties?.[segment]; + } + return current; +} + +function isPathUnsupported(path: string, unsupportedPaths: string[]): boolean { + return unsupportedPaths.some( + (unsupportedPath) => + unsupportedPath === "" || + path === unsupportedPath || + path.startsWith(`${unsupportedPath}.`), + ); +} + +describe("generated config schema Control UI contract", () => { + it("never drops accepted literals from mixed unions", () => { + const rawSchema = computeBaseConfigSchemaResponse({ + generatedAt: "control-ui-contract", + }).schema as JsonSchema; + const analysis = analyzeConfigSchema(rawSchema); + const mixedUnions = collectMixedUnions(rawSchema); + + expect(mixedUnions.length).toBeGreaterThan(0); + const lossyPaths = mixedUnions.flatMap(({ path, schema, literals }) => { + if (isPathUnsupported(path, analysis.unsupportedPaths)) { + return []; + } + const analyzedSchema = analysis.schema ? schemaAtPath(analysis.schema, path) : undefined; + return literals + .filter((literal) => Value.Check(schema as never, literal)) + .filter( + (literal) => !analyzedSchema || !isSupportedConfigValueValid(analyzedSchema, literal), + ) + .map(() => path); + }); + + expect([...new Set(lossyPaths)].toSorted()).toEqual([]); + }); +}); diff --git a/ui/src/components/config-form-composition-integrity.browser.test.ts b/ui/src/components/config-form-composition-integrity.browser.test.ts index 765eb3af9b8e..012bbaab3430 100644 --- a/ui/src/components/config-form-composition-integrity.browser.test.ts +++ b/ui/src/components/config-form-composition-integrity.browser.test.ts @@ -37,6 +37,28 @@ describe("config form composition integrity", () => { expect(unsupportedUnion.unsupportedPaths).toEqual(["mixed"]); }); + it("keeps literal and typed unions in Raw mode", () => { + const analysis = analyzeConfigSchema({ + type: "object", + properties: { + retention: { + anyOf: [{ type: "string" }, { const: false }], + }, + mode: { + oneOf: [{ type: "boolean" }, { enum: ["auto", "manual"] }], + }, + }, + }); + + expect(analysis.unsupportedPaths).toEqual(["retention", "mode"]); + expect(analysis.schema?.properties?.retention).toMatchObject({ + anyOf: [{ type: "string" }, { const: false }], + }); + expect(analysis.schema?.properties?.mode).toMatchObject({ + oneOf: [{ type: "boolean" }, { enum: ["auto", "manual"] }], + }); + }); + it("marks required-only object branches as form-unsafe", () => { const analysis = analyzeConfigSchema({ type: "object", diff --git a/ui/src/components/config-form.analyze.ts b/ui/src/components/config-form.analyze.ts index edc0a7dacd07..9c5727651b73 100644 --- a/ui/src/components/config-form.analyze.ts +++ b/ui/src/components/config-form.analyze.ts @@ -606,6 +606,12 @@ function normalizeUnion( }; } + // A native field cannot preserve both literal sentinels and an open typed branch. + // Keep the original union for Raw mode instead of silently dropping valid values. + if (literals.length > 0 && remaining.length > 0) { + return null; + } + if (remaining.length === 1) { const remainingSchema = remaining[0]; if (!remainingSchema) {