mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
fix(ui): preserve mixed schema union values (#116602)
This commit is contained in:
@@ -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) || "<root>", 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 === "<root>" ? [] : 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 === "<root>" ||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user