From 183db47e97f5960df46f8accbcb2ad2d0cc0613a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 30 Jul 2026 23:08:53 +0800 Subject: [PATCH] fix(ui): preserve schema-backed settings edits (#116282) * fix(ui): harden schema-backed settings controls Co-authored-by: wangmiao0668000666 * test(ui): match config secret label in Chromium * chore(ui): remove release-owned changelog entry * test(agents): mock incremental registry persistence --------- Co-authored-by: wangmiao0668000666 --- .../config-form-array-candidates.ts | 81 ++ .../config-form-array-identity.test.ts | 32 + .../components/config-form-array-identity.ts | 53 + ...onfig-form-array-integrity.browser.test.ts | 944 +++++++++++++++ .../config-form-collection-draft.ts | 268 +++++ ...form-composition-integrity.browser.test.ts | 690 +++++++++++ .../config-form-copy-on-write.test.ts | 37 + .../components/config-form-copy-on-write.ts | 84 ++ .../config-form-integrity.browser.test.ts | 1019 +++++++++++++++++ .../config-form-map-integrity.browser.test.ts | 92 ++ ...orm-nested-array-integrity.browser.test.ts | 74 ++ ...g-form-rejection-integrity.browser.test.ts | 569 +++++++++ ...nfig-form-scalar-integrity.browser.test.ts | 148 +++ .../config-form-structured-draft.ts | 152 +++ ui/src/components/config-form.analyze.ts | 395 ++++++- .../config-form.constraints.test.ts | 456 ++++++++ ui/src/components/config-form.constraints.ts | 728 ++++++++++++ .../components/config-form.node.collection.ts | 336 +++++- ui/src/components/config-form.node.scalar.ts | 327 +++++- ui/src/components/config-form.node.shared.ts | 145 ++- ui/src/components/config-form.node.ts | 28 +- ui/src/components/config-form.search.ts | 3 +- ui/src/components/config-form.shared.ts | 50 +- ui/src/components/config-form.validation.ts | 259 +++++ ui/src/components/settings-ui.ts | 14 +- ui/src/e2e/config-form-integrity.e2e.test.ts | 190 +++ ui/src/i18n/locales/en.ts | 5 + ui/src/lib/config/index.test.ts | 83 +- ui/src/lib/config/index.ts | 25 +- ui/src/styles/config.css | 47 + 30 files changed, 7195 insertions(+), 139 deletions(-) create mode 100644 ui/src/components/config-form-array-candidates.ts create mode 100644 ui/src/components/config-form-array-identity.test.ts create mode 100644 ui/src/components/config-form-array-identity.ts create mode 100644 ui/src/components/config-form-array-integrity.browser.test.ts create mode 100644 ui/src/components/config-form-collection-draft.ts create mode 100644 ui/src/components/config-form-composition-integrity.browser.test.ts create mode 100644 ui/src/components/config-form-copy-on-write.test.ts create mode 100644 ui/src/components/config-form-copy-on-write.ts create mode 100644 ui/src/components/config-form-integrity.browser.test.ts create mode 100644 ui/src/components/config-form-map-integrity.browser.test.ts create mode 100644 ui/src/components/config-form-nested-array-integrity.browser.test.ts create mode 100644 ui/src/components/config-form-rejection-integrity.browser.test.ts create mode 100644 ui/src/components/config-form-scalar-integrity.browser.test.ts create mode 100644 ui/src/components/config-form-structured-draft.ts create mode 100644 ui/src/components/config-form.constraints.test.ts create mode 100644 ui/src/components/config-form.constraints.ts create mode 100644 ui/src/components/config-form.validation.ts create mode 100644 ui/src/e2e/config-form-integrity.e2e.test.ts diff --git a/ui/src/components/config-form-array-candidates.ts b/ui/src/components/config-form-array-candidates.ts new file mode 100644 index 000000000000..94d62b3d4eab --- /dev/null +++ b/ui/src/components/config-form-array-candidates.ts @@ -0,0 +1,81 @@ +import { + arrayConstraintCandidates, + configValuesEqual, + defaultValue, + isSupportedConfigValueValid, + MAX_AUTO_ARRAY_DEFAULT_ITEMS, + NO_SAFE_DEFAULT, +} from "./config-form.constraints.ts"; +import type { JsonSchema } from "./config-form.shared.ts"; + +type ArrayAddCandidates = { + atomicCandidate: unknown[] | undefined; + autoCandidate: unknown[] | undefined; +}; + +function extendsArrayValue(value: readonly unknown[], candidate: readonly unknown[]): boolean { + return ( + candidate.length > value.length && + value.every((entry, index) => configValuesEqual(entry, candidate[index])) + ); +} + +export function arrayAddCandidates(params: { + schema: JsonSchema; + value: unknown[]; + minimumItems: number; + maximumItems: number | undefined; + uniqueItems: boolean; + isUnset: boolean; + isRequired: boolean; + itemSchemaAt: (index: number) => JsonSchema; +}): ArrayAddCandidates { + const { + schema, + value, + minimumItems, + maximumItems, + uniqueItems, + isUnset, + isRequired, + itemSchemaAt, + } = params; + const requiredAppendCount = Math.max(1, minimumItems - value.length); + const autoAppendCount = + requiredAppendCount > MAX_AUTO_ARRAY_DEFAULT_ITEMS ? 1 : requiredAppendCount; + const generatedItems: unknown[] = []; + for (let offset = 0; offset < autoAppendCount; offset += 1) { + const generatedDefault = defaultValue(itemSchemaAt(value.length + offset)); + if (generatedDefault === NO_SAFE_DEFAULT) { + generatedItems.length = 0; + break; + } + generatedItems.push(generatedDefault); + } + const generatedCandidate = + generatedItems.length === autoAppendCount ? [...value, ...generatedItems] : undefined; + const autoCandidate = + generatedCandidate !== undefined && + !uniqueItems && + (maximumItems === undefined || generatedCandidate.length <= maximumItems) && + (generatedCandidate.length < minimumItems || + isSupportedConfigValueValid(schema, generatedCandidate)) + ? generatedCandidate + : undefined; + + const currentValueValid = isSupportedConfigValueValid(schema, value); + const constrainedCandidate = arrayConstraintCandidates(schema).find( + (candidate) => + isSupportedConfigValueValid(schema, candidate) && + (isUnset || !currentValueValid || extendsArrayValue(value, candidate)), + ); + const wholeArrayDefault = + constrainedCandidate ?? + (isUnset && isRequired && maximumItems === 0 && isSupportedConfigValueValid(schema, []) + ? [] + : undefined); + const atomicCandidate = Array.isArray(wholeArrayDefault) + ? structuredClone(wholeArrayDefault) + : undefined; + return { atomicCandidate, autoCandidate }; +} diff --git a/ui/src/components/config-form-array-identity.test.ts b/ui/src/components/config-form-array-identity.test.ts new file mode 100644 index 000000000000..e640374ce124 --- /dev/null +++ b/ui/src/components/config-form-array-identity.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { + appendArrayRowIdentities, + discardArrayRowIdentities, + preserveArrayRowIdentities, + rowIdentitiesForArray, +} from "./config-form-array-identity.ts"; + +describe("config form array row identity", () => { + it("keeps appended primitive identities unique after an equal row is removed", () => { + const initial = ["same", "same"]; + const initialIdentities = rowIdentitiesForArray(initial); + const afterRemoval = ["same"]; + preserveArrayRowIdentities(afterRemoval, initialIdentities.slice(1)); + + const afterAppend = ["same", "same"]; + appendArrayRowIdentities(afterAppend, rowIdentitiesForArray(afterRemoval), 1); + const appendedIdentities = rowIdentitiesForArray(afterAppend); + + expect(new Set(appendedIdentities).size).toBe(appendedIdentities.length); + expect(appendedIdentities[0]).toBe(initialIdentities[1]); + }); + + it("removes row metadata from rejected candidate arrays", () => { + const candidate = ["same"]; + const rejectedIdentity = Symbol("rejected-row"); + preserveArrayRowIdentities(candidate, [rejectedIdentity]); + discardArrayRowIdentities(candidate); + + expect(rowIdentitiesForArray(candidate)[0]).not.toBe(rejectedIdentity); + }); +}); diff --git a/ui/src/components/config-form-array-identity.ts b/ui/src/components/config-form-array-identity.ts new file mode 100644 index 000000000000..bd3312e30399 --- /dev/null +++ b/ui/src/components/config-form-array-identity.ts @@ -0,0 +1,53 @@ +// Control UI helpers preserve repeated-row draft ownership across array edits. +const arrayRowIdentities = new WeakMap(); + +function primitiveRowIdentity(value: unknown, occurrence: number): string { + const normalized = + typeof value === "number" && Object.is(value, -0) + ? "-0" + : typeof value === "number" && Number.isNaN(value) + ? "NaN" + : String(value); + return `${typeof value}:${normalized}:${occurrence}`; +} + +export function rowIdentitiesForArray(value: unknown[]): readonly unknown[] { + const existing = arrayRowIdentities.get(value); + if (existing?.length === value.length) { + return existing; + } + const occurrences = new Map(); + const created = value.map((entry) => { + if (entry && typeof entry === "object") { + return entry; + } + const base = primitiveRowIdentity(entry, 0); + const occurrence = occurrences.get(base) ?? 0; + occurrences.set(base, occurrence + 1); + return primitiveRowIdentity(entry, occurrence); + }); + arrayRowIdentities.set(value, created); + return created; +} + +export function preserveArrayRowIdentities( + nextValue: unknown[], + identities: readonly unknown[], +): void { + arrayRowIdentities.set(nextValue, identities); +} + +export function discardArrayRowIdentities(value: unknown[]): void { + arrayRowIdentities.delete(value); +} + +export function appendArrayRowIdentities( + nextValue: unknown[], + identities: readonly unknown[], + count: number, +): void { + // Appended rows need fresh tokens even when their values equal preserved rows. + // Re-deriving occurrence labels can duplicate a survivor's token after removal. + const appended = Array.from({ length: count }, () => Symbol("array-row")); + preserveArrayRowIdentities(nextValue, [...identities, ...appended]); +} diff --git a/ui/src/components/config-form-array-integrity.browser.test.ts b/ui/src/components/config-form-array-integrity.browser.test.ts new file mode 100644 index 000000000000..f242da2145bf --- /dev/null +++ b/ui/src/components/config-form-array-integrity.browser.test.ts @@ -0,0 +1,944 @@ +// Control UI tests cover array draft recovery and repeated-item constraints. +import { render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import { ConfigFormCollectionDraft } from "./config-form-collection-draft.ts"; +import { renderArray } from "./config-form.node.collection.ts"; +import type { JsonSchema } from "./config-form.shared.ts"; +import { renderNode } from "./config-form.ts"; + +function expectElement(element: T | null | undefined, label: string): T { + expect(element instanceof Element, label).toBe(true); + if (!(element instanceof Element)) { + throw new Error(`missing ${label}`); + } + return element; +} + +describe("config form array integrity", () => { + it("applies safe whole-array composition candidates atomically", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + render( + renderArray( + { + schema: { + type: "array", + items: { type: "string" }, + allOf: [{ const: ["a", "b"] }], + }, + value: undefined, + path: ["values"], + hints: {}, + unsupported: new Set(), + disabled: false, + isRequired: true, + onPatch, + }, + renderNode, + ), + container, + ); + expectElement( + Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "whole-array composition add", + ).click(); + expect(onPatch).toHaveBeenCalledWith(["values"], ["a", "b"]); + + onPatch.mockClear(); + render( + renderArray( + { + schema: { + type: "array", + items: { type: "string" }, + enum: [[], ["a", "b"]], + }, + value: [], + path: ["values"], + hints: {}, + unsupported: new Set(), + disabled: false, + isRequired: true, + onPatch, + }, + renderNode, + ), + container, + ); + expectElement( + Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "valid constrained array extension", + ).click(); + expect(onPatch).toHaveBeenCalledWith(["values"], ["a", "b"]); + + onPatch.mockClear(); + render( + renderArray( + { + schema: { + type: "array", + items: { type: "string", default: "item" }, + default: ["a", "b"], + }, + value: [], + path: ["values"], + hints: {}, + unsupported: new Set(), + disabled: false, + isRequired: true, + onPatch, + }, + renderNode, + ), + container, + ); + expectElement( + Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "explicit empty array add", + ).click(); + expect(onPatch).toHaveBeenCalledWith(["values"], ["item"]); + + onPatch.mockClear(); + render( + renderArray( + { + schema: { + type: "array", + items: { type: "string" }, + const: ["a", "b"], + }, + value: [], + path: ["values"], + hints: {}, + unsupported: new Set(), + disabled: false, + isRequired: true, + onPatch, + }, + renderNode, + ), + container, + ); + expectElement( + Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "explicit invalid constrained array add", + ).click(); + expect(onPatch).toHaveBeenCalledWith(["values"], ["a", "b"]); + + onPatch.mockClear(); + render( + renderArray( + { + schema: { + type: "array", + items: { type: "string" }, + const: [], + maxItems: 0, + }, + value: undefined, + path: ["values"], + hints: {}, + unsupported: new Set(), + disabled: false, + isRequired: true, + onPatch, + }, + renderNode, + ), + container, + ); + const emptyConstAdd = expectElement( + Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "empty const array add", + ); + expect(emptyConstAdd.disabled).toBe(false); + emptyConstAdd.click(); + expect(onPatch).toHaveBeenCalledWith(["values"], []); + + onPatch.mockClear(); + render( + renderArray( + { + schema: { + type: "array", + items: { type: "string" }, + maxItems: 0, + }, + value: undefined, + path: ["values"], + hints: {}, + unsupported: new Set(), + disabled: false, + isRequired: true, + onPatch, + }, + renderNode, + ), + container, + ); + const requiredEmptyAdd = expectElement( + Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "required empty-only array add", + ); + expect(requiredEmptyAdd.disabled).toBe(false); + requiredEmptyAdd.click(); + expect(onPatch).toHaveBeenCalledWith(["values"], []); + }); + + it("keeps large and unique array minimums incrementally editable", async () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const renderSchema = (schema: JsonSchema) => { + render( + renderArray( + { + schema, + value: undefined, + path: ["codes"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + }, + renderNode, + ), + container, + ); + }; + + renderSchema({ + type: "array", + minItems: 101, + items: { type: "string" }, + }); + const largeMinimumAdd = expectElement( + Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "large minimum array add", + ); + expect(largeMinimumAdd.disabled).toBe(false); + largeMinimumAdd.click(); + expect(onPatch).toHaveBeenCalledWith(["codes"], [""]); + + onPatch.mockClear(); + renderSchema({ + type: "array", + minItems: 2, + uniqueItems: true, + items: { type: "string" }, + }); + const draftHost = expectElement( + container.querySelector("openclaw-config-form-collection-draft"), + "unique array draft", + ); + await draftHost.updateComplete; + expectElement( + Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "unique array add", + ).click(); + await draftHost.updateComplete; + const draftValue = expectElement( + draftHost.querySelector("[data-collection-draft-value]"), + "unique array draft value", + ); + draftValue.value = "first"; + draftValue.dispatchEvent(new Event("input", { bubbles: true })); + await draftHost.updateComplete; + expectElement( + Array.from(draftHost.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "unique array draft commit", + ).click(); + expect(onPatch).toHaveBeenCalledWith(["codes"], ["first"]); + + onPatch.mockClear(); + render( + renderArray( + { + schema: { + type: "array", + minItems: 2, + uniqueItems: true, + items: { type: "string" }, + }, + value: ["first"], + path: ["codes"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + }, + renderNode, + ), + container, + ); + const duplicateDraft = expectElement( + container.querySelector("openclaw-config-form-collection-draft"), + "duplicate array draft", + ); + await duplicateDraft.updateComplete; + expectElement( + Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "duplicate array add", + ).click(); + await duplicateDraft.updateComplete; + const duplicateValue = expectElement( + duplicateDraft.querySelector("[data-collection-draft-value]"), + "duplicate array draft value", + ); + duplicateValue.value = "first"; + duplicateValue.dispatchEvent(new Event("input", { bubbles: true })); + await duplicateDraft.updateComplete; + expectElement( + Array.from(duplicateDraft.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "duplicate array draft commit", + ).click(); + await duplicateDraft.updateComplete; + expect(duplicateValue.getAttribute("aria-invalid")).toBe("true"); + expect(onPatch).not.toHaveBeenCalled(); + + render( + renderArray( + { + schema: { + type: "array", + minItems: 2, + items: { type: "string" }, + allOf: [{ items: { type: "string", pattern: "^[0-9]+$" } }], + }, + value: undefined, + path: ["codes"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + }, + renderNode, + ), + container, + ); + const composedDraft = expectElement( + container.querySelector("openclaw-config-form-collection-draft"), + "composed item draft", + ); + await composedDraft.updateComplete; + expectElement( + Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "composed item add", + ).click(); + await composedDraft.updateComplete; + const composedValue = expectElement( + composedDraft.querySelector("[data-collection-draft-value]"), + "composed item draft value", + ); + composedValue.value = "abc"; + composedValue.dispatchEvent(new Event("input", { bubbles: true })); + await composedDraft.updateComplete; + const composedCommit = expectElement( + Array.from(composedDraft.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "composed item draft commit", + ); + composedCommit.click(); + await composedDraft.updateComplete; + expect(composedValue.getAttribute("aria-invalid")).toBe("true"); + expect(onPatch).not.toHaveBeenCalled(); + + composedValue.value = "123"; + composedValue.dispatchEvent(new Event("input", { bubbles: true })); + await composedDraft.updateComplete; + composedCommit.click(); + expect(onPatch).toHaveBeenCalledWith(["codes"], ["123"]); + container.remove(); + }); + + it("rejects existing array edits that violate uniqueItems", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + render( + renderArray( + { + schema: { + type: "array", + uniqueItems: true, + items: { type: "string" }, + }, + value: ["alpha", "beta"], + path: ["codes"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + }, + renderNode, + ), + container, + ); + const inputs = Array.from(container.querySelectorAll(".cfg-array input")); + expect(inputs).toHaveLength(2); + const second = expectElement(inputs[1], "second unique array item"); + + second.value = "alpha"; + second.dispatchEvent(new Event("input", { bubbles: true })); + expect(onPatch).not.toHaveBeenCalled(); + expect(second.value).toBe("beta"); + expect(second.getAttribute("aria-invalid")).toBe("false"); + + second.value = "gamma"; + second.dispatchEvent(new Event("input", { bubbles: true })); + expect(onPatch).toHaveBeenCalledWith(["codes"], ["alpha", "gamma"]); + }); + + it("restores boolean rows when uniqueItems rejects a toggle", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + render( + renderArray( + { + schema: { + type: "array", + uniqueItems: true, + items: { type: "boolean" }, + }, + value: [false, true], + path: ["flags"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + }, + renderNode, + ), + container, + ); + const switches = Array.from( + container.querySelectorAll("wa-switch.settings-toggle"), + ); + expect(switches).toHaveLength(2); + const second = expectElement(switches[1], "second unique boolean item"); + + second.checked = false; + second.dispatchEvent(new Event("change", { bubbles: true })); + + expect(onPatch).not.toHaveBeenCalled(); + expect(second.checked).toBe(true); + }); + + it("allows invalid arrays to be repaired one item at a time", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + const schema = { + type: "array", + minItems: 4, + maxItems: 4, + uniqueItems: true, + items: { type: "string" }, + }; + const renderValue = (value: string[]) => { + render( + renderArray( + { + schema, + value, + path: ["codes"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + }, + renderNode, + ), + container, + ); + }; + + renderValue(["alpha", "alpha", "beta", "beta"]); + const firstRepair = Array.from( + container.querySelectorAll(".cfg-array input"), + )[1]; + expect(firstRepair).toBeDefined(); + if (!firstRepair) { + return; + } + firstRepair.value = "gamma"; + firstRepair.dispatchEvent(new Event("input", { bubbles: true })); + expect(onPatch).toHaveBeenLastCalledWith(["codes"], ["alpha", "gamma", "beta", "beta"]); + + onPatch.mockClear(); + renderValue(["alpha", "gamma", "beta", "beta"]); + const secondRepair = Array.from( + container.querySelectorAll(".cfg-array input"), + )[3]; + expect(secondRepair).toBeDefined(); + if (!secondRepair) { + return; + } + secondRepair.value = "delta"; + secondRepair.dispatchEvent(new Event("input", { bubbles: true })); + expect(onPatch).toHaveBeenLastCalledWith(["codes"], ["alpha", "gamma", "beta", "delta"]); + }); + + it("validates the resulting tuple before removing an item", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + render( + renderArray( + { + schema: { + type: "array", + items: [{ type: "string" }, { type: "number" }], + additionalItems: false, + }, + value: ["identifier", 3], + path: ["tuple"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + }, + renderNode, + ), + container, + ); + + const removeButtons = Array.from( + container.querySelectorAll("button[aria-label='Remove item']"), + ); + expect(removeButtons).toHaveLength(2); + expect(removeButtons[0]?.disabled).toBe(true); + expect(removeButtons[1]?.disabled).toBe(false); + const addButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ); + expect(addButton?.disabled).toBe(true); + removeButtons[1]?.click(); + expect(onPatch).toHaveBeenCalledWith(["tuple"], ["identifier"]); + }); + + it("allows over-limit arrays to be repaired by repeated removal", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + const schema = { + type: "array", + maxItems: 2, + items: { type: "string" }, + }; + const renderValue = (value: string[]) => { + render( + renderArray( + { + schema, + value, + path: ["codes"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + }, + renderNode, + ), + container, + ); + }; + + renderValue(["one", "two", "three", "four"]); + let removeButtons = Array.from( + container.querySelectorAll("button[aria-label='Remove item']"), + ); + expect(removeButtons.every((button) => !button.disabled)).toBe(true); + removeButtons[3]?.click(); + expect(onPatch).toHaveBeenLastCalledWith(["codes"], ["one", "two", "three"]); + + onPatch.mockClear(); + renderValue(["one", "two", "three"]); + removeButtons = Array.from( + container.querySelectorAll("button[aria-label='Remove item']"), + ); + expect(removeButtons.every((button) => !button.disabled)).toBe(true); + removeButtons[2]?.click(); + expect(onPatch).toHaveBeenLastCalledWith(["codes"], ["one", "two"]); + }); + + it("restores JSON rows when a collection constraint rejects the edit", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + render( + renderArray( + { + schema: { + type: "array", + uniqueItems: true, + items: { + anyOf: [{ type: "object" }, { type: "array" }], + }, + }, + value: [{ id: "first" }, { id: "second" }], + path: ["entries"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + }, + renderNode, + ), + container, + ); + + const textareas = Array.from(container.querySelectorAll("textarea")); + expect(textareas).toHaveLength(2); + const second = expectElement(textareas[1], "second JSON array item"); + second.value = '{"id":"first"}'; + second.dispatchEvent(new Event("input", { bubbles: true })); + second.dispatchEvent(new Event("change", { bubbles: true })); + + expect(onPatch).not.toHaveBeenCalled(); + expect(second.value).toContain('"second"'); + expect(second.getAttribute("aria-invalid")).toBe("false"); + }); + + it("preserves an unaffected JSON row draft when another row changes", () => { + const container = document.createElement("div"); + const schema = { + type: "array", + items: { + anyOf: [{ type: "object" }, { type: "array" }], + }, + }; + let currentValue: unknown[] = [{ id: "first" }, { id: "second" }]; + const renderValue = () => { + render( + renderArray( + { + schema, + value: currentValue, + path: ["entries"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch: (_path, nextValue) => { + currentValue = nextValue as unknown[]; + renderValue(); + }, + }, + renderNode, + ), + container, + ); + }; + + renderValue(); + let textareas = Array.from(container.querySelectorAll("textarea")); + const first = expectElement(textareas[0], "first JSON array item"); + const second = expectElement(textareas[1], "second JSON array item"); + first.value = '{"id":'; + first.dispatchEvent(new Event("input", { bubbles: true })); + expect(first.getAttribute("aria-invalid")).toBe("true"); + + second.value = '{"id":"changed"}'; + second.dispatchEvent(new Event("input", { bubbles: true })); + second.dispatchEvent(new Event("change", { bubbles: true })); + + textareas = Array.from(container.querySelectorAll("textarea")); + expect(textareas[0]?.value).toBe('{"id":'); + expect(textareas[0]?.getAttribute("aria-invalid")).toBe("true"); + expect(currentValue).toEqual([{ id: "first" }, { id: "changed" }]); + }); + + it("resets scalar and JSON drafts when the first equal primitive row is removed", () => { + const scalarContainer = document.createElement("div"); + let scalarValue: unknown[] = ["same", "same"]; + const renderScalarValue = () => { + render( + renderArray( + { + schema: { + type: "array", + items: { type: "string", minLength: 2 }, + }, + value: scalarValue, + path: ["values"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch: (_path, nextValue) => { + scalarValue = nextValue as unknown[]; + renderScalarValue(); + }, + }, + renderNode, + ), + scalarContainer, + ); + }; + renderScalarValue(); + const scalarInput = expectElement( + scalarContainer.querySelector("input"), + "first equal scalar row", + ); + scalarInput.value = "x"; + scalarInput.dispatchEvent(new Event("input", { bubbles: true })); + expect(scalarInput.getAttribute("aria-invalid")).toBe("true"); + expectElement( + scalarContainer.querySelector("button[aria-label='Remove item']"), + "first equal scalar row remove", + ).click(); + const remainingScalar = expectElement( + scalarContainer.querySelector("input"), + "remaining equal scalar row", + ); + expect(remainingScalar).toBe(scalarInput); + expect(remainingScalar.value).toBe("same"); + expect(remainingScalar.getAttribute("aria-invalid")).toBe("false"); + + const jsonContainer = document.createElement("div"); + let jsonValue: unknown[] = [true, true]; + const renderJsonValue = () => { + render( + renderArray( + { + schema: { type: "array", items: {} }, + value: jsonValue, + path: ["values"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch: (_path, nextValue) => { + jsonValue = nextValue as unknown[]; + renderJsonValue(); + }, + }, + renderNode, + ), + jsonContainer, + ); + }; + renderJsonValue(); + const jsonDraft = expectElement( + jsonContainer.querySelector("textarea"), + "first equal JSON row", + ); + jsonDraft.value = "{"; + jsonDraft.dispatchEvent(new Event("input", { bubbles: true })); + expect(jsonDraft.getAttribute("aria-invalid")).toBe("true"); + expectElement( + jsonContainer.querySelector("button[aria-label='Remove item']"), + "first equal JSON row remove", + ).click(); + const remainingJson = expectElement( + jsonContainer.querySelector("textarea"), + "remaining equal JSON row", + ); + expect(remainingJson).toBe(jsonDraft); + expect(remainingJson.value).toBe("true"); + expect(remainingJson.getAttribute("aria-invalid")).toBe("false"); + }); + + it("adds null through nullable scalar collection drafts", async () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + render( + renderArray( + { + schema: { + type: "array", + uniqueItems: true, + items: { type: ["string", "null"] }, + }, + value: [""], + path: ["values"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + }, + renderNode, + ), + container, + ); + const draftHost = expectElement( + container.querySelector("openclaw-config-form-collection-draft"), + "nullable scalar collection draft", + ); + expectElement( + Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "nullable scalar array add", + ).click(); + await draftHost.updateComplete; + const nullToggle = expectElement( + draftHost.querySelector("[data-collection-draft-null]"), + "nullable scalar null toggle", + ); + nullToggle.checked = true; + nullToggle.dispatchEvent(new Event("change", { bubbles: true })); + await draftHost.updateComplete; + expect( + draftHost.querySelector("[data-collection-draft-value]")?.disabled, + ).toBe(true); + expectElement( + Array.from(draftHost.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "nullable scalar null commit", + ).click(); + expect(onPatch).toHaveBeenCalledWith(["values"], ["", null]); + container.remove(); + }); + + it("propagates rejected edits through nested constrained arrays", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + render( + renderArray( + { + schema: { + type: "array", + uniqueItems: true, + items: { + type: "array", + items: { type: "string" }, + }, + }, + value: [["alpha"], ["beta"]], + path: ["groups"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + }, + renderNode, + ), + container, + ); + + const inputs = Array.from(container.querySelectorAll("input")); + expect(inputs).toHaveLength(2); + const second = expectElement(inputs[1], "second nested array item"); + second.value = "alpha"; + second.dispatchEvent(new Event("input", { bubbles: true })); + + expect(onPatch).not.toHaveBeenCalled(); + expect(second.value).toBe("beta"); + expect(second.getAttribute("aria-invalid")).toBe("false"); + }); + + it("resets a reused object row after array removal", () => { + const container = document.createElement("div"); + const schema = { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string", minLength: 2 }, + }, + }, + }; + let currentValue: unknown[] = [{ name: "same" }, { name: "same" }]; + const renderValue = () => { + render( + renderArray( + { + schema, + value: currentValue, + path: ["entries"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch: (_path, nextValue) => { + currentValue = nextValue as unknown[]; + renderValue(); + }, + }, + renderNode, + ), + container, + ); + }; + + renderValue(); + const firstInput = expectElement( + container.querySelector("input"), + "first object row input", + ); + firstInput.value = "x"; + firstInput.dispatchEvent(new Event("input", { bubbles: true })); + expect(firstInput.getAttribute("aria-invalid")).toBe("true"); + + const firstRemove = expectElement( + container.querySelector("button[aria-label='Remove item']"), + "first object row remove", + ); + firstRemove.click(); + + const shiftedInput = expectElement( + container.querySelector("input"), + "shifted object row input", + ); + expect(shiftedInput.value).toBe("same"); + expect(shiftedInput.getAttribute("aria-invalid")).toBe("false"); + expect(currentValue).toEqual([{ name: "same" }]); + }); + + it("renders open tuple tail items as unconstrained JSON values", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + render( + renderArray( + { + schema: { + type: "array", + items: [{ type: "string" }], + }, + value: ["head", { enabled: true }], + path: ["tuple"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + }, + renderNode, + ), + container, + ); + + const tail = expectElement( + container.querySelector("textarea"), + "open tuple tail JSON value", + ); + tail.value = '{"enabled":false}'; + tail.dispatchEvent(new Event("input", { bubbles: true })); + tail.dispatchEvent(new Event("change", { bubbles: true })); + expect(onPatch).toHaveBeenCalledWith(["tuple"], ["head", { enabled: false }]); + + const addButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ); + expect(addButton?.disabled).toBe(false); + }); +}); diff --git a/ui/src/components/config-form-collection-draft.ts b/ui/src/components/config-form-collection-draft.ts new file mode 100644 index 000000000000..07b7797d60a3 --- /dev/null +++ b/ui/src/components/config-form-collection-draft.ts @@ -0,0 +1,268 @@ +import { html, nothing, type PropertyValues } from "lit"; +import { property, state } from "lit/decorators.js"; +import { t } from "../i18n/index.ts"; +import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; +import { configValuesEqual, isSupportedConfigValueValid } from "./config-form.constraints.ts"; +import { schemaType, type JsonSchema } from "./config-form.shared.ts"; + +export type ConfigFormCollectionDraftProps = { + schema: JsonSchema; + label: string; + disabled: boolean; + identity: string; + sourceIdentity: unknown; + existingKeys?: readonly string[]; + existingValues?: readonly unknown[]; + validateValue?: (value: unknown) => boolean; +}; + +export type ConfigFormCollectionDraftCommit = { + key?: string; + value: unknown; +}; + +export class ConfigFormCollectionDraft extends OpenClawLightDomElement { + @property({ attribute: false }) props?: ConfigFormCollectionDraftProps; + + @state() private draftOpen = false; + @state() private draftKey = ""; + @state() private draftValue = ""; + @state() private draftIsNull = false; + @state() private error = ""; + @state() private invalidTarget: "key" | "value" | null = null; + + protected override willUpdate(changedProperties: PropertyValues): void { + const previous = changedProperties.get("props") as ConfigFormCollectionDraftProps | undefined; + const next = this.props; + if ( + previous && + (!next || + previous.identity !== next.identity || + !Object.is(previous.sourceIdentity, next.sourceIdentity)) + ) { + this.closeDraft(); + } + } + + openDraft(): void { + if (this.props?.disabled) { + return; + } + this.draftOpen = true; + void this.updateComplete.then(() => { + this.querySelector("[data-collection-draft-value]")?.focus(); + }); + } + + private clearError(): void { + this.error = ""; + this.invalidTarget = null; + } + + private closeDraft(): void { + this.draftOpen = false; + this.draftKey = ""; + this.draftValue = ""; + this.draftIsNull = false; + this.clearError(); + } + + private fail(target: "key" | "value", message: string): void { + this.invalidTarget = target; + this.error = message; + void this.updateComplete.then(() => { + this.querySelector( + target === "key" ? "[data-collection-draft-key]" : "[data-collection-draft-value]", + )?.focus(); + }); + } + + private parseValue( + schema: JsonSchema, + ): { ok: true; value: unknown } | { ok: false; message: string } { + if (this.draftIsNull) { + return { ok: true, value: null }; + } + const valueType = schemaType(schema); + if (valueType === "string") { + return { ok: true, value: this.draftValue }; + } + if (valueType === "number" || valueType === "integer") { + const value = Number(this.draftValue); + return this.draftValue.trim() && Number.isFinite(value) + ? { ok: true, value } + : { ok: false, message: t("configForm.invalidNumber") }; + } + try { + return { ok: true, value: JSON.parse(this.draftValue) }; + } catch { + return { ok: false, message: t("configForm.invalidJson") }; + } + } + + private commit(): void { + const props = this.props; + if (!props || props.disabled) { + return; + } + const parsed = this.parseValue(props.schema); + if (!parsed.ok) { + this.fail("value", parsed.message); + return; + } + if (!isSupportedConfigValueValid(props.schema, parsed.value)) { + this.fail( + "value", + ["number", "integer"].includes(schemaType(props.schema) ?? "") + ? t("configForm.invalidNumber") + : t("configForm.invalidString"), + ); + return; + } + if (props.existingValues?.some((value) => configValuesEqual(value, parsed.value))) { + this.fail("value", t("configForm.invalidString")); + return; + } + if (props.validateValue && !props.validateValue(parsed.value)) { + this.fail("value", t("configForm.invalidString")); + return; + } + const key = this.draftKey.trim(); + if (props.existingKeys && (!key || props.existingKeys.includes(key))) { + this.fail("key", t("configForm.invalidString")); + return; + } + + const accepted = this.dispatchEvent( + new CustomEvent("config-collection-draft-commit", { + bubbles: true, + composed: true, + cancelable: true, + detail: { + ...(props.existingKeys ? { key } : {}), + value: parsed.value, + }, + }), + ); + if (accepted) { + this.closeDraft(); + } else { + this.fail("value", t("configForm.invalidString")); + } + } + + protected override updated(): void { + const keyInput = this.querySelector("[data-collection-draft-key]"); + const valueInput = this.querySelector( + "[data-collection-draft-value]", + ); + keyInput?.setCustomValidity(this.invalidTarget === "key" ? this.error : ""); + valueInput?.setCustomValidity(this.invalidTarget === "value" ? this.error : ""); + } + + override render() { + const props = this.props; + if (!props || !this.draftOpen || props.disabled) { + return nothing; + } + const valueType = schemaType(props.schema); + const canUseNull = isSupportedConfigValueValid(props.schema, null); + const usesTextInput = + valueType === "string" || valueType === "number" || valueType === "integer"; + const errorId = `${this.id}-error`; + const valueLabel = `${t("configForm.add")}: ${props.label}`; + const valueControl = usesTextInput + ? html` + { + this.draftValue = (event.currentTarget as HTMLInputElement).value; + this.clearError(); + }} + /> + ` + : html` + + `; + + return html` +
+
+
+ ${props.existingKeys + ? html` + { + this.draftKey = (event.currentTarget as HTMLInputElement).value; + this.clearError(); + }} + /> + ` + : nothing} + ${canUseNull + ? html` + + ` + : nothing} + ${valueControl} + ${this.error} +
+ + +
+
+
+
+ `; + } +} + +if (!customElements.get("openclaw-config-form-collection-draft")) { + customElements.define("openclaw-config-form-collection-draft", ConfigFormCollectionDraft); +} diff --git a/ui/src/components/config-form-composition-integrity.browser.test.ts b/ui/src/components/config-form-composition-integrity.browser.test.ts new file mode 100644 index 000000000000..765eb3af9b8e --- /dev/null +++ b/ui/src/components/config-form-composition-integrity.browser.test.ts @@ -0,0 +1,690 @@ +// Control UI tests cover schema composition that changes field requiredness. +import { render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import { isSupportedConfigValueValid } from "./config-form.constraints.ts"; +import { renderObject } from "./config-form.node.collection.ts"; +import { analyzeConfigSchema, renderConfigForm, renderNode } from "./config-form.ts"; + +describe("config form composition integrity", () => { + it("keeps mixed union and allOf compositions fail-closed", () => { + const analysis = analyzeConfigSchema({ + type: "object", + properties: { + mixed: { + type: "string", + anyOf: [{ const: "a" }, { const: "b" }], + allOf: [{ const: "a" }], + }, + }, + }); + + expect(analysis.unsupportedPaths).toEqual(["mixed"]); + expect(analysis.schema?.properties?.mixed).toMatchObject({ + anyOf: [{ const: "a" }, { const: "b" }], + allOf: [{ const: "a" }], + }); + + const unsupportedUnion = analyzeConfigSchema({ + type: "object", + properties: { + mixed: { + type: "string", + anyOf: [{ const: "a" }, { const: "b" }], + not: { const: "b" }, + }, + }, + }); + expect(unsupportedUnion.unsupportedPaths).toEqual(["mixed"]); + }); + + it("marks required-only object branches as form-unsafe", () => { + const analysis = analyzeConfigSchema({ + type: "object", + properties: { + closed: { + type: "object", + allOf: [{ required: ["token"] }], + }, + open: { + type: "object", + additionalProperties: true, + allOf: [{ required: ["token"] }], + }, + }, + }); + + expect(analysis.unsupportedPaths).toEqual(["closed"]); + }); + + it("marks branch-scoped additional-properties schemas as form-unsafe", () => { + const analysis = analyzeConfigSchema({ + type: "object", + properties: { + conflicting: { + type: "object", + allOf: [ + { properties: { count: { type: "integer" } } }, + { additionalProperties: { type: "string" } }, + ], + }, + representable: { + type: "object", + allOf: [ + { + properties: { count: { type: "integer" } }, + additionalProperties: { type: "string" }, + }, + ], + }, + }, + }); + + expect(analysis.unsupportedPaths).toEqual(["conflicting"]); + }); + + it("keeps annotations harmless and items-only schemas form-unsafe", () => { + const annotated = analyzeConfigSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.test/config", + type: "object", + examples: [{}], + deprecated: false, + readOnly: false, + writeOnly: false, + properties: { + name: { type: "string" }, + }, + }); + expect(annotated.unsupportedPaths).toEqual([]); + + const itemsOnly = analyzeConfigSchema({ + type: "object", + properties: { + value: { + items: { type: "string" }, + }, + }, + }); + expect(itemsOnly.unsupportedPaths).toEqual(["value"]); + expect(itemsOnly.schema?.properties?.value?.type).toBeUndefined(); + + const composedArrayItems = analyzeConfigSchema({ + type: "object", + properties: { + codes: { + type: "array", + items: { type: "string" }, + allOf: [{ items: { pattern: "^[0-9]+$" } }], + }, + nestedCodes: { + type: "array", + items: { type: "string" }, + allOf: [{ allOf: [{ minItems: 1 }] }], + }, + nullableCodes: { + type: ["array", "null"], + items: { type: "string" }, + allOf: [{ allOf: [{ minItems: 1 }] }], + }, + nullOnlyCodes: { + type: ["array", "null"], + items: { type: "string" }, + allOf: [{ type: ["null"] }], + }, + nestedConflict: { + type: "array", + items: { type: "string" }, + allOf: [{ allOf: [{ type: "object", properties: {} }] }], + }, + }, + }); + expect(composedArrayItems.unsupportedPaths).toEqual([ + "nullableCodes", + "nullOnlyCodes", + "nestedConflict", + ]); + expect(composedArrayItems.schema?.properties?.codes?.allOf?.[0]?.type).toBe("array"); + const nullableCodes = composedArrayItems.schema?.properties?.nullableCodes; + expect(nullableCodes?.allOf?.[0]?.nullable).toBe(true); + expect(isSupportedConfigValueValid(nullableCodes ?? {}, null)).toBe(true); + const nullOnlyCodes = composedArrayItems.schema?.properties?.nullOnlyCodes; + expect(nullOnlyCodes?.allOf?.[0]?.type).toEqual(["null"]); + expect(isSupportedConfigValueValid(nullOnlyCodes ?? {}, null)).toBe(true); + expect(isSupportedConfigValueValid(nullOnlyCodes ?? {}, ["123"])).toBe(false); + const nestedConflict = composedArrayItems.schema?.properties?.nestedConflict; + expect(isSupportedConfigValueValid(nestedConflict ?? {}, ["123"])).toBe(false); + }); + + it("does not clear fields required through allOf", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + const analysis = analyzeConfigSchema({ + type: "object", + properties: { + settings: { + type: "object", + properties: { + count: { type: "integer" }, + }, + allOf: [ + { + required: ["count", "mode"], + properties: { + count: { minimum: 2 }, + mode: { type: "string", enum: ["a", "b", "c", "d", "e", "f"] }, + }, + }, + ], + }, + }, + }); + expect(analysis.unsupportedPaths).toEqual([]); + expect(analysis.schema).not.toBeNull(); + if (!analysis.schema) { + return; + } + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: {}, + unsupportedPaths: analysis.unsupportedPaths, + value: { settings: { count: 3, mode: "a" } }, + showAdvanced: true, + onShowAdvanced: () => {}, + onPatch, + }), + container, + ); + + const input = container.querySelector("input[aria-label='Count']"); + expect(input).not.toBeNull(); + if (!input) { + return; + } + input.value = ""; + input.dispatchEvent(new Event("input", { bubbles: true })); + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect(onPatch).not.toHaveBeenCalled(); + + input.value = "1"; + input.dispatchEvent(new Event("input", { bubbles: true })); + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect(onPatch).not.toHaveBeenCalled(); + + input.value = "2"; + input.dispatchEvent(new Event("input", { bubbles: true })); + expect(input.getAttribute("aria-invalid")).toBe("false"); + expect(onPatch).toHaveBeenCalledWith(["settings", "count"], 2); + + onPatch.mockClear(); + const select = container.querySelector("select[aria-label='Mode']"); + expect(select).not.toBeNull(); + if (!select) { + return; + } + expect(select.querySelector("option[value='__unset__']")?.disabled).toBe( + true, + ); + select.value = "__unset__"; + select.dispatchEvent(new Event("change", { bubbles: true })); + expect(select.value).toBe("0"); + expect(onPatch).not.toHaveBeenCalled(); + }); + + it("does not clear required JSON-backed fields", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + render( + renderObject( + { + schema: { + type: "object", + properties: { + payload: { + anyOf: [{ type: "object" }, { type: "array" }], + }, + }, + allOf: [{ required: ["payload"] }], + }, + value: { payload: { enabled: true } }, + path: ["settings"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + }, + renderNode, + ), + container, + ); + + const textarea = container.querySelector("textarea"); + expect(textarea).not.toBeNull(); + if (!textarea) { + return; + } + textarea.value = ""; + textarea.dispatchEvent(new Event("input", { bubbles: true })); + textarea.dispatchEvent(new Event("change", { bubbles: true })); + expect(textarea.getAttribute("aria-invalid")).toBe("true"); + expect(onPatch).not.toHaveBeenCalled(); + + textarea.value = "123"; + textarea.dispatchEvent(new Event("input", { bubbles: true })); + textarea.dispatchEvent(new Event("change", { bubbles: true })); + expect(textarea.getAttribute("aria-invalid")).toBe("true"); + expect(onPatch).not.toHaveBeenCalled(); + }); + + it("preserves heterogeneous tuple schemas through analysis and rendering", () => { + const analysis = analyzeConfigSchema({ + type: "object", + properties: { + tuple: { + type: "array", + items: [{ type: "string" }, { type: "integer", minimum: 2 }], + additionalItems: false, + }, + }, + }); + expect(analysis.unsupportedPaths).toEqual([]); + const tupleSchema = analysis.schema?.properties?.tuple; + expect( + Array.isArray(tupleSchema?.items) ? tupleSchema.items.map((item) => item.type) : [], + ).toEqual(["string", "integer"]); + if (!analysis.schema) { + return; + } + + const container = document.createElement("div"); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: {}, + unsupportedPaths: analysis.unsupportedPaths, + value: { tuple: ["head", 2] }, + showAdvanced: true, + onShowAdvanced: () => {}, + onPatch: vi.fn(), + }), + container, + ); + const inputs = Array.from(container.querySelectorAll(".cfg-array input")); + expect(inputs.map((input) => input.type)).toEqual(["text", "number"]); + const add = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ); + expect(add?.disabled).toBe(true); + }); + + it("rejects child edits that violate composed object constraints", () => { + const analysis = analyzeConfigSchema({ + type: "object", + properties: { + settings: { + type: "object", + properties: { + mode: { type: "string" }, + }, + allOf: [{ const: { mode: "safe" } }], + }, + }, + }); + expect(analysis.unsupportedPaths).toEqual([]); + if (!analysis.schema) { + return; + } + + const onPatch = vi.fn(); + const container = document.createElement("div"); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: {}, + unsupportedPaths: analysis.unsupportedPaths, + value: { settings: { mode: "safe" } }, + showAdvanced: true, + onShowAdvanced: () => {}, + onPatch, + }), + container, + ); + const mode = container.querySelector("input[aria-label='Mode']"); + expect(mode).not.toBeNull(); + if (!mode) { + return; + } + mode.value = "fast"; + mode.dispatchEvent(new Event("input", { bubbles: true })); + expect(mode.value).toBe("safe"); + expect(onPatch).not.toHaveBeenCalled(); + }); + + it("renders and enforces additional properties composed through allOf", () => { + const analysis = analyzeConfigSchema({ + type: "object", + properties: { + aliases: { + type: "object", + allOf: [ + { + additionalProperties: { type: "string", minLength: 2 }, + }, + ], + }, + }, + }); + expect(analysis.unsupportedPaths).toEqual([]); + if (!analysis.schema) { + return; + } + + const onPatch = vi.fn(); + const container = document.createElement("div"); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: {}, + unsupportedPaths: analysis.unsupportedPaths, + value: { aliases: { custom: "ok" } }, + showAdvanced: true, + onShowAdvanced: () => {}, + onPatch, + }), + container, + ); + expect( + Array.from(container.querySelectorAll("button")).some( + (button) => button.textContent?.trim() === "Add Entry", + ), + ).toBe(true); + const custom = container.querySelector("input[aria-label='Custom']"); + expect(custom).not.toBeNull(); + if (!custom) { + return; + } + custom.value = "x"; + custom.dispatchEvent(new Event("input", { bubbles: true })); + expect(custom.value).toBe("x"); + expect(custom.getAttribute("aria-invalid")).toBe("true"); + expect(onPatch).not.toHaveBeenCalled(); + }); + + it("lets additionalProperties false dominate composed map policies", () => { + const analysis = analyzeConfigSchema({ + type: "object", + properties: { + aliases: { + type: "object", + additionalProperties: { type: "string" }, + allOf: [{ additionalProperties: false }], + }, + }, + }); + expect(analysis.unsupportedPaths).toEqual([]); + if (!analysis.schema) { + return; + } + + const container = document.createElement("div"); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: {}, + unsupportedPaths: analysis.unsupportedPaths, + value: { aliases: {} }, + showAdvanced: true, + onShowAdvanced: () => {}, + onPatch: vi.fn(), + }), + container, + ); + expect( + Array.from(container.querySelectorAll("button")).some( + (button) => button.textContent?.trim() === "Add Entry", + ), + ).toBe(false); + }); + + it("infers nested and compatible allOf types while rejecting impossible intersections", () => { + const analysis = analyzeConfigSchema({ + type: "object", + properties: { + nested: { + allOf: [{ allOf: [{ type: "string", minLength: 2 }] }], + }, + numeric: { + allOf: [{ type: "number" }, { type: "integer", minimum: 2 }], + }, + impossible: { + allOf: [{ type: "string" }, { type: "number" }], + }, + }, + }); + expect(analysis.unsupportedPaths).toEqual(["impossible"]); + expect(analysis.schema?.properties?.nested?.type).toBe("string"); + expect(analysis.schema?.properties?.numeric?.type).toBe("integer"); + if (!analysis.schema) { + return; + } + + const onPatch = vi.fn(); + const container = document.createElement("div"); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: {}, + unsupportedPaths: analysis.unsupportedPaths, + value: { nested: "ok", numeric: 2, impossible: "raw-only" }, + showAdvanced: true, + onShowAdvanced: () => {}, + onPatch, + }), + container, + ); + const nested = container.querySelector("input[aria-label='Nested']"); + const numeric = container.querySelector("input[aria-label='Numeric']"); + expect(nested?.type).toBe("text"); + expect(numeric?.type).toBe("number"); + if (!nested) { + return; + } + nested.value = "x"; + nested.dispatchEvent(new Event("input", { bubbles: true })); + expect(nested.getAttribute("aria-invalid")).toBe("true"); + expect(onPatch).not.toHaveBeenCalled(); + }); + + it("marks ambiguous non-null type arrays as form-unsafe", () => { + const analysis = analyzeConfigSchema({ + type: "object", + properties: { + numberFirst: { type: ["number", "string"] }, + stringFirst: { type: ["string", "number"] }, + }, + }); + expect(analysis.unsupportedPaths).toEqual(["numberFirst", "stringFirst"]); + }); + + it("marks allOf branches with unenforced constraint keywords as form-unsafe", () => { + const analysis = analyzeConfigSchema({ + type: "object", + properties: { + strictObject: { + type: "object", + additionalProperties: true, + allOf: [{ minProperties: 1 }], + }, + strictArray: { + type: "array", + items: { type: "string" }, + allOf: [{ contains: { const: "required" } }], + }, + typedObject: { + allOf: [{ type: "object", minProperties: 1 }], + }, + nestedConstraint: { + allOf: [ + { + type: "object", + properties: { + child: { minProperties: 1 }, + }, + }, + ], + }, + outerConstraint: { + type: "array", + items: { type: "string" }, + contains: { const: "required" }, + allOf: [{ maxItems: 3 }], + }, + plainConstraint: { + type: "array", + items: { type: "string" }, + contains: { const: "required" }, + }, + }, + }); + expect(analysis.unsupportedPaths).toEqual([ + "strictObject", + "strictArray", + "typedObject", + "nestedConstraint.child", + "outerConstraint", + "plainConstraint", + ]); + }); + + it("marks incompatible effective allOf child schemas as form-unsafe", () => { + const analysis = analyzeConfigSchema({ + type: "object", + properties: { + settings: { + type: "object", + properties: { + mode: { type: "string" }, + }, + allOf: [ + { + properties: { + mode: { type: "number" }, + constraintOnly: { const: "safe" }, + }, + }, + ], + }, + mixedItems: { + type: "array", + items: { type: "string" }, + allOf: [{ items: { type: "number" } }], + }, + }, + }); + expect(analysis.unsupportedPaths).toEqual([ + "settings.mode", + "settings.constraintOnly", + "mixedItems", + ]); + }); + + it("preserves nullability inherited through allOf", () => { + const analysis = analyzeConfigSchema({ + type: "object", + properties: { + inherited: { + allOf: [{ type: ["string", "null"] }], + }, + excludedByOuterType: { + type: "string", + allOf: [{ type: ["string", "null"] }], + }, + unionTypeExcludesNull: { + type: "string", + anyOf: [{ const: "fixed" }, { const: null }], + }, + }, + }); + expect(analysis.unsupportedPaths).toEqual(["inherited"]); + expect(analysis.schema?.properties?.inherited).toMatchObject({ + type: "string", + nullable: true, + }); + expect(analysis.schema?.properties?.excludedByOuterType).toMatchObject({ + type: "string", + nullable: false, + }); + expect(analysis.schema?.properties?.unionTypeExcludesNull).toMatchObject({ + nullable: false, + enumIncludesNull: false, + }); + }); + + it("preserves whether nullable enums actually include null", () => { + const analysis = analyzeConfigSchema({ + type: "object", + properties: { + excludesNull: { + type: ["string", "null"], + enum: ["fixed"], + }, + includesNull: { + type: ["string", "null"], + enum: ["fixed", null], + }, + typeExcludesNull: { + type: "string", + enum: ["fixed", null], + }, + }, + }); + expect(analysis.unsupportedPaths).toEqual([]); + expect(analysis.schema?.properties?.excludesNull?.enumIncludesNull).toBe(false); + expect(analysis.schema?.properties?.includesNull?.enumIncludesNull).toBe(true); + expect(analysis.schema?.properties?.typeExcludesNull).toMatchObject({ + nullable: false, + enumIncludesNull: false, + }); + }); + + it("keeps type-less allOf fields unsafe and closed empty tuples repairable", () => { + const analysis = analyzeConfigSchema({ + type: "object", + properties: { + unknown: { allOf: [{ minLength: 2 }] }, + empty: { + type: "array", + items: [], + additionalItems: false, + }, + }, + }); + expect(analysis.unsupportedPaths).toEqual(["unknown"]); + if (!analysis.schema) { + return; + } + + const onPatch = vi.fn(); + const container = document.createElement("div"); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: {}, + unsupportedPaths: analysis.unsupportedPaths, + value: { unknown: "raw-only", empty: ["invalid"] }, + showAdvanced: true, + onShowAdvanced: () => {}, + onPatch, + }), + container, + ); + const add = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ); + expect(add?.disabled).toBe(true); + const remove = container.querySelector("button[aria-label='Remove item']"); + expect(remove?.disabled).toBe(false); + remove?.click(); + expect(onPatch).toHaveBeenCalledWith(["empty"], []); + }); +}); diff --git a/ui/src/components/config-form-copy-on-write.test.ts b/ui/src/components/config-form-copy-on-write.test.ts new file mode 100644 index 000000000000..ce87bb301fd5 --- /dev/null +++ b/ui/src/components/config-form-copy-on-write.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { copyWithPathPatch } from "./config-form-copy-on-write.ts"; + +describe("config form copy-on-write patching", () => { + it("patches special own keys without changing object prototypes", () => { + const current = JSON.parse( + '{"constructor":{"value":"before"},"prototype":{"value":"before"},"__proto__":{"value":"before"},"untouched":{"stable":true}}', + ) as Record; + + for (const key of ["constructor", "prototype", "__proto__"]) { + const result = copyWithPathPatch(current, [key, "value"], "after"); + expect(result.ok).toBe(true); + if (!result.ok) { + continue; + } + const patched = result.value as Record>; + expect(Object.hasOwn(patched, key)).toBe(true); + expect(patched[key]?.value).toBe("after"); + expect(Object.getPrototypeOf(patched)).toBe(Object.prototype); + expect(patched.untouched).toBe(current.untouched); + } + }); + + it("creates an own __proto__ data property safely", () => { + const result = copyWithPathPatch({}, ["__proto__", "value"], "safe"); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + const patched = result.value as Record>; + expect(Object.hasOwn(patched, "__proto__")).toBe(true); + expect(Object.getOwnPropertyDescriptor(patched, "__proto__")?.value).toEqual({ + value: "safe", + }); + expect(Object.getPrototypeOf(patched)).toBe(Object.prototype); + }); +}); diff --git a/ui/src/components/config-form-copy-on-write.ts b/ui/src/components/config-form-copy-on-write.ts new file mode 100644 index 000000000000..6fa3a29530a9 --- /dev/null +++ b/ui/src/components/config-form-copy-on-write.ts @@ -0,0 +1,84 @@ +const INVALID_PATH_PATCH = Symbol("invalid-path-patch"); + +type PathPatchResult = + | { ok: true; value: unknown } + | { ok: false; value: typeof INVALID_PATH_PATCH }; + +function patchPathValue( + current: unknown, + path: Array, + index: number, + replacement: unknown, +): PathPatchResult { + const segment = path[index]; + if (segment === undefined) { + return { ok: false, value: INVALID_PATH_PATCH }; + } + const last = index === path.length - 1; + + if (typeof segment === "number") { + if (current != null && !Array.isArray(current)) { + return { ok: false, value: INVALID_PATH_PATCH }; + } + const next = Array.isArray(current) ? [...current] : []; + if (last) { + if (replacement === undefined) { + next.splice(segment, 1); + } else { + next[segment] = replacement; + } + return { ok: true, value: next }; + } + const child = patchPathValue(next[segment], path, index + 1, replacement); + if (!child.ok) { + return child; + } + next[segment] = child.value; + return { ok: true, value: next }; + } + + if (current != null && (typeof current !== "object" || Array.isArray(current))) { + return { ok: false, value: INVALID_PATH_PATCH }; + } + const next = current ? { ...(current as Record) } : {}; + if (last) { + if (replacement === undefined) { + delete next[segment]; + } else { + Object.defineProperty(next, segment, { + value: replacement, + enumerable: true, + configurable: true, + writable: true, + }); + } + return { ok: true, value: next }; + } + const child = patchPathValue( + Object.hasOwn(next, segment) ? next[segment] : undefined, + path, + index + 1, + replacement, + ); + if (!child.ok) { + return child; + } + Object.defineProperty(next, segment, { + value: child.value, + enumerable: true, + configurable: true, + writable: true, + }); + return { ok: true, value: next }; +} + +export function copyWithPathPatch( + current: unknown, + path: Array, + replacement: unknown, +): PathPatchResult { + if (path.length === 0) { + return { ok: true, value: replacement }; + } + return patchPathValue(current, path, 0, replacement); +} diff --git a/ui/src/components/config-form-integrity.browser.test.ts b/ui/src/components/config-form-integrity.browser.test.ts new file mode 100644 index 000000000000..0ff1eecac115 --- /dev/null +++ b/ui/src/components/config-form-integrity.browser.test.ts @@ -0,0 +1,1019 @@ +// Control UI tests cover config form constraints, draft recovery, and repeated controls. +import { render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import { + ConfigFormCollectionDraft, + type ConfigFormCollectionDraftCommit, +} from "./config-form-collection-draft.ts"; +import { renderArray } from "./config-form.node.collection.ts"; +import { renderNumberInput, renderTextInput } from "./config-form.node.scalar.ts"; +import { configFieldId } from "./config-form.shared.ts"; +import { analyzeConfigSchema, renderConfigForm, renderNode } from "./config-form.ts"; + +function expectElement(element: T | null | undefined, label: string): T { + expect(element instanceof Element, label).toBe(true); + if (!(element instanceof Element)) { + throw new Error(`missing ${label}`); + } + return element; +} + +describe("config form integrity", () => { + it("applies schema constraints and derives bounded repeated defaults", async () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const analysis = analyzeConfigSchema({ + type: "object", + properties: { + laboratory: { + type: "object", + required: ["endpoint", "retryBudget"], + properties: { + endpoint: { + type: "string", + description: "Lowercase slug.", + minLength: 3, + maxLength: 16, + pattern: "[a-z-]+", + }, + optionalAlias: { + type: "string", + minLength: 3, + }, + explicitEmpty: { + type: "string", + minLength: 0, + pattern: "^$", + }, + glyph: { + type: "string", + maxLength: 1, + pattern: "^.$", + }, + codes: { + type: "array", + items: { + type: "string", + minLength: 3, + pattern: "^[0-9]+$", + }, + }, + limited: { + type: "array", + maxItems: 1, + items: { type: "integer" }, + }, + provider: { + type: "string", + description: "Execution provider.", + enum: ["a", "b", "c", "d", "e", "f"], + }, + apiKey: { type: "string" }, + retryBudget: { + type: "integer", + description: "Even values from two through eight.", + minimum: 2, + maximum: 8, + multipleOf: 2, + }, + weights: { + type: "array", + items: { type: "integer", minimum: 2, maximum: 8, multipleOf: 2 }, + }, + }, + }, + }, + }); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: { "laboratory.apiKey": { sensitive: true } }, + unsupportedPaths: analysis.unsupportedPaths, + value: { + laboratory: { + endpoint: "local-api", + optionalAlias: "main", + explicitEmpty: "present", + glyph: "a", + codes: [], + limited: [1], + provider: "a", + apiKey: "test-secret", + retryBudget: 8, + weights: [2], + }, + }, + showAdvanced: true, + onShowAdvanced: () => {}, + onPatch, + }), + container, + ); + + const endpoint = expectElement( + container.querySelector("input[aria-label='Endpoint']"), + "constrained endpoint input", + ); + expect(endpoint.getAttribute("minlength")).toBeNull(); + expect(endpoint.getAttribute("maxlength")).toBeNull(); + expect(endpoint.pattern).toBe(""); + expect(endpoint.getAttribute("aria-describedby")).toBe( + "config-field-s10-006c00610062006f007200610074006f00720079_s8-0065006e00640070006f0069006e0074-description", + ); + endpoint.value = "Xlocal-apiY"; + endpoint.dispatchEvent(new Event("input", { bubbles: true })); + expect(onPatch).toHaveBeenCalledWith(["laboratory", "endpoint"], "Xlocal-apiY"); + + endpoint.value = " a "; + endpoint.dispatchEvent(new Event("input", { bubbles: true })); + expect(onPatch).toHaveBeenCalledWith(["laboratory", "endpoint"], " a "); + endpoint.dispatchEvent(new Event("change", { bubbles: true })); + expect(endpoint.value).toBe(" a "); + expect(endpoint.getAttribute("aria-invalid")).toBe("false"); + expect(onPatch).not.toHaveBeenCalledWith(["laboratory", "endpoint"], "a"); + + endpoint.value = "123"; + endpoint.dispatchEvent(new Event("input", { bubbles: true })); + expect(endpoint.getAttribute("aria-invalid")).toBe("true"); + expect(onPatch).not.toHaveBeenCalledWith(["laboratory", "endpoint"], "123"); + + endpoint.value = ""; + endpoint.dispatchEvent(new Event("input", { bubbles: true })); + expect(endpoint.getAttribute("aria-invalid")).toBe("true"); + + const optionalAlias = expectElement( + container.querySelector("input[aria-label='Optional Alias']"), + "optional constrained string", + ); + optionalAlias.value = ""; + optionalAlias.dispatchEvent(new Event("input", { bubbles: true })); + expect(optionalAlias.getAttribute("aria-invalid")).toBe("false"); + expect(onPatch).toHaveBeenCalledWith(["laboratory", "optionalAlias"], undefined); + const explicitEmpty = expectElement( + container.querySelector("input[aria-label='Explicit Empty']"), + "optional explicit empty string", + ); + explicitEmpty.value = ""; + explicitEmpty.dispatchEvent(new Event("input", { bubbles: true })); + expect(explicitEmpty.getAttribute("aria-invalid")).toBe("false"); + expect(onPatch).toHaveBeenCalledWith(["laboratory", "explicitEmpty"], ""); + const glyph = expectElement( + container.querySelector("input[aria-label='Glyph']"), + "unicode constrained string", + ); + expect(glyph.getAttribute("maxlength")).toBeNull(); + glyph.value = "😀"; + glyph.dispatchEvent(new Event("input", { bubbles: true })); + expect(glyph.getAttribute("aria-invalid")).toBe("false"); + expect(onPatch).toHaveBeenCalledWith(["laboratory", "glyph"], "😀"); + const codes = expectElement( + Array.from(container.querySelectorAll(".cfg-array")).find((block) => + block.textContent?.includes("Codes"), + ), + "patterned string array", + ); + const addCode = expectElement( + Array.from(codes.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "patterned string array add button", + ); + expect(addCode.disabled).toBe(false); + addCode.click(); + const codeDraftHost = expectElement( + codes.querySelector("openclaw-config-form-collection-draft"), + "patterned string array draft host", + ); + await codeDraftHost.updateComplete; + const codeDraft = expectElement( + codes.querySelector(".cfg-collection-draft"), + "patterned string array draft", + ); + expect(onPatch).not.toHaveBeenCalledWith(["laboratory", "codes"], expect.anything()); + const codeValue = expectElement( + codeDraft.querySelector("input[aria-label='Add: Codes']"), + "patterned string array draft value", + ); + const commitCode = expectElement( + Array.from(codeDraft.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "patterned string array draft commit", + ); + codeValue.value = "abc"; + commitCode.click(); + await codeDraftHost.updateComplete; + const invalidCodeValue = expectElement( + codeDraftHost.querySelector("[data-collection-draft-value]"), + "invalid patterned string array draft value", + ); + expect(invalidCodeValue.getAttribute("aria-invalid")).toBe("true"); + expect(onPatch).not.toHaveBeenCalledWith(["laboratory", "codes"], expect.anything()); + invalidCodeValue.value = "123"; + invalidCodeValue.dispatchEvent(new Event("input", { bubbles: true })); + await codeDraftHost.updateComplete; + expectElement( + Array.from(codeDraftHost.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "valid patterned string array draft commit", + ).click(); + expect(onPatch).toHaveBeenCalledWith(["laboratory", "codes"], ["123"]); + await codeDraftHost.updateComplete; + expect(codes.querySelector(".cfg-collection-draft")).toBeNull(); + const limited = expectElement( + Array.from(container.querySelectorAll(".cfg-array")).find((block) => + block.textContent?.includes("Limited"), + ), + "max-items array", + ); + const addLimited = expectElement( + Array.from(limited.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "max-items array add button", + ); + expect(addLimited.disabled).toBe(true); + const provider = expectElement( + container.querySelector("select[aria-label='Provider']"), + "named provider select", + ); + expect(provider.getAttribute("aria-describedby")).toBe( + "config-field-s10-006c00610062006f007200610074006f00720079_s8-00700072006f00760069006400650072-description", + ); + const apiKey = expectElement( + container.querySelector("input[aria-label='Api Key']"), + "named secret input", + ); + expect(apiKey.readOnly).toBe(true); + expect(apiKey.classList.contains("cfg-redacted")).toBe(true); + + const retryBudget = expectElement( + container.querySelector("input[aria-label='Retry Budget']"), + "constrained retry budget input", + ); + expect(retryBudget.min).toBe("2"); + expect(retryBudget.max).toBe("8"); + expect(retryBudget.step).toBe("2"); + const weightInput = expectElement( + container.querySelector(".cfg-array input[aria-label='Weights']"), + "bounded array input", + ); + + retryBudget.value = ""; + retryBudget.dispatchEvent(new Event("input", { bubbles: true })); + expect(retryBudget.getAttribute("aria-invalid")).toBe("true"); + expect(retryBudget.validationMessage).not.toBe(""); + expect(onPatch).not.toHaveBeenCalledWith(["laboratory", "retryBudget"], undefined); + + retryBudget.value = "3"; + retryBudget.dispatchEvent(new Event("input", { bubbles: true })); + expect(retryBudget.validationMessage).not.toBe(""); + retryBudget.dispatchEvent(new Event("change", { bubbles: true })); + expect(retryBudget.value).toBe("4"); + expect(retryBudget.validationMessage).toBe(""); + expect(retryBudget.checkValidity()).toBe(true); + expect(onPatch).toHaveBeenCalledWith(["laboratory", "retryBudget"], 4); + + const increment = expectElement( + container.querySelector("button[aria-label='Retry Budget: +2']"), + "retry increment button", + ); + increment.click(); + expect(onPatch).toHaveBeenCalledWith(["laboratory", "retryBudget"], 8); + + const addButton = expectElement( + Array.from( + expectElement( + weightInput.closest(".cfg-array"), + "bounded array", + ).querySelectorAll("button"), + ).find((button) => button.textContent?.trim() === "Add"), + "bounded array add button", + ); + addButton.click(); + expect(onPatch).toHaveBeenCalledWith(["laboratory", "weights"], [2, 2]); + container.remove(); + }); + + it("generates unambiguous accessible IDs for nested paths", () => { + expect(configFieldId(["a--b"], "description")).not.toBe( + configFieldId(["a", "b"], "description"), + ); + expect(configFieldId([1], "description")).not.toBe(configFieldId(["1"], "description")); + expect(() => configFieldId(["\ud800"], "description")).not.toThrow(); + expect(configFieldId(["\ud800"], "description")).not.toBe( + configFieldId(["\ufffd"], "description"), + ); + }); + + it("keeps rejected map-key edits synchronized with the persisted key", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + const analysis = analyzeConfigSchema({ + type: "object", + properties: { + accounts: { type: "object", additionalProperties: true }, + }, + }); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: {}, + unsupportedPaths: analysis.unsupportedPaths, + value: { accounts: { alpha: {}, beta: {} } }, + showAdvanced: true, + onShowAdvanced: () => {}, + onPatch, + }), + container, + ); + + const alpha = expectElement( + Array.from(container.querySelectorAll(".cfg-map input")).find( + (input) => input.value === "alpha", + ), + "alpha map key input", + ); + alpha.value = " "; + alpha.dispatchEvent(new Event("change", { bubbles: true })); + expect(alpha.value).toBe("alpha"); + expect(onPatch).not.toHaveBeenCalled(); + + alpha.value = "beta"; + alpha.dispatchEvent(new Event("change", { bubbles: true })); + expect(alpha.value).toBe("alpha"); + expect(onPatch).not.toHaveBeenCalled(); + }); + + it("commits typed map entries only after the local draft is valid", async () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const analysis = analyzeConfigSchema({ + type: "object", + properties: { + aliases: { + type: "object", + properties: { + fixed: { type: "integer" }, + }, + additionalProperties: { + type: "string", + minLength: 3, + pattern: "^[0-9]+$", + }, + }, + }, + }); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: {}, + unsupportedPaths: analysis.unsupportedPaths, + value: { aliases: {} }, + showAdvanced: true, + onShowAdvanced: () => {}, + onPatch, + }), + container, + ); + + const addEntry = expectElement( + Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add Entry", + ), + "typed map add button", + ); + addEntry.click(); + const draftHost = expectElement( + container.querySelector("openclaw-config-form-collection-draft"), + "typed map draft host", + ); + await draftHost.updateComplete; + const draft = expectElement( + container.querySelector(".cfg-map .cfg-collection-draft"), + "typed map draft", + ); + const key = expectElement( + draft.querySelector("[data-collection-draft-key]"), + "typed map draft key", + ); + const value = expectElement( + draft.querySelector("[data-collection-draft-value]"), + "typed map draft value", + ); + const commit = expectElement( + Array.from(draft.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add Entry", + ), + "typed map draft commit", + ); + key.value = "fixed"; + key.dispatchEvent(new Event("input", { bubbles: true })); + value.value = "123"; + value.dispatchEvent(new Event("input", { bubbles: true })); + await draftHost.updateComplete; + commit.click(); + await draftHost.updateComplete; + const reservedKey = expectElement( + draftHost.querySelector("[data-collection-draft-key]"), + "reserved typed map draft key", + ); + expect(reservedKey.getAttribute("aria-invalid")).toBe("true"); + expect(onPatch).not.toHaveBeenCalled(); + + draftHost.dispatchEvent( + new CustomEvent("config-collection-draft-commit", { + bubbles: true, + detail: { key: "constructor", value: "123" }, + }), + ); + expect(onPatch).toHaveBeenCalledWith(["aliases"], { constructor: "123" }); + onPatch.mockClear(); + + reservedKey.value = "primary"; + reservedKey.dispatchEvent(new Event("input", { bubbles: true })); + const invalidValueInput = expectElement( + draftHost.querySelector("[data-collection-draft-value]"), + "typed map draft value after reserved key", + ); + invalidValueInput.value = "abc"; + invalidValueInput.dispatchEvent(new Event("input", { bubbles: true })); + await draftHost.updateComplete; + expectElement( + Array.from(draftHost.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add Entry", + ), + "invalid typed map draft commit", + ).click(); + await draftHost.updateComplete; + const invalidValue = expectElement( + draftHost.querySelector("[data-collection-draft-value]"), + "invalid typed map draft value", + ); + expect(invalidValue.getAttribute("aria-invalid")).toBe("true"); + expect(onPatch).not.toHaveBeenCalled(); + + invalidValue.value = "123"; + invalidValue.dispatchEvent(new Event("input", { bubbles: true })); + await draftHost.updateComplete; + expectElement( + Array.from(draftHost.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add Entry", + ), + "valid typed map draft commit", + ).click(); + expect(onPatch).toHaveBeenCalledWith(["aliases"], { primary: "123" }); + await draftHost.updateComplete; + expect(container.querySelector(".cfg-map .cfg-collection-draft")).toBeNull(); + container.remove(); + }); + + it("validates tuple collection drafts by position and additional-item policy", async () => { + const host = document.createElement( + "openclaw-config-form-collection-draft", + ) as ConfigFormCollectionDraft; + const commits = vi.fn(); + host.id = "tuple-draft"; + host.props = { + schema: { + type: "array", + items: [ + { + allOf: [ + { type: "string", pattern: "^[0-9]+$", enum: ["123", "12"] }, + { minLength: 3 }, + { anyOf: [{ const: "123" }, { const: "12" }] }, + { oneOf: [{ pattern: "^[0-9]+$" }, { const: "never" }] }, + ], + }, + { type: "number", const: 0 }, + ], + additionalItems: false, + }, + label: "Tuple", + disabled: false, + identity: "tuple-draft", + sourceIdentity: [], + }; + host.addEventListener("config-collection-draft-commit", commits); + document.body.append(host); + + host.openDraft(); + await host.updateComplete; + const value = expectElement( + host.querySelector("[data-collection-draft-value]"), + "tuple draft value", + ); + value.value = '["123",-0]'; + value.dispatchEvent(new Event("input", { bubbles: true })); + await host.updateComplete; + expectElement( + Array.from(host.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "valid tuple draft commit", + ).click(); + expect(commits).toHaveBeenCalledTimes(1); + + host.openDraft(); + await host.updateComplete; + const invalidValue = expectElement( + host.querySelector("[data-collection-draft-value]"), + "invalid tuple draft value", + ); + invalidValue.value = '["12",-0]'; + invalidValue.dispatchEvent(new Event("input", { bubbles: true })); + await host.updateComplete; + expectElement( + Array.from(host.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "invalid tuple draft commit", + ).click(); + await host.updateComplete; + expect(commits).toHaveBeenCalledTimes(1); + expect( + expectElement( + host.querySelector("[data-collection-draft-value]"), + "rejected tuple draft value", + ).getAttribute("aria-invalid"), + ).toBe("true"); + + const extraValue = expectElement( + host.querySelector("[data-collection-draft-value]"), + "tuple draft value with extra item", + ); + extraValue.value = '["123",-0,2]'; + extraValue.dispatchEvent(new Event("input", { bubbles: true })); + await host.updateComplete; + expectElement( + Array.from(host.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "tuple draft commit with extra item", + ).click(); + await host.updateComplete; + expect(commits).toHaveBeenCalledTimes(1); + + host.props = { + ...host.props, + sourceIdentity: [], + }; + await host.updateComplete; + expect(host.querySelector(".cfg-collection-draft")).toBeNull(); + host.remove(); + }); + + it("preserves unaffected scalar drafts and resets changed repeated rows", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + const analysis = analyzeConfigSchema({ + type: "object", + properties: { + values: { + type: "array", + items: { type: "string", pattern: "^[0-9]+$" }, + }, + }, + }); + const renderValues = (values: string[]) => { + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: {}, + unsupportedPaths: analysis.unsupportedPaths, + value: { values }, + showAdvanced: true, + onShowAdvanced: () => {}, + onPatch, + }), + container, + ); + }; + + renderValues(["111", "222"]); + const first = expectElement( + container.querySelector("input[aria-label='Values']"), + "first repeated scalar input", + ); + first.value = "abc"; + first.dispatchEvent(new Event("input", { bubbles: true })); + expect(first.getAttribute("aria-invalid")).toBe("true"); + expect(first.validationMessage).not.toBe(""); + + renderValues(["111", "333"]); + const afterSiblingUpdate = expectElement( + container.querySelector("input[aria-label='Values']"), + "repeated scalar input after sibling update", + ); + expect(afterSiblingUpdate).toBe(first); + expect(afterSiblingUpdate.value).toBe("abc"); + expect(afterSiblingUpdate.getAttribute("aria-invalid")).toBe("true"); + expect(afterSiblingUpdate.validationMessage).not.toBe(""); + + renderValues(["111", "333", "444"]); + const afterAppend = expectElement( + container.querySelector("input[aria-label='Values']"), + "repeated scalar input after append", + ); + expect(afterAppend).toBe(first); + expect(afterAppend.value).toBe("abc"); + expect(afterAppend.getAttribute("aria-invalid")).toBe("true"); + expect(afterAppend.validationMessage).not.toBe(""); + + renderValues(["111"]); + const afterLaterRemoval = expectElement( + container.querySelector("input[aria-label='Values']"), + "repeated scalar input after later removal", + ); + expect(afterLaterRemoval).toBe(first); + expect(afterLaterRemoval.value).toBe("abc"); + expect(afterLaterRemoval.getAttribute("aria-invalid")).toBe("true"); + expect(afterLaterRemoval.validationMessage).not.toBe(""); + + renderValues(["444"]); + const changed = expectElement( + container.querySelector("input[aria-label='Values']"), + "changed repeated scalar input", + ); + expect(changed).toBe(first); + expect(changed.value).toBe("444"); + expect(changed.getAttribute("aria-invalid")).toBe("false"); + expect(changed.validationMessage).toBe(""); + }); + + it("clears scalar validity when sensitive presentation changes", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + const analysis = analyzeConfigSchema({ + type: "object", + properties: { + apiKey: { type: "string", pattern: "^[0-9]+$" }, + }, + }); + const renderSensitive = (revealSensitive: boolean) => { + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: { apiKey: { sensitive: true } }, + unsupportedPaths: analysis.unsupportedPaths, + value: { apiKey: "123" }, + showAdvanced: true, + onShowAdvanced: () => {}, + revealSensitive, + onPatch, + }), + container, + ); + }; + + renderSensitive(true); + const input = expectElement( + container.querySelector("input[aria-label='Api Key']"), + "revealed sensitive scalar", + ); + input.value = "abc"; + input.dispatchEvent(new Event("input", { bubbles: true })); + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect(input.validationMessage).not.toBe(""); + expect(onPatch).not.toHaveBeenCalled(); + + renderSensitive(false); + const hidden = expectElement( + container.querySelector("input[aria-label='Api Key']"), + "hidden sensitive scalar", + ); + expect(hidden).toBe(input); + expect(hidden.value).toBe(""); + expect(hidden.getAttribute("aria-invalid")).toBe("false"); + expect(hidden.validationMessage).toBe(""); + + renderSensitive(true); + const revealed = expectElement( + container.querySelector("input[aria-label='Api Key']"), + "restored sensitive scalar", + ); + expect(revealed).toBe(input); + expect(revealed.value).toBe("123"); + expect(revealed.getAttribute("aria-invalid")).toBe("false"); + expect(revealed.validationMessage).toBe(""); + }); + + it("validates scalar edits against composed schemas", () => { + const onPatch = vi.fn(); + const stringContainer = document.createElement("div"); + render( + renderTextInput({ + schema: { + type: "string", + allOf: [{ pattern: "^[0-9]+$" }], + }, + value: "123", + path: ["code"], + hints: {}, + unsupported: new Set(), + disabled: false, + inputType: "text", + onPatch, + }), + stringContainer, + ); + + const code = expectElement( + stringContainer.querySelector("input[aria-label='Code']"), + "composed string scalar", + ); + code.value = "abc"; + code.dispatchEvent(new Event("input", { bubbles: true })); + code.dispatchEvent(new Event("change", { bubbles: true })); + expect(code.getAttribute("aria-invalid")).toBe("true"); + expect(onPatch).not.toHaveBeenCalledWith(["code"], "abc"); + code.value = "456"; + code.dispatchEvent(new Event("input", { bubbles: true })); + expect(code.getAttribute("aria-invalid")).toBe("false"); + expect(onPatch).toHaveBeenCalledWith(["code"], "456"); + + const numberContainer = document.createElement("div"); + render( + renderNumberInput({ + schema: { + type: "integer", + allOf: [{ minimum: 2 }, { multipleOf: 2 }], + }, + value: 2, + path: ["amount"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + }), + numberContainer, + ); + const amount = expectElement( + numberContainer.querySelector("input[aria-label='Amount']"), + "composed numeric scalar", + ); + expect( + expectElement( + numberContainer.querySelector("button[aria-label='Amount: +2']"), + "composed numeric increment", + ).disabled, + ).toBe(false); + numberContainer + .querySelector("button[aria-label='Amount: +2']") + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onPatch).toHaveBeenCalledWith(["amount"], 4); + onPatch.mockClear(); + amount.value = "3"; + amount.dispatchEvent(new Event("input", { bubbles: true })); + expect(amount.getAttribute("aria-invalid")).toBe("true"); + expect(onPatch).not.toHaveBeenCalledWith(["amount"], 3); + amount.dispatchEvent(new Event("change", { bubbles: true })); + expect(amount.value).toBe("4"); + expect(amount.getAttribute("aria-invalid")).toBe("false"); + expect(onPatch).toHaveBeenCalledWith(["amount"], 4); + amount.value = "4"; + amount.dispatchEvent(new Event("input", { bubbles: true })); + expect(amount.getAttribute("aria-invalid")).toBe("false"); + expect(onPatch).toHaveBeenCalledWith(["amount"], 4); + + const overflowContainer = document.createElement("div"); + render( + renderNumberInput({ + schema: { type: "integer", multipleOf: 2 }, + value: 2, + path: ["overflow"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + }), + overflowContainer, + ); + const overflow = expectElement( + overflowContainer.querySelector("input[aria-label='Overflow']"), + "overflow numeric scalar", + ); + onPatch.mockClear(); + Object.defineProperty(overflow, "value", { + configurable: true, + value: "1e309", + writable: true, + }); + overflow.dispatchEvent(new Event("input", { bubbles: true })); + expect(overflow.getAttribute("aria-invalid")).toBe("true"); + expect(() => overflow.dispatchEvent(new Event("change", { bubbles: true }))).not.toThrow(); + expect(overflow.value).toBe("1e309"); + expect(overflow.getAttribute("aria-invalid")).toBe("true"); + expect(onPatch).not.toHaveBeenCalled(); + }); + + it("materializes and preserves array minItems", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + const schema = { + type: "array", + allOf: [{ minItems: 2, maxItems: 2 }], + items: { type: "string" }, + }; + const renderValue = (value: unknown) => { + render( + renderArray( + { + schema, + value, + path: ["codes"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + }, + renderNode, + ), + container, + ); + }; + + renderValue(undefined); + expectElement( + Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "minimum array add", + ).click(); + expect(onPatch).toHaveBeenCalledWith(["codes"], ["", ""]); + + renderValue(["", ""]); + const removeButtons = Array.from( + container.querySelectorAll("button[aria-label='Remove item']"), + ); + expect(removeButtons).toHaveLength(2); + expect(removeButtons.every((button) => button.disabled)).toBe(true); + }); + + it("retains unset array drafts until the collection source changes", async () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const schema = { + type: "array", + items: { type: "string", pattern: "^[0-9]+$" }, + }; + const renderValue = (value: unknown) => { + render( + renderArray( + { + schema, + value, + path: ["codes"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + }, + renderNode, + ), + container, + ); + }; + + renderValue(undefined); + const codes = expectElement( + container.querySelector(".cfg-array"), + "unset codes array", + ); + const draftHost = expectElement( + codes.querySelector("openclaw-config-form-collection-draft"), + "unset codes draft host", + ); + await draftHost.updateComplete; + expectElement( + Array.from(codes.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "unset codes add button", + ).click(); + await draftHost.updateComplete; + const draftValue = expectElement( + draftHost.querySelector("[data-collection-draft-value]"), + "unset codes draft value", + ); + draftValue.value = "123"; + draftValue.dispatchEvent(new Event("input", { bubbles: true })); + await draftHost.updateComplete; + + renderValue(undefined); + await draftHost.updateComplete; + expect( + expectElement( + draftHost.querySelector("[data-collection-draft-value]"), + "preserved unset codes draft value", + ).value, + ).toBe("123"); + + renderValue([]); + await draftHost.updateComplete; + expect(draftHost.querySelector(".cfg-collection-draft")).toBeNull(); + container.remove(); + }); + + it("retains invalid JSON drafts with an inline accessible error", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + const primaryValue = { enabled: true }; + const analysis = analyzeConfigSchema({ + type: "object", + properties: { + accounts: { type: "object", additionalProperties: true }, + }, + }); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: {}, + unsupportedPaths: analysis.unsupportedPaths, + value: { accounts: { primary: primaryValue, secondary: { enabled: false } } }, + showAdvanced: true, + onShowAdvanced: () => {}, + onPatch, + }), + container, + ); + + const textarea = expectElement( + container.querySelector(".cfg-map textarea"), + "JSON map value", + ); + expect(textarea.getAttribute("aria-label")).toBe("primary: JSON value"); + textarea.value = '{"enabled":'; + textarea.dispatchEvent(new Event("input", { bubbles: true })); + textarea.dispatchEvent(new Event("change", { bubbles: true })); + + expect(textarea.value).toBe('{"enabled":'); + expect(textarea.getAttribute("aria-invalid")).toBe("true"); + const error = expectElement( + container.querySelector("[role='alert']"), + "JSON error", + ); + expect(error.hidden).toBe(false); + expect(error.textContent).toContain("valid JSON"); + expect(onPatch).not.toHaveBeenCalled(); + + textarea.value = '{"enabled":false}'; + textarea.dispatchEvent(new Event("input", { bubbles: true })); + textarea.dispatchEvent(new Event("change", { bubbles: true })); + expect(textarea.getAttribute("aria-invalid")).toBe("false"); + expect(error.hidden).toBe(true); + expect(onPatch).toHaveBeenCalledWith(["accounts", "primary"], { enabled: false }); + + textarea.value = '{"enabled":'; + textarea.dispatchEvent(new Event("input", { bubbles: true })); + expect(textarea.getAttribute("aria-invalid")).toBe("true"); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: {}, + unsupportedPaths: analysis.unsupportedPaths, + value: { accounts: { primary: primaryValue, secondary: { enabled: true } } }, + showAdvanced: true, + onShowAdvanced: () => {}, + onPatch, + }), + container, + ); + const siblingUpdateTextarea = expectElement( + container.querySelector( + ".cfg-map textarea[aria-label='primary: JSON value']", + ), + "JSON map value after sibling update", + ); + expect(siblingUpdateTextarea.value).toBe('{"enabled":'); + expect(siblingUpdateTextarea.getAttribute("aria-invalid")).toBe("true"); + expect(siblingUpdateTextarea.validationMessage).not.toBe(""); + expect(error.hidden).toBe(false); + + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: {}, + unsupportedPaths: analysis.unsupportedPaths, + value: { accounts: { primary: { enabled: true } } }, + showAdvanced: true, + onShowAdvanced: () => {}, + onPatch, + }), + container, + ); + const resetTextarea = expectElement( + container.querySelector(".cfg-map textarea"), + "externally reset JSON map value", + ); + expect(resetTextarea.value).toContain('"enabled": true'); + expect(resetTextarea.getAttribute("aria-invalid")).toBe("false"); + expect(resetTextarea.validationMessage).toBe(""); + expect(error.hidden).toBe(true); + }); +}); diff --git a/ui/src/components/config-form-map-integrity.browser.test.ts b/ui/src/components/config-form-map-integrity.browser.test.ts new file mode 100644 index 000000000000..fc09b462ec25 --- /dev/null +++ b/ui/src/components/config-form-map-integrity.browser.test.ts @@ -0,0 +1,92 @@ +import { render } from "lit"; +import { describe, expect, it } from "vitest"; +import { ConfigFormCollectionDraft } from "./config-form-collection-draft.ts"; +import { analyzeConfigSchema, renderConfigForm } from "./config-form.ts"; + +function expectElement(element: T | null | undefined, label: string): T { + expect(element instanceof Element, label).toBe(true); + if (!(element instanceof Element)) { + throw new Error(`missing ${label}`); + } + return element; +} + +describe("config form map integrity", () => { + it("retains unset map drafts until the collection source changes", async () => { + const container = document.createElement("div"); + document.body.append(container); + const analysis = analyzeConfigSchema({ + type: "object", + properties: { + aliases: { + type: "object", + additionalProperties: { + type: "string", + pattern: "^[0-9]+$", + }, + }, + }, + }); + const renderValue = (aliases: Record | undefined) => { + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: {}, + unsupportedPaths: analysis.unsupportedPaths, + value: aliases === undefined ? {} : { aliases }, + showAdvanced: true, + onShowAdvanced: () => {}, + onPatch: () => {}, + }), + container, + ); + }; + + renderValue(undefined); + const map = expectElement(container.querySelector(".cfg-map"), "unset map"); + const draftHost = expectElement( + map.querySelector("openclaw-config-form-collection-draft"), + "unset map draft host", + ); + expectElement( + Array.from(map.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add Entry", + ), + "unset map add button", + ).click(); + await draftHost.updateComplete; + const key = expectElement( + draftHost.querySelector("[data-collection-draft-key]"), + "unset map draft key", + ); + const value = expectElement( + draftHost.querySelector("[data-collection-draft-value]"), + "unset map draft value", + ); + key.value = "primary"; + key.dispatchEvent(new Event("input", { bubbles: true })); + value.value = "123"; + value.dispatchEvent(new Event("input", { bubbles: true })); + await draftHost.updateComplete; + + renderValue(undefined); + await draftHost.updateComplete; + expect( + expectElement( + draftHost.querySelector("[data-collection-draft-key]"), + "preserved unset map draft key", + ).value, + ).toBe("primary"); + expect( + expectElement( + draftHost.querySelector("[data-collection-draft-value]"), + "preserved unset map draft value", + ).value, + ).toBe("123"); + + renderValue({}); + await draftHost.updateComplete; + expect(draftHost.querySelector(".cfg-collection-draft")).toBeNull(); + container.remove(); + }); +}); diff --git a/ui/src/components/config-form-nested-array-integrity.browser.test.ts b/ui/src/components/config-form-nested-array-integrity.browser.test.ts new file mode 100644 index 000000000000..cf8bf1103137 --- /dev/null +++ b/ui/src/components/config-form-nested-array-integrity.browser.test.ts @@ -0,0 +1,74 @@ +import { render } from "lit"; +import { describe, expect, it } from "vitest"; +import { renderArray } from "./config-form.node.collection.ts"; +import { renderNode } from "./config-form.ts"; + +function expectElement(element: T | null | undefined, label: string): T { + expect(element instanceof Element, label).toBe(true); + if (!(element instanceof Element)) { + throw new Error(`missing ${label}`); + } + return element; +} + +describe("config form nested array integrity", () => { + it("preserves an unrelated JSON draft when a sibling field changes", () => { + const container = document.createElement("div"); + let currentValue: unknown[] = [{ name: "before", payload: { enabled: true } }]; + const renderValue = () => { + render( + renderArray( + { + schema: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + payload: {}, + }, + }, + }, + value: currentValue, + path: ["entries"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch: (_path, nextValue) => { + currentValue = nextValue as unknown[]; + renderValue(); + }, + }, + renderNode, + ), + container, + ); + }; + + renderValue(); + const payload = expectElement( + container.querySelector("textarea"), + "payload JSON draft", + ); + payload.value = "{"; + payload.dispatchEvent(new Event("input", { bubbles: true })); + expect(payload.getAttribute("aria-invalid")).toBe("true"); + + const name = expectElement( + container.querySelector("input[aria-label='Name']"), + "name input", + ); + name.value = "after"; + name.dispatchEvent(new Event("input", { bubbles: true })); + name.dispatchEvent(new Event("change", { bubbles: true })); + + const currentPayload = expectElement( + container.querySelector("textarea"), + "preserved payload JSON draft", + ); + expect(currentPayload).toBe(payload); + expect(currentPayload.value).toBe("{"); + expect(currentPayload.getAttribute("aria-invalid")).toBe("true"); + expect(currentValue).toEqual([{ name: "after", payload: { enabled: true } }]); + }); +}); diff --git a/ui/src/components/config-form-rejection-integrity.browser.test.ts b/ui/src/components/config-form-rejection-integrity.browser.test.ts new file mode 100644 index 000000000000..5f9477e89049 --- /dev/null +++ b/ui/src/components/config-form-rejection-integrity.browser.test.ts @@ -0,0 +1,569 @@ +// Control UI tests cover nested config edit rejection and draft preservation. +import { render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import { ConfigFormCollectionDraft } from "./config-form-collection-draft.ts"; +import { renderArray, renderObject } from "./config-form.node.collection.ts"; +import { renderNode } from "./config-form.ts"; + +type ConfigFormStructuredDraftElement = HTMLElement & { + updateComplete: Promise; +}; + +function expectElement(element: T | null | undefined, label: string): T { + expect(element instanceof Element, label).toBe(true); + if (!(element instanceof Element)) { + throw new Error(`missing ${label}`); + } + return element; +} + +describe("config form rejection integrity", () => { + it("preserves an invalid property draft when a sibling edit rerenders the object", () => { + const container = document.createElement("div"); + const schema = { + type: "object", + properties: { + name: { type: "string", minLength: 2 }, + mode: { type: "string", enum: ["a", "b", "c", "d", "e", "f"] }, + }, + }; + let currentValue = { name: "valid", mode: "a" }; + const renderValue = () => { + render( + renderObject( + { + schema, + value: currentValue, + path: ["settings"], + hints: {}, + unsupported: new Set(), + disabled: false, + sourceIdentity: currentValue, + controlIdentity: currentValue, + onPatch: (path, nextValue) => { + const key = path.at(-1); + if (key !== "name" && key !== "mode") { + return false; + } + currentValue = { ...currentValue, [key]: nextValue }; + renderValue(); + return true; + }, + }, + renderNode, + ), + container, + ); + }; + + renderValue(); + const name = expectElement( + container.querySelector("input[aria-label='Name']"), + "name input", + ); + name.value = "x"; + name.dispatchEvent(new Event("input", { bubbles: true })); + expect(name.getAttribute("aria-invalid")).toBe("true"); + + const mode = expectElement( + container.querySelector("select[aria-label='Mode']"), + "mode select", + ); + mode.value = "1"; + mode.dispatchEvent(new Event("change", { bubbles: true })); + + const rerenderedName = expectElement( + container.querySelector("input[aria-label='Name']"), + "rerendered name input", + ); + expect(rerenderedName.value).toBe("x"); + expect(rerenderedName.getAttribute("aria-invalid")).toBe("true"); + expect(currentValue).toEqual({ name: "valid", mode: "b" }); + }); + + it("keeps a nested collection draft open when its parent rejects the commit", async () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + render( + renderArray( + { + schema: { + type: "array", + uniqueItems: true, + items: { + type: "array", + items: { type: "string", pattern: "^[a-z]+$" }, + }, + }, + value: [["alpha"], []], + path: ["groups"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + }, + renderNode, + ), + container, + ); + + const arrays = Array.from(container.querySelectorAll(".cfg-array")); + const secondGroup = expectElement(arrays[2], "second nested array"); + const draft = expectElement( + secondGroup.querySelector("openclaw-config-form-collection-draft"), + "second nested array draft", + ); + await draft.updateComplete; + expectElement( + Array.from(secondGroup.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "second nested array add", + ).click(); + await draft.updateComplete; + const value = expectElement( + draft.querySelector("[data-collection-draft-value]"), + "second nested array draft value", + ); + value.value = "alpha"; + value.dispatchEvent(new Event("input", { bubbles: true })); + await draft.updateComplete; + expectElement( + Array.from(draft.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "second nested array commit", + ).click(); + await draft.updateComplete; + + expect(onPatch).not.toHaveBeenCalled(); + expect(draft.querySelector(".cfg-collection-draft")).not.toBeNull(); + const retainedValue = expectElement( + draft.querySelector("[data-collection-draft-value]"), + "retained nested array draft value", + ); + expect(retainedValue.value).toBe("alpha"); + expect(retainedValue.getAttribute("aria-invalid")).toBe("true"); + expect(draft.querySelector("[role='alert']")?.hidden).toBe(false); + container.remove(); + }); + + it("opens an array draft when a parent rejects the automatic default", async () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + render( + renderArray( + { + schema: { + type: "array", + uniqueItems: true, + items: { + type: "array", + items: { type: "string" }, + }, + }, + value: [[""], []], + path: ["groups"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + }, + renderNode, + ), + container, + ); + + const arrays = Array.from(container.querySelectorAll(".cfg-array")); + const secondGroup = expectElement(arrays[2], "second auto-default array"); + const draft = expectElement( + secondGroup.querySelector("openclaw-config-form-collection-draft"), + "second auto-default array draft", + ); + await draft.updateComplete; + expectElement( + Array.from(secondGroup.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "second auto-default array add", + ).click(); + await draft.updateComplete; + expect(draft.querySelector(".cfg-collection-draft")).not.toBeNull(); + + const value = expectElement( + draft.querySelector("[data-collection-draft-value]"), + "array fallback draft value", + ); + value.value = "x"; + value.dispatchEvent(new Event("input", { bubbles: true })); + await draft.updateComplete; + expectElement( + Array.from(draft.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "array fallback draft commit", + ).click(); + expect(onPatch).toHaveBeenCalledWith(["groups"], [[""], ["x"]]); + container.remove(); + }); + + it("opens a map draft when a parent rejects the automatic entry", async () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + render( + renderArray( + { + schema: { + type: "array", + uniqueItems: true, + items: { + type: "object", + additionalProperties: { type: "object" }, + }, + }, + value: [{ "custom-1": {} }, {}], + path: ["entries"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + }, + renderNode, + ), + container, + ); + + const maps = Array.from(container.querySelectorAll(".cfg-map")); + const secondMap = expectElement(maps[1], "second auto-default map"); + const draft = expectElement( + secondMap.querySelector("openclaw-config-form-collection-draft"), + "second auto-default map draft", + ); + await draft.updateComplete; + expectElement( + Array.from(secondMap.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add Entry", + ), + "second auto-default map add", + ).click(); + await draft.updateComplete; + expect(draft.querySelector(".cfg-collection-draft")).not.toBeNull(); + + const key = expectElement( + draft.querySelector("[data-collection-draft-key]"), + "map fallback draft key", + ); + const value = expectElement( + draft.querySelector("[data-collection-draft-value]"), + "map fallback draft value", + ); + key.value = "custom-2"; + key.dispatchEvent(new Event("input", { bubbles: true })); + value.value = "{}"; + value.dispatchEvent(new Event("input", { bubbles: true })); + await draft.updateComplete; + expectElement( + Array.from(draft.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add Entry", + ), + "map fallback draft commit", + ).click(); + expect(onPatch).toHaveBeenCalledWith(["entries"], [{ "custom-1": {} }, { "custom-2": {} }]); + container.remove(); + }); + + it("restores a map key when a constrained parent rejects the rename", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + render( + renderArray( + { + schema: { + type: "array", + uniqueItems: true, + items: { + type: "object", + additionalProperties: { type: "string" }, + }, + }, + value: [{ a: "1" }, { b: "1" }], + path: ["entries"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + }, + renderNode, + ), + container, + ); + + const key = expectElement( + container.querySelector("input[aria-label='Key: b']"), + "second map key", + ); + key.value = "a"; + key.dispatchEvent(new Event("change", { bubbles: true })); + + expect(onPatch).not.toHaveBeenCalled(); + expect(key.value).toBe("b"); + }); + + it("commits an optional object only after all required children are valid", async () => { + const container = document.createElement("div"); + document.body.append(container); + let currentValue: Record = {}; + const onPatch = vi.fn((path: Array, value: unknown) => { + currentValue = { ...currentValue, connection: value }; + renderValue(); + return true; + }); + const schema = { + type: "object", + properties: { + connection: { + type: "object", + required: ["host", "port"], + properties: { + host: { type: "string", minLength: 1 }, + port: { type: "integer", minimum: 1 }, + }, + }, + }, + }; + const renderValue = () => { + render( + renderObject( + { + schema, + value: currentValue, + path: ["settings"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + }, + renderNode, + ), + container, + ); + }; + + renderValue(); + const draft = expectElement( + container.querySelector( + "openclaw-config-form-structured-draft", + ), + "optional object draft", + ); + await draft.updateComplete; + const host = expectElement( + draft.querySelector("input[aria-label='Host']"), + "optional host", + ); + host.value = "gateway.local"; + host.dispatchEvent(new Event("input", { bubbles: true })); + await draft.updateComplete; + expect(onPatch).not.toHaveBeenCalled(); + + renderValue(); + await draft.updateComplete; + expect( + expectElement( + draft.querySelector("input[aria-label='Host']"), + "preserved optional host", + ).value, + ).toBe("gateway.local"); + + const port = expectElement( + draft.querySelector("input[aria-label='Port']"), + "optional port", + ); + port.value = "18789"; + port.dispatchEvent(new Event("input", { bubbles: true })); + + expect(onPatch).toHaveBeenCalledTimes(1); + expect(onPatch).toHaveBeenCalledWith(["settings", "connection"], { + host: "gateway.local", + port: 18789, + }); + expect(currentValue).toEqual({ + connection: { host: "gateway.local", port: 18789 }, + }); + expect(container.querySelector("openclaw-config-form-structured-draft")).toBeNull(); + container.remove(); + }); + + it("retains a complete optional object draft when its atomic commit is rejected", async () => { + const onPatch = vi.fn(() => false); + const container = document.createElement("div"); + document.body.append(container); + const schema = { + type: "object", + properties: { + connection: { + type: "object", + required: ["host", "port"], + properties: { + host: { type: "string", minLength: 1 }, + port: { type: "integer", minimum: 1 }, + }, + }, + }, + }; + const renderValue = () => { + render( + renderObject( + { + schema, + value: {}, + path: ["settings"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + }, + renderNode, + ), + container, + ); + }; + + renderValue(); + const draft = expectElement( + container.querySelector( + "openclaw-config-form-structured-draft", + ), + "rejected optional object draft", + ); + await draft.updateComplete; + const host = expectElement( + draft.querySelector("input[aria-label='Host']"), + "rejected optional host", + ); + host.value = "gateway.local"; + host.dispatchEvent(new Event("input", { bubbles: true })); + await draft.updateComplete; + const port = expectElement( + draft.querySelector("input[aria-label='Port']"), + "rejected optional port", + ); + port.value = "18789"; + port.dispatchEvent(new Event("input", { bubbles: true })); + await draft.updateComplete; + + expect(onPatch).toHaveBeenCalledTimes(1); + expect(onPatch).toHaveBeenCalledWith(["settings", "connection"], { + host: "gateway.local", + port: 18789, + }); + expect( + expectElement( + draft.querySelector("input[aria-label='Host']"), + "retained rejected host", + ).value, + ).toBe("gateway.local"); + expect( + expectElement( + draft.querySelector("input[aria-label='Port']"), + "retained rejected port", + ).value, + ).toBe("18789"); + expect(draft.querySelector("[role='alert']")?.textContent).toContain( + "draft is still here", + ); + + renderValue(); + await draft.updateComplete; + expect( + expectElement( + draft.querySelector("input[aria-label='Host']"), + "rerendered rejected host", + ).value, + ).toBe("gateway.local"); + expect(draft.querySelector("[role='alert']")?.textContent).toContain( + "draft is still here", + ); + container.remove(); + }); + + it("constructs an optional large-minimum array without leaking partial values", async () => { + const onPatch = vi.fn((_path: Array, _value: unknown) => false); + const container = document.createElement("div"); + document.body.append(container); + const schema = { + type: "object", + properties: { + codes: { + type: "array", + minItems: 101, + maxItems: 101, + items: { type: "string" }, + }, + }, + }; + const renderValue = () => { + render( + renderObject( + { + schema, + value: {}, + path: ["settings"], + hints: {}, + unsupported: new Set(), + disabled: false, + onPatch, + }, + renderNode, + ), + container, + ); + }; + const add = (draft: ConfigFormStructuredDraftElement) => + expectElement( + Array.from(draft.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add", + ), + "large-minimum array add", + ); + + renderValue(); + const draft = expectElement( + container.querySelector( + "openclaw-config-form-structured-draft", + ), + "large-minimum array draft", + ); + await draft.updateComplete; + add(draft).click(); + await draft.updateComplete; + expect(onPatch).not.toHaveBeenCalled(); + expect(draft.textContent).toContain("1 item"); + + renderValue(); + await draft.updateComplete; + expect(draft.textContent).toContain("1 item"); + + add(draft).click(); + await draft.updateComplete; + expect(onPatch).toHaveBeenCalledTimes(1); + const [path, value] = onPatch.mock.calls[0] ?? []; + expect(path).toEqual(["settings", "codes"]); + expect(value).toEqual(Array.from({ length: 101 }, () => "")); + expect(draft.textContent).toContain("101 items"); + expect(draft.querySelector("[role='alert']")?.textContent).toContain( + "draft is still here", + ); + + renderValue(); + await draft.updateComplete; + expect(draft.textContent).toContain("101 items"); + expect(draft.querySelector("[role='alert']")?.textContent).toContain( + "draft is still here", + ); + container.remove(); + }); +}); diff --git a/ui/src/components/config-form-scalar-integrity.browser.test.ts b/ui/src/components/config-form-scalar-integrity.browser.test.ts new file mode 100644 index 000000000000..d6d99df9941e --- /dev/null +++ b/ui/src/components/config-form-scalar-integrity.browser.test.ts @@ -0,0 +1,148 @@ +// Control UI tests cover scalar identity and nullable enum behavior. +import { render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import { renderNumberInput, renderSelect } from "./config-form.node.scalar.ts"; + +function expectElement(element: T | null | undefined, label: string): T { + expect(element instanceof Element, label).toBe(true); + if (!(element instanceof Element)) { + throw new Error(`missing ${label}`); + } + return element; +} + +describe("config form scalar integrity", () => { + it("keeps repeated number input identity arguments aligned", () => { + const container = document.createElement("div"); + const renderValue = (controlIdentity: number[]) => { + render( + renderNumberInput({ + schema: { type: "integer" }, + value: 2, + path: ["values", 0], + hints: {}, + unsupported: new Set(), + disabled: false, + sourceIdentity: 2, + controlIdentity, + onPatch: vi.fn(), + }), + container, + ); + }; + + renderValue([2]); + const input = expectElement( + container.querySelector("input[type='number']"), + "repeated number input", + ); + renderValue([2, 4]); + expect(container.querySelector("input[type='number']")).toBe(input); + expect(input.value).toBe("2"); + expect(input.getAttribute("aria-invalid")).toBe("false"); + }); + + it("allows required nullable enums to select their null member", () => { + const container = document.createElement("div"); + const nullablePatch = vi.fn(); + render( + renderSelect({ + schema: { + type: "string", + nullable: true, + enumIncludesNull: true, + }, + value: "fixed", + path: ["nullableMode"], + hints: {}, + unsupported: new Set(), + disabled: false, + isRequired: true, + options: ["fixed", "other"], + onPatch: nullablePatch, + }), + container, + ); + const nullableSelect = expectElement( + container.querySelector("select"), + "required nullable enum", + ); + const nullOption = expectElement( + nullableSelect.querySelector("option[value='__null__']"), + "nullable enum null option", + ); + expect(nullOption.disabled).toBe(false); + expect( + nullableSelect.querySelector("option[value='__unset__']")?.disabled, + ).toBe(true); + nullableSelect.value = "__null__"; + nullableSelect.dispatchEvent(new Event("change", { bubbles: true })); + expect(nullablePatch).toHaveBeenCalledWith(["nullableMode"], null); + + const requiredPatch = vi.fn(); + render( + renderSelect({ + schema: { type: "string" }, + value: "fixed", + path: ["requiredMode"], + hints: {}, + unsupported: new Set(), + disabled: false, + isRequired: true, + options: ["fixed", "other"], + onPatch: requiredPatch, + }), + container, + ); + const requiredSelect = expectElement( + container.querySelector("select"), + "required non-null enum", + ); + expect( + requiredSelect.querySelector("option[value='__unset__']")?.disabled, + ).toBe(true); + requiredSelect.value = "__unset__"; + requiredSelect.dispatchEvent(new Event("change", { bubbles: true })); + expect(requiredSelect.value).toBe("0"); + expect(requiredPatch).not.toHaveBeenCalled(); + }); + + it("keeps optional nullable enum unset distinct from explicit null", () => { + const container = document.createElement("div"); + const onPatch = vi.fn(); + const renderValue = (value: unknown) => { + render( + renderSelect({ + schema: { + type: "string", + nullable: true, + enumIncludesNull: true, + }, + value, + path: ["mode"], + hints: {}, + unsupported: new Set(), + disabled: false, + options: ["fixed", "other"], + onPatch, + }), + container, + ); + }; + + renderValue(null); + const select = expectElement( + container.querySelector("select"), + "optional nullable enum", + ); + expect(select.value).toBe("__null__"); + select.value = "__unset__"; + select.dispatchEvent(new Event("change", { bubbles: true })); + expect(onPatch).toHaveBeenLastCalledWith(["mode"], undefined); + + renderValue("fixed"); + select.value = "__null__"; + select.dispatchEvent(new Event("change", { bubbles: true })); + expect(onPatch).toHaveBeenLastCalledWith(["mode"], null); + }); +}); diff --git a/ui/src/components/config-form-structured-draft.ts b/ui/src/components/config-form-structured-draft.ts new file mode 100644 index 000000000000..65e8afef9fdb --- /dev/null +++ b/ui/src/components/config-form-structured-draft.ts @@ -0,0 +1,152 @@ +import { html, nothing, type PropertyValues } from "lit"; +import { property, state } from "lit/decorators.js"; +import { t } from "../i18n/index.ts"; +import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; +import { copyWithPathPatch } from "./config-form-copy-on-write.ts"; +import { isSupportedConfigValueValid } from "./config-form.constraints.ts"; +import type { ConfigNodeRenderer, ConfigNodeRenderParams } from "./config-form.node.shared.ts"; +import { configFieldId, schemaType } from "./config-form.shared.ts"; + +export type ConfigFormStructuredDraftProps = { + identity: string; + sourceIdentity: unknown; + initialValue: Record | unknown[]; + params: ConfigNodeRenderParams; + renderNode: ConfigNodeRenderer; +}; + +function cloneDraftValue(value: Record | unknown[]) { + return structuredClone(value); +} + +export function structuredDraftInitialValue( + params: ConfigNodeRenderParams, +): Record | unknown[] | undefined { + const type = schemaType(params.schema); + if (type !== "object" && type !== "array") { + return undefined; + } + const schemaDefault = params.schema.default; + if ( + (type === "object" && + schemaDefault && + typeof schemaDefault === "object" && + !Array.isArray(schemaDefault)) || + (type === "array" && Array.isArray(schemaDefault)) + ) { + return cloneDraftValue(schemaDefault as Record | unknown[]); + } + return type === "object" ? {} : []; +} + +export function shouldStageStructuredDraft( + params: ConfigNodeRenderParams, + initialValue: Record | unknown[] | undefined, +): initialValue is Record | unknown[] { + return ( + initialValue !== undefined && + params.value === undefined && + params.isRequired !== true && + params.structuredDraftOwner !== true && + !isSupportedConfigValueValid(params.schema, initialValue) + ); +} + +class ConfigFormStructuredDraft extends OpenClawLightDomElement { + @property({ attribute: false }) props?: ConfigFormStructuredDraftProps; + + @state() private draftValue: Record | unknown[] | undefined; + @state() private error = ""; + + protected override willUpdate(changedProperties: PropertyValues): void { + if (!changedProperties.has("props")) { + return; + } + const previous = changedProperties.get("props") as ConfigFormStructuredDraftProps | undefined; + const next = this.props; + if ( + next && + (!previous || + previous.identity !== next.identity || + !Object.is(previous.sourceIdentity, next.sourceIdentity)) + ) { + this.draftValue = cloneDraftValue(next.initialValue); + this.error = ""; + } + } + + private patchDraft(path: Array, value: unknown): boolean { + const props = this.props; + const current = this.draftValue; + if (!props || !current) { + return false; + } + const rootPath = props.params.path; + if ( + path.length < rootPath.length || + !rootPath.every((segment, index) => segment === path[index]) + ) { + return false; + } + const relativePath = path.slice(rootPath.length); + const patched = + relativePath.length === 0 + ? { ok: true as const, value } + : copyWithPathPatch(current, relativePath, value); + if (!patched.ok) { + return false; + } + const candidate = patched.value; + const type = schemaType(props.params.schema); + if ( + (type === "object" && + (!candidate || typeof candidate !== "object" || Array.isArray(candidate))) || + (type === "array" && !Array.isArray(candidate)) + ) { + return false; + } + + this.draftValue = candidate as Record | unknown[]; + this.error = ""; + if (!isSupportedConfigValueValid(props.params.schema, candidate)) { + return true; + } + if (props.params.onPatch(rootPath, candidate) !== false) { + return true; + } + this.error = t("configForm.draftRejected"); + return false; + } + + override render() { + const props = this.props; + const draftValue = this.draftValue; + if (!props || !draftValue) { + return nothing; + } + const errorId = configFieldId(props.params.path, "structured-draft-error"); + return html` + ${props.renderNode({ + ...props.params, + value: draftValue, + sourceIdentity: draftValue, + controlIdentity: draftValue, + structuredDraftOwner: true, + onPatch: (path, value) => this.patchDraft(path, value), + })} + ${this.error + ? html` +
+
+ ${this.error} +
+
+ ` + : nothing} + `; + } +} + +if (!customElements.get("openclaw-config-form-structured-draft")) { + customElements.define("openclaw-config-form-structured-draft", ConfigFormStructuredDraft); +} diff --git a/ui/src/components/config-form.analyze.ts b/ui/src/components/config-form.analyze.ts index 124d8e5e6f9d..edc0a7dacd07 100644 --- a/ui/src/components/config-form.analyze.ts +++ b/ui/src/components/config-form.analyze.ts @@ -1,4 +1,11 @@ // Control UI view renders config form.analyze screen content. +import { + arrayItemSchema, + objectAdditionalPropertiesSchema, + objectPropertyKeys, + objectPropertySchema, + requiredPropertyKeys, +} from "./config-form.constraints.ts"; import { pathKey, schemaType, type JsonSchema } from "./config-form.shared.ts"; export type ConfigSchemaAnalysis = { @@ -6,7 +13,49 @@ export type ConfigSchemaAnalysis = { unsupportedPaths: string[]; }; -const META_KEYS = new Set(["title", "description", "default", "nullable", "tags", "x-tags"]); +const META_KEYS = new Set([ + "$id", + "$schema", + "title", + "description", + "default", + "deprecated", + "nullable", + "enumIncludesNull", + "examples", + "readOnly", + "tags", + "writeOnly", + "x-tags", +]); +const SUPPORTED_CONSTRAINT_ONLY_KEYS = new Set([ + ...META_KEYS, + "const", + "required", + "additionalProperties", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "minLength", + "maxLength", + "pattern", + "minItems", + "maxItems", + "uniqueItems", +]); +const SUPPORTED_FORM_SCHEMA_KEYS = new Set([ + ...SUPPORTED_CONSTRAINT_ONLY_KEYS, + "type", + "properties", + "items", + "additionalItems", + "enum", + "anyOf", + "oneOf", + "allOf", +]); const RENDERABLE_UNION_TYPES = new Set([ "string", "number", @@ -37,6 +86,147 @@ function uniqueValues(values: unknown[]): unknown[] { return unique; } +function inferredSchemaTypes(schema: JsonSchema, seen = new Set()): Set { + if (seen.has(schema)) { + return new Set(); + } + seen.add(schema); + const types = new Set(); + const declared = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : []; + for (const type of declared) { + if (type !== "null") { + types.add(type); + } + } + if (types.size === 0) { + if (schema.properties || schema.additionalProperties) { + types.add("object"); + } + } + for (const entry of schema.allOf ?? []) { + for (const type of inferredSchemaTypes(entry, seen)) { + types.add(type); + } + } + seen.delete(schema); + return types; +} + +function intersectedSchemaType(types: ReadonlySet): string | undefined { + if (types.size === 1) { + return types.values().next().value; + } + if (types.size > 1 && [...types].every((type) => type === "number" || type === "integer")) { + return types.has("integer") ? "integer" : "number"; + } + return undefined; +} + +function hasIncompatibleTypes(types: ReadonlySet): boolean { + return types.size > 1 && intersectedSchemaType(types) === undefined; +} + +function inferredSchemaType(schema: JsonSchema): string | undefined { + return intersectedSchemaType(inferredSchemaTypes(schema)); +} + +function shouldNormalizeAllOfBranch(schema: JsonSchema): boolean { + return Boolean( + inferredSchemaType(schema) || + schema.items || + schema.enum || + schema.anyOf || + schema.oneOf || + schema.allOf, + ); +} + +function hasOnlySupportedConstraintKeywords(schema: JsonSchema): boolean { + return Object.keys(schema).every((key) => SUPPORTED_CONSTRAINT_ONLY_KEYS.has(key)); +} + +function hasOnlySupportedFormKeywords(schema: JsonSchema): boolean { + return Object.keys(schema).every((key) => SUPPORTED_FORM_SCHEMA_KEYS.has(key)); +} + +function schemaAllowsNull(schema: JsonSchema, seen = new Set()): boolean { + if (seen.has(schema)) { + return false; + } + seen.add(schema); + const declaredTypes = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : []; + let allowsNull = + schema.nullable === true || declaredTypes.length === 0 || declaredTypes.includes("null"); + if (schema.const !== undefined) { + allowsNull = allowsNull && schema.const === null; + } + if (schema.enum) { + allowsNull = allowsNull && schema.enum.some((entry) => entry === null); + } + if (schema.allOf) { + allowsNull = allowsNull && schema.allOf.every((entry) => schemaAllowsNull(entry, seen)); + } + if (schema.anyOf) { + allowsNull = allowsNull && schema.anyOf.some((entry) => schemaAllowsNull(entry, seen)); + } + if (schema.oneOf) { + allowsNull = + allowsNull && schema.oneOf.filter((entry) => schemaAllowsNull(entry, seen)).length === 1; + } + seen.delete(schema); + return allowsNull; +} + +function effectiveArrayItemIndexes(schema: JsonSchema): number[] { + const pending = [schema]; + const seen = new Set(); + let maxTupleLength = 0; + let hasRepeatedItems = false; + while (pending.length > 0) { + const current = pending.pop(); + if (!current || seen.has(current)) { + continue; + } + seen.add(current); + if (Array.isArray(current.items)) { + maxTupleLength = Math.max(maxTupleLength, current.items.length); + } else if (current.items) { + hasRepeatedItems = true; + } + pending.push(...(current.allOf ?? [])); + } + const count = Math.max(maxTupleLength, hasRepeatedItems ? 1 : 0); + return Array.from({ length: count }, (_, index) => index); +} + +function hasUnrepresentableComposedAdditionalProperties(schema: JsonSchema): boolean { + const schemas: JsonSchema[] = []; + const pending = [schema]; + const seen = new Set(); + while (pending.length > 0) { + const current = pending.pop(); + if (!current || seen.has(current)) { + continue; + } + seen.add(current); + schemas.push(current); + pending.push(...(current.allOf ?? [])); + } + if (schemas.length <= 1) { + return false; + } + const propertyKeys = new Set(schemas.flatMap((entry) => Object.keys(entry.properties ?? {}))); + return schemas.some((entry) => { + const policy = entry.additionalProperties; + return ( + Boolean(policy) && + typeof policy === "object" && + Object.keys(policy).length > 0 && + [...propertyKeys].some((key) => !Object.hasOwn(entry.properties ?? {}, key)) + ); + }); +} + export function analyzeConfigSchema(raw: unknown): ConfigSchemaAnalysis { if (!raw || typeof raw !== "object") { return { schema: null, unsupportedPaths: [""] }; @@ -47,41 +237,121 @@ export function analyzeConfigSchema(raw: unknown): ConfigSchemaAnalysis { function normalizeSchemaNode( schema: JsonSchema, path: Array, + compositionBranch = false, + inheritedCompositionType?: string, + inheritedCompositionAllowsNull?: boolean, ): ConfigSchemaAnalysis { const unsupported = new Set(); const normalized: JsonSchema = { ...schema }; const pathLabel = pathKey(path) || ""; - if (schema.anyOf || schema.oneOf || schema.allOf) { + if (!hasOnlySupportedFormKeywords(schema)) { + unsupported.add(pathLabel); + } + + if (schema.anyOf || schema.oneOf) { const union = normalizeUnion(schema, path); if (union) { - return union; + return { + schema: union.schema, + unsupportedPaths: Array.from(new Set([...unsupported, ...union.unsupportedPaths])), + }; } return { schema, unsupportedPaths: [pathLabel] }; } - const nullable = Array.isArray(schema.type) && schema.type.includes("null"); - const type = - schemaType(schema) ?? (schema.properties || schema.additionalProperties ? "object" : undefined); + const declaredTypes = Array.isArray(schema.type) + ? schema.type.filter((entry) => entry !== "null") + : []; + const inferredTypes = inferredSchemaTypes(schema); + const inheritedCompositionOnly = + compositionBranch && + Boolean(inheritedCompositionType) && + schema.type === undefined && + inferredTypes.size === 0; + if (inheritedCompositionOnly && inheritedCompositionType) { + inferredTypes.add(inheritedCompositionType); + } + if ( + compositionBranch && + inheritedCompositionType && + inferredTypes.size > 0 && + hasIncompatibleTypes(new Set([...inferredTypes, inheritedCompositionType])) + ) { + unsupported.add(pathLabel); + } + if (new Set(declaredTypes).size > 1 || hasIncompatibleTypes(inferredTypes)) { + unsupported.add(pathLabel); + } + const type = intersectedSchemaType(inferredTypes); + const allowsNull = + schemaAllowsNull(schema) && + (inheritedCompositionAllowsNull === undefined || inheritedCompositionAllowsNull); + + if (schema.allOf) { + const normalizedAllOf: JsonSchema[] = []; + for (const entry of schema.allOf) { + if (!entry || typeof entry !== "object") { + unsupported.add(pathLabel); + continue; + } + if (!shouldNormalizeAllOfBranch(entry)) { + normalizedAllOf.push(entry); + if (!hasOnlySupportedConstraintKeywords(entry)) { + unsupported.add(pathLabel); + } + continue; + } + const result = normalizeSchemaNode(entry, path, true, type, allowsNull); + normalizedAllOf.push(result.schema ?? entry); + for (const unsupportedPath of result.unsupportedPaths) { + unsupported.add(unsupportedPath); + } + } + normalized.allOf = normalizedAllOf; + } + normalized.type = type ?? schema.type; - normalized.nullable = nullable || schema.nullable; + normalized.nullable = allowsNull; + const hasLocalObjectStructure = + schema.properties !== undefined || schema.additionalProperties !== undefined; + const hasLocalArrayStructure = schema.items !== undefined || schema.additionalItems !== undefined; if (normalized.enum) { const { enumValues, nullable: enumNullable } = normalizeEnum(normalized.enum); normalized.enum = enumValues; - if (enumNullable) { - normalized.nullable = true; - } + normalized.enumIncludesNull = enumNullable && allowsNull; if (enumValues.length === 0) { unsupported.add(pathLabel); } } + if (schema.allOf && allowsNull && !normalized.enumIncludesNull) { + unsupported.add(pathLabel); + } - if (type === "object") { + if (type === "object" && (!inheritedCompositionOnly || hasLocalObjectStructure)) { const properties = schema.properties ?? {}; + const propertyKeys = new Set(objectPropertyKeys(schema)); + const additionalProperties = objectAdditionalPropertiesSchema(schema); + if ( + [...requiredPropertyKeys(schema)].some((key) => !propertyKeys.has(key)) && + !additionalProperties + ) { + unsupported.add(pathLabel); + } + if (hasUnrepresentableComposedAdditionalProperties(schema)) { + unsupported.add(pathLabel); + } const normalizedProps: Record = {}; for (const [key, value] of Object.entries(properties)) { - const res = normalizeSchemaNode(value, [...path, key]); + if (compositionBranch && !shouldNormalizeAllOfBranch(value)) { + normalizedProps[key] = value; + if (!hasOnlySupportedConstraintKeywords(value)) { + unsupported.add(pathKey([...path, key]) || ""); + } + continue; + } + const res = normalizeSchemaNode(value, [...path, key], compositionBranch); if (res.schema) { normalizedProps[key] = res.schema; } @@ -91,6 +361,19 @@ function normalizeSchemaNode( } normalized.properties = normalizedProps; + if (schema.allOf) { + for (const key of objectPropertyKeys(schema)) { + const effectiveSchema = objectPropertySchema(schema, key); + if (!effectiveSchema) { + continue; + } + const result = normalizeSchemaNode(effectiveSchema, [...path, key]); + for (const unsupportedPath of result.unsupportedPaths) { + unsupported.add(unsupportedPath); + } + } + } + if (schema.additionalProperties === true) { // Treat `true` as an untyped map schema so dynamic object keys can still be edited. normalized.additionalProperties = {}; @@ -98,30 +381,96 @@ function normalizeSchemaNode( normalized.additionalProperties = false; } else if (schema.additionalProperties && typeof schema.additionalProperties === "object") { if (!isAnySchema(schema.additionalProperties)) { - const res = normalizeSchemaNode(schema.additionalProperties, [...path, "*"]); + const res = normalizeSchemaNode( + schema.additionalProperties, + [...path, "*"], + compositionBranch, + ); normalized.additionalProperties = res.schema ?? schema.additionalProperties; if (res.unsupportedPaths.length > 0) { unsupported.add(pathLabel); } } } - } else if (type === "array") { - const itemsSchema = Array.isArray(schema.items) ? schema.items[0] : schema.items; - if (!itemsSchema) { + } else if (type === "array" && (!inheritedCompositionOnly || hasLocalArrayStructure)) { + if (Array.isArray(schema.items)) { + const normalizedItems: JsonSchema[] = []; + for (let index = 0; index < schema.items.length; index += 1) { + const itemSchema = schema.items[index]; + if (!itemSchema) { + unsupported.add(pathLabel); + continue; + } + if (compositionBranch && !shouldNormalizeAllOfBranch(itemSchema)) { + normalizedItems.push(itemSchema); + if (!hasOnlySupportedConstraintKeywords(itemSchema)) { + unsupported.add(pathLabel); + } + continue; + } + const result = normalizeSchemaNode(itemSchema, [...path, index], compositionBranch); + normalizedItems.push(result.schema ?? itemSchema); + for (const unsupportedPath of result.unsupportedPaths) { + unsupported.add(unsupportedPath); + } + } + normalized.items = normalizedItems; + if (schema.additionalItems && typeof schema.additionalItems === "object") { + if (compositionBranch && !shouldNormalizeAllOfBranch(schema.additionalItems)) { + normalized.additionalItems = schema.additionalItems; + if (!hasOnlySupportedConstraintKeywords(schema.additionalItems)) { + unsupported.add(pathLabel); + } + } else { + const result = normalizeSchemaNode( + schema.additionalItems, + [...path, "*"], + compositionBranch, + ); + normalized.additionalItems = result.schema ?? schema.additionalItems; + for (const unsupportedPath of result.unsupportedPaths) { + unsupported.add(unsupportedPath); + } + } + } else { + normalized.additionalItems = schema.additionalItems; + } + } else if (!schema.items) { unsupported.add(pathLabel); } else { - const res = normalizeSchemaNode(itemsSchema, [...path, "*"]); - normalized.items = res.schema ?? itemsSchema; - if (res.unsupportedPaths.length > 0) { - unsupported.add(pathLabel); + if (compositionBranch && !shouldNormalizeAllOfBranch(schema.items)) { + normalized.items = schema.items; + if (!hasOnlySupportedConstraintKeywords(schema.items)) { + unsupported.add(pathLabel); + } + } else { + const res = normalizeSchemaNode(schema.items, [...path, "*"], compositionBranch); + normalized.items = res.schema ?? schema.items; + if (res.unsupportedPaths.length > 0) { + unsupported.add(pathLabel); + } + } + } + if (schema.allOf) { + for (const index of effectiveArrayItemIndexes(schema)) { + const effectiveSchema = arrayItemSchema(schema, index); + if (!effectiveSchema) { + continue; + } + const result = normalizeSchemaNode(effectiveSchema, [...path, index]); + for (const unsupportedPath of result.unsupportedPaths) { + unsupported.add(unsupportedPath); + } } } } else if ( + !(inheritedCompositionOnly && (type === "object" || type === "array")) && type !== "string" && type !== "number" && type !== "integer" && type !== "boolean" && - !normalized.enum + !normalized.enum && + !(compositionBranch && schema.allOf) ) { unsupported.add(pathLabel); } @@ -193,6 +542,8 @@ function normalizeUnion( schema: JsonSchema, path: Array, ): ConfigSchemaAnalysis | null { + // Union normalization replaces the composition keywords, so mixed allOf schemas + // must stay unsupported instead of silently losing sibling restrictions. if (schema.allOf) { return null; } @@ -231,6 +582,7 @@ function normalizeUnion( } remaining.push(entry); } + nullable = nullable && schemaAllowsNull(schema); // Config secrets accept either a raw key string or a structured secret ref object. // The form only supports editing the string path for now. @@ -245,6 +597,7 @@ function normalizeUnion( ...schema, enum: uniqueValues(literals), nullable, + enumIncludesNull: nullable, anyOf: undefined, oneOf: undefined, allOf: undefined, diff --git a/ui/src/components/config-form.constraints.test.ts b/ui/src/components/config-form.constraints.test.ts new file mode 100644 index 000000000000..dd54b60c7f15 --- /dev/null +++ b/ui/src/components/config-form.constraints.test.ts @@ -0,0 +1,456 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { + arrayInputConstraints, + canApplyArrayCandidate, + coerceConfigFormNumberString, + configValuesEqual, + defaultValue, + isSupportedConfigValueValid, + NO_SAFE_DEFAULT, + normalizeNumericValue, + numericInputConstraints, + objectPropertyKeys, + objectPropertySchema, + requiredPropertyKeys, +} from "./config-form.constraints.ts"; +import type { JsonSchema } from "./config-form.shared.ts"; +import { decimalRational } from "./config-form.validation.ts"; + +describe("config form schema constraints", () => { + it("coerces only decimal and scientific config number spellings", () => { + expect(coerceConfigFormNumberString("42.5", false)).toBe(42.5); + expect(coerceConfigFormNumberString(".5e2", false)).toBe(50); + expect(coerceConfigFormNumberString("-2.5E-3", false)).toBe(-0.0025); + expect(coerceConfigFormNumberString("1e5", true)).toBe(100_000); + expect(coerceConfigFormNumberString("", false)).toBeUndefined(); + + for (const spelling of [ + "0x10", + "0b1010", + "0o17", + "+5", + "1_000", + "Infinity", + "NaN", + "1e", + "e5", + ]) { + expect(coerceConfigFormNumberString(spelling, false)).toBe(spelling); + } + expect(coerceConfigFormNumberString("42.5", true)).toBe("42.5"); + }); + + it("rejects non-finite decimal rationals and schema multiples", () => { + expect(decimalRational(Number.NaN)).toBeUndefined(); + expect(decimalRational(Number.POSITIVE_INFINITY)).toBeUndefined(); + expect(numericInputConstraints({ type: "number", multipleOf: Number.NaN }).step).toBe("any"); + expect( + numericInputConstraints({ type: "integer", multipleOf: Number.POSITIVE_INFINITY }).step, + ).toBe(1); + }); + + it("aligns native numeric bounds to JSON Schema multiples", () => { + const schema = { + type: "integer", + minimum: 1, + maximum: 9, + multipleOf: 2, + }; + expect(numericInputConstraints(schema)).toMatchObject({ min: 2, max: 8, step: 2 }); + expect(normalizeNumericValue(9, schema)).toBe(8); + expect(defaultValue(schema)).toBe(2); + }); + + it("does not let large-number tolerance cross a whole step", () => { + const schema = { + type: "number", + minimum: 10_000_000_000_000_000, + multipleOf: 3, + }; + expect(numericInputConstraints(schema).min).toBe(10_000_000_000_000_002); + expect(defaultValue(schema)).toBe(10_000_000_000_000_002); + expect( + isSupportedConfigValueValid({ type: "number", multipleOf: 3 }, 10_000_000_000_000_000), + ).toBe(false); + expect( + isSupportedConfigValueValid({ type: "number", multipleOf: 3 }, 10_000_000_000_000_002), + ).toBe(true); + + const decimalSchema = { + type: "number", + minimum: 10_000_000_000_000_002, + multipleOf: 10, + }; + expect(numericInputConstraints(decimalSchema).min).toBe(10_000_000_000_000_010); + expect(defaultValue(decimalSchema)).toBe(10_000_000_000_000_010); + expect( + isSupportedConfigValueValid({ type: "number", multipleOf: 10 }, 10_000_000_000_000_002), + ).toBe(false); + expect(isSupportedConfigValueValid({ type: "number", multipleOf: 0.1 }, 0.3)).toBe(true); + expect(isSupportedConfigValueValid({ type: "number", multipleOf: 0.1 }, 0.2 + 0.1)).toBe(false); + expect( + numericInputConstraints({ type: "number", minimum: -10, maximum: -10, multipleOf: 3 }), + ).toMatchObject({ min: -9, max: -12 }); + expect(defaultValue({ type: "number", minimum: -10, maximum: -10, multipleOf: 3 })).toBe( + NO_SAFE_DEFAULT, + ); + }); + + it("converts exclusive integer bounds into reachable native bounds", () => { + const schema = { + type: "integer", + exclusiveMinimum: 2, + exclusiveMaximum: 8, + }; + expect(numericInputConstraints(schema)).toMatchObject({ + min: 3, + max: 7, + exclusiveMin: 2, + exclusiveMax: 8, + step: 1, + }); + }); + + it("preserves decimal step precision", () => { + const schema = { + type: "number", + minimum: 0.1, + maximum: 0.3, + multipleOf: 0.1, + }; + expect(normalizeNumericValue(0.2 + 0.1, schema)).toBe(0.3); + expect( + normalizeNumericValue(3 * 1.5e-7, { + type: "number", + maximum: 6e-7, + multipleOf: 1.5e-7, + }), + ).toBe(4.5e-7); + }); + + it("derives integer-compatible steps from fractional multiples", () => { + expect(numericInputConstraints({ type: "integer", multipleOf: 0.5 }).step).toBe(1); + expect(numericInputConstraints({ type: "integer", multipleOf: 2.5 }).step).toBe(5); + expect(normalizeNumericValue(3, { type: "integer", multipleOf: 2.5 })).toBe(5); + }); + + it("derives valid defaults for exclusive unconstrained bounds", () => { + const schema = { + type: "number", + exclusiveMinimum: 0, + }; + expect(numericInputConstraints(schema)).toMatchObject({ + min: 0, + exclusiveMin: 0, + step: "any", + }); + expect(defaultValue(schema)).toBe(1); + }); + + it("normalizes one-sided exclusive bounds without jumping across the range", () => { + const belowMaximum = normalizeNumericValue(10, { + type: "number", + exclusiveMaximum: 10, + }); + const aboveMinimum = normalizeNumericValue(10, { + type: "number", + exclusiveMinimum: 10, + }); + + expect(belowMaximum).toBeLessThan(10); + expect(belowMaximum).toBeGreaterThan(9); + expect(aboveMinimum).toBeGreaterThan(10); + expect(aboveMinimum).toBeLessThan(11); + }); + + it("derives numeric controls from intersected allOf constraints", () => { + const schema = { + type: "integer", + minimum: 1, + allOf: [{ maximum: 13 }, { multipleOf: 2 }, { multipleOf: 3 }], + }; + expect(numericInputConstraints(schema)).toMatchObject({ min: 6, max: 12, step: 6 }); + expect(normalizeNumericValue(7, schema)).toBe(6); + expect( + numericInputConstraints({ + type: "number", + allOf: [{ type: ["integer", "number"] }], + }).step, + ).toBe("any"); + expect( + normalizeNumericValue(1.5, { + type: "number", + allOf: [{ type: ["integer", "number"] }], + }), + ).toBe(1.5); + }); + + it("derives array controls from intersected allOf constraints", () => { + expect( + arrayInputConstraints({ + type: "array", + allOf: [ + { minItems: 1, maxItems: 4 }, + { minItems: 2, maxItems: 3, uniqueItems: true }, + ], + }), + ).toEqual({ minItems: 2, maxItems: 3, uniqueItems: true }); + expect( + arrayInputConstraints({ + type: "array", + items: [{ type: "string" }, { type: "number" }], + additionalItems: false, + }), + ).toEqual({ minItems: 0, maxItems: 2, uniqueItems: false }); + + let deeplyNested: JsonSchema = { maxItems: 0 }; + for (let depth = 0; depth < 40; depth += 1) { + deeplyNested = { allOf: [deeplyNested] }; + } + expect( + arrayInputConstraints({ + type: "array", + allOf: [deeplyNested], + }).maxItems, + ).toBe(0); + }); + + it("allows removals that move arrays toward whole-array constraints", () => { + const schema = { + type: "array", + items: { type: "string" }, + const: ["a"], + } satisfies JsonSchema; + + expect(canApplyArrayCandidate(schema, ["a", "x", "y"], ["a", "x"], false, false)).toBe(true); + expect(canApplyArrayCandidate(schema, ["a", "x"], ["a"], false, false)).toBe(true); + expect(canApplyArrayCandidate(schema, ["a", "x", "y"], ["x", "y"], false, false)).toBe(false); + }); + + it("validates and compares deeply nested config values without depth cutoffs", () => { + let deeplyComposed: JsonSchema = { type: "string", minLength: 2 }; + for (let depth = 0; depth < 40; depth += 1) { + deeplyComposed = { allOf: [deeplyComposed] }; + } + expect(isSupportedConfigValueValid(deeplyComposed, "ok")).toBe(true); + expect(isSupportedConfigValueValid(deeplyComposed, "x")).toBe(false); + expect(isSupportedConfigValueValid({ type: "string", nullable: true }, null)).toBe(true); + expect( + isSupportedConfigValueValid({ type: "string", nullable: true, const: "fixed" }, null), + ).toBe(false); + expect( + isSupportedConfigValueValid( + { nullable: true, allOf: [{ type: "string", const: "fixed" }] }, + null, + ), + ).toBe(false); + expect(isSupportedConfigValueValid({ nullable: true, enum: ["fixed"] }, null)).toBe(false); + expect( + isSupportedConfigValueValid( + { nullable: true, enum: ["fixed"], enumIncludesNull: true }, + null, + ), + ).toBe(true); + expect(defaultValue({ type: "string", nullable: true, default: null })).toBeNull(); + + let deeplyNested: Record = { value: "same" }; + for (let depth = 0; depth < 40; depth += 1) { + deeplyNested = { child: deeplyNested }; + } + const duplicate = structuredClone(deeplyNested); + expect(configValuesEqual(deeplyNested, duplicate)).toBe(true); + expect( + isSupportedConfigValueValid( + { + type: "array", + uniqueItems: true, + }, + [deeplyNested, duplicate], + ), + ).toBe(false); + + const selfCycle: Record = {}; + selfCycle.next = selfCycle; + const first: Record = {}; + const second: Record = {}; + first.next = second; + second.next = first; + expect(configValuesEqual(selfCycle, selfCycle)).toBe(false); + expect(configValuesEqual(selfCycle, first)).toBe(false); + }); + + it("collects required object properties from nested allOf schemas", () => { + const schema = { + type: "object", + required: ["direct"], + properties: { count: { type: "integer" } }, + allOf: [ + { + required: ["composed"], + properties: { count: { minimum: 2 } }, + allOf: [{ required: ["nested"] }], + }, + ], + }; + expect(requiredPropertyKeys(schema)).toEqual(new Set(["direct", "composed", "nested"])); + const countSchema = objectPropertySchema(schema, "count"); + expect(countSchema).toBeDefined(); + expect(countSchema && isSupportedConfigValueValid(countSchema, 1)).toBe(false); + expect(countSchema && isSupportedConfigValueValid(countSchema, 2)).toBe(true); + }); + + it("respects branch-local additional-properties scopes for composed properties", () => { + const forbidden = { + type: "object", + additionalProperties: false, + allOf: [{ properties: { mode: { type: "string" } } }], + } satisfies JsonSchema; + expect(objectPropertyKeys(forbidden)).toEqual([]); + expect(objectPropertySchema(forbidden, "mode")).toBeUndefined(); + + const declared = { + type: "object", + properties: { mode: { type: "string" } }, + additionalProperties: false, + allOf: [{ properties: { mode: { minLength: 2 } } }], + } satisfies JsonSchema; + expect(objectPropertyKeys(declared)).toEqual(["mode"]); + expect(objectPropertySchema(declared, "mode")).toMatchObject({ + type: "string", + allOf: [{ minLength: 2 }], + }); + }); + + it("derives only defaults that the schema can prove", () => { + expect( + defaultValue({ + type: "object", + required: ["mode", "weights"], + properties: { + mode: { type: "string", enum: ["safe", "fast"] }, + weights: { + type: "array", + minItems: 2, + items: { type: "integer", minimum: 2 }, + }, + }, + }), + ).toEqual({ mode: "safe", weights: [2, 2] }); + expect(defaultValue({ type: "string", minLength: 3 })).toBe("xxx"); + expect(defaultValue({ type: "string", minLength: 3, pattern: "^[0-9]+$" })).toBe( + NO_SAFE_DEFAULT, + ); + expect(defaultValue({ type: "string", enum: ["x", ""], pattern: "^$" })).toBe(""); + expect(defaultValue({ type: "integer", enum: [1, 4], minimum: 2, multipleOf: 2 })).toBe(4); + expect(defaultValue({ type: "string", enum: ["x"], pattern: "^$" })).toBe(NO_SAFE_DEFAULT); + expect(defaultValue({ type: "null" })).toBeNull(); + expect(defaultValue({ type: "string", allOf: [{ minLength: 3 }] })).toBe(NO_SAFE_DEFAULT); + expect( + defaultValue({ + type: "array", + items: { type: "string" }, + allOf: [{ const: ["a", "b"] }], + }), + ).toEqual(["a", "b"]); + expect(defaultValue({ type: "integer", default: 3, multipleOf: 2 })).toBe(NO_SAFE_DEFAULT); + expect(defaultValue({ type: "string", minLength: 1_000_000_000 })).toBe(NO_SAFE_DEFAULT); + expect( + defaultValue({ + type: "array", + minItems: 3, + items: [{ type: "string" }, { type: "integer", minimum: 2 }], + additionalItems: { type: "boolean" }, + }), + ).toEqual(["", 2, false]); + expect( + defaultValue({ + type: "array", + minItems: 3, + items: [{ type: "string" }, { type: "integer" }], + additionalItems: false, + }), + ).toBe(NO_SAFE_DEFAULT); + expect( + defaultValue({ + type: "array", + minItems: 2, + uniqueItems: true, + items: { type: "string" }, + }), + ).toBe(NO_SAFE_DEFAULT); + expect( + defaultValue({ + type: "array", + minItems: 101, + items: { type: "string" }, + }), + ).toBe(NO_SAFE_DEFAULT); + expect( + defaultValue({ + type: "array", + minItems: 101, + items: [{ type: "string" }], + additionalItems: { type: "string" }, + }), + ).toBe(NO_SAFE_DEFAULT); + expect( + isSupportedConfigValueValid( + { + type: "object", + properties: {}, + additionalProperties: false, + }, + { constructor: "inherited-name" }, + ), + ).toBe(false); + expect( + defaultValue({ + type: "object", + required: ["constructor"], + properties: {}, + additionalProperties: false, + }), + ).toBe(NO_SAFE_DEFAULT); + }); + + it("clones mutable schema defaults and repeated derived defaults", () => { + const expectIndependentCandidates = ( + schema: JsonSchema, + source: { nested: { enabled: boolean } }, + ) => { + const first = defaultValue(schema) as typeof source; + const second = defaultValue(schema) as typeof source; + + expect(first).not.toBe(source); + expect(second).not.toBe(first); + first.nested.enabled = false; + expect(second.nested.enabled).toBe(true); + expect(source.nested.enabled).toBe(true); + }; + const explicitDefault = { nested: { enabled: true } }; + expectIndependentCandidates({ type: "object", default: explicitDefault }, explicitDefault); + const constant = { nested: { enabled: true } }; + expectIndependentCandidates({ type: "object", const: constant }, constant); + const enumerated = { nested: { enabled: true } }; + expectIndependentCandidates({ type: "object", enum: [enumerated] }, enumerated); + + const repeated = defaultValue({ + type: "array", + minItems: 2, + items: { + type: "object", + required: ["tags"], + properties: { + tags: { type: "array", default: ["primary"] }, + }, + }, + }) as Array<{ tags: string[] }>; + + expect(repeated).toHaveLength(2); + expect(repeated[0]).not.toBe(repeated[1]); + expect(repeated[0]?.tags).not.toBe(repeated[1]?.tags); + repeated[0]?.tags.push("changed"); + expect(repeated[1]?.tags).toEqual(["primary"]); + }); +}); diff --git a/ui/src/components/config-form.constraints.ts b/ui/src/components/config-form.constraints.ts new file mode 100644 index 000000000000..081810690ac0 --- /dev/null +++ b/ui/src/components/config-form.constraints.ts @@ -0,0 +1,728 @@ +// Control UI helpers derive native constraints and safe initial values from config schemas. +import { schemaType, type JsonSchema } from "./config-form.shared.ts"; +import { + configValuesEqual, + decimalRational, + isSupportedConfigValueValid, + ownPropertySchema, +} from "./config-form.validation.ts"; + +export { configValuesEqual, isSupportedConfigValueValid } from "./config-form.validation.ts"; + +function finiteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +const CONFIG_FORM_DECIMAL_NUMBER_RE = /^-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/u; + +export function coerceConfigFormNumberString( + value: string, + integer: boolean, +): number | undefined | string { + const trimmed = value.trim(); + if (trimmed === "") { + return undefined; + } + if (!CONFIG_FORM_DECIMAL_NUMBER_RE.test(trimmed)) { + return value; + } + const parsed = Number(trimmed); + if (!Number.isFinite(parsed) || (integer && !Number.isInteger(parsed))) { + return value; + } + return parsed; +} + +function decimalPlaces(value: number): number { + const text = String(value).toLowerCase(); + const [coefficient = "", exponentText] = text.split("e"); + const fractionLength = coefficient.split(".")[1]?.length ?? 0; + const exponent = Number(exponentText ?? 0); + return Math.max(0, fractionLength - exponent); +} + +function normalizePrecision(value: number, step: number | undefined): number { + if (!step) { + return value; + } + const places = decimalPlaces(step); + return places <= 100 ? Number(value.toFixed(places)) : value; +} + +function greatestCommonDivisor(left: bigint, right: bigint): bigint { + let a = left < 0n ? -left : left; + let b = right < 0n ? -right : right; + while (b !== 0n) { + const remainder = a % b; + a = b; + b = remainder; + } + return a; +} + +function leastCommonMultiple(left: bigint, right: bigint): bigint { + return (left / greatestCommonDivisor(left, right)) * right; +} + +function integerCompatibleStep(multipleOf: number): number { + const [coefficient = "", exponentText] = String(multipleOf).toLowerCase().split("e"); + const [whole = "0", fraction = ""] = coefficient.split("."); + const exponent = Number(exponentText ?? 0); + const digits = BigInt(`${whole}${fraction}`); + const denominatorExponent = fraction.length - exponent; + const numerator = denominatorExponent < 0 ? digits * 10n ** BigInt(-denominatorExponent) : digits; + const denominator = denominatorExponent > 0 ? 10n ** BigInt(denominatorExponent) : 1n; + const divisor = greatestCommonDivisor(numerator, denominator); + const step = Number(numerator / divisor); + if (!Number.isFinite(step) || step <= 0) { + return 1; + } + return step; +} + +function alignToStep(value: number, step: number, direction: "ceil" | "floor" | "round"): number { + const valueRational = decimalRational(value); + const stepRational = decimalRational(step); + if (!valueRational || !stepRational || stepRational.numerator === 0n) { + return value; + } + const dividend = valueRational.numerator * stepRational.denominator; + const divisor = valueRational.denominator * stepRational.numerator; + const truncated = dividend / divisor; + const remainder = dividend % divisor; + const floor = remainder < 0n ? truncated - 1n : truncated; + const aligned = + direction === "floor" + ? floor + : direction === "ceil" + ? remainder === 0n + ? truncated + : remainder > 0n + ? truncated + 1n + : truncated + : (dividend - floor * divisor) * 2n < divisor + ? floor + : floor + 1n; + return normalizePrecision(Number(aligned) * step, step); +} + +type NumericInputConstraints = { + min?: number; + max?: number; + exclusiveMin?: number; + exclusiveMax?: number; + step: number | "any"; +}; + +type ArrayInputConstraints = { + minItems: number; + maxItems?: number; + uniqueItems: boolean; +}; + +type EffectiveNumericBound = { + value?: number; + exclusive: boolean; +}; + +function collectAllOfSchemas(schema: JsonSchema): JsonSchema[] { + const result: JsonSchema[] = []; + const pending = [schema]; + const seen = new Set(); + while (pending.length > 0) { + const current = pending.pop(); + if (!current || seen.has(current)) { + continue; + } + seen.add(current); + result.push(current); + for (let index = (current.allOf?.length ?? 0) - 1; index >= 0; index -= 1) { + const entry = current.allOf?.[index]; + if (entry) { + pending.push(entry); + } + } + } + return result; +} + +function effectiveNumericBound( + schemas: JsonSchema[], + direction: "lower" | "upper", +): EffectiveNumericBound { + let value: number | undefined; + let exclusive = false; + for (const schema of schemas) { + const inclusiveCandidate = finiteNumber( + direction === "lower" ? schema.minimum : schema.maximum, + ); + const exclusiveCandidate = finiteNumber( + direction === "lower" ? schema.exclusiveMinimum : schema.exclusiveMaximum, + ); + for (const [candidate, candidateExclusive] of [ + [inclusiveCandidate, false], + [exclusiveCandidate, true], + ] as const) { + if ( + candidate !== undefined && + (value === undefined || + (direction === "lower" ? candidate > value : candidate < value) || + (candidate === value && candidateExclusive && !exclusive)) + ) { + value = candidate; + exclusive = candidateExclusive; + } + } + } + return { value, exclusive }; +} + +function combinedMultipleOf(schemas: JsonSchema[]): number | undefined { + let numerator: bigint | undefined; + let denominator: bigint | undefined; + for (const schema of schemas) { + const multipleOf = finiteNumber(schema.multipleOf); + if (multipleOf === undefined || multipleOf <= 0) { + continue; + } + const rational = decimalRational(multipleOf); + if (!rational) { + continue; + } + const divisor = greatestCommonDivisor(rational.numerator, rational.denominator); + const nextNumerator = rational.numerator / divisor; + const nextDenominator = rational.denominator / divisor; + numerator = + numerator === undefined ? nextNumerator : leastCommonMultiple(numerator, nextNumerator); + denominator = + denominator === undefined + ? nextDenominator + : greatestCommonDivisor(denominator, nextDenominator); + } + if (numerator === undefined || denominator === undefined) { + return undefined; + } + const combined = Number(numerator) / Number(denominator); + return Number.isFinite(combined) && combined > 0 ? combined : undefined; +} + +export function arrayInputConstraints(schema: JsonSchema): ArrayInputConstraints { + const schemas = collectAllOfSchemas(schema); + let minItems = 0; + let maxItems: number | undefined; + let uniqueItems = false; + for (const entry of schemas) { + if ( + Number.isSafeInteger(entry.minItems) && + entry.minItems !== undefined && + entry.minItems >= 0 + ) { + minItems = Math.max(minItems, entry.minItems); + } + if ( + Number.isSafeInteger(entry.maxItems) && + entry.maxItems !== undefined && + entry.maxItems >= 0 + ) { + maxItems = maxItems === undefined ? entry.maxItems : Math.min(maxItems, entry.maxItems); + } + if (Array.isArray(entry.items) && entry.additionalItems === false) { + maxItems = Math.min(maxItems ?? Number.POSITIVE_INFINITY, entry.items.length); + } + uniqueItems ||= entry.uniqueItems === true; + } + return { minItems, maxItems, uniqueItems }; +} + +export function requiredPropertyKeys(schema: JsonSchema): Set { + return new Set(collectAllOfSchemas(schema).flatMap((entry) => entry.required ?? [])); +} + +export function objectPropertyKeys(schema: JsonSchema): string[] { + const schemas = collectAllOfSchemas(schema); + const keys = new Set(); + for (const entry of schemas) { + for (const key of Object.keys(entry.properties ?? {})) { + keys.add(key); + } + } + return [...keys].filter((key) => + schemas.every( + (entry) => + ownPropertySchema(entry, key) !== undefined || entry.additionalProperties !== false, + ), + ); +} + +function combinedSchema(candidates: JsonSchema[]): JsonSchema | undefined { + const base = candidates.find((candidate) => schemaType(candidate) !== undefined) ?? candidates[0]; + return !base || candidates.length === 1 + ? base + : { + ...base, + allOf: [...(base.allOf ?? []), ...candidates.filter((candidate) => candidate !== base)], + }; +} + +export function objectAdditionalPropertiesSchema( + schema: JsonSchema, +): JsonSchema | false | undefined { + const policies = collectAllOfSchemas(schema) + .map((entry) => entry.additionalProperties) + .filter((policy) => policy !== undefined); + if (policies.some((policy) => policy === false)) { + return false; + } + const schemas = policies.filter( + (policy): policy is JsonSchema => Boolean(policy) && typeof policy === "object", + ); + if (schemas.length > 0) { + return combinedSchema(schemas); + } + return policies.some((policy) => policy === true) ? {} : undefined; +} + +function objectRepairIssueCount(schema: JsonSchema, value: Record): number { + let issues = isSupportedConfigValueValid(schema, value) ? 0 : 1; + const knownKeys = new Set(objectPropertyKeys(schema)); + for (const key of requiredPropertyKeys(schema)) { + if (!Object.hasOwn(value, key)) { + issues += 1; + } + } + const additionalProperties = objectAdditionalPropertiesSchema(schema); + for (const [key, entryValue] of Object.entries(value)) { + const propertySchema = objectPropertySchema(schema, key); + if (propertySchema) { + if (!isSupportedConfigValueValid(propertySchema, entryValue)) { + issues += 1; + } + continue; + } + if ( + !knownKeys.has(key) && + (additionalProperties === false || + additionalProperties === undefined || + !isSupportedConfigValueValid(additionalProperties, entryValue)) + ) { + issues += 1; + } + } + return issues; +} + +export function canApplyObjectCandidate( + schema: JsonSchema, + current: Record, + candidate: Record, +): boolean { + if (isSupportedConfigValueValid(schema, candidate)) { + return true; + } + if (isSupportedConfigValueValid(schema, current)) { + return false; + } + return objectRepairIssueCount(schema, candidate) <= objectRepairIssueCount(schema, current); +} + +export function objectPropertySchema(schema: JsonSchema, key: string): JsonSchema | undefined { + if (!objectPropertyKeys(schema).includes(key)) { + return undefined; + } + return combinedSchema( + collectAllOfSchemas(schema) + .map((entry) => ownPropertySchema(entry, key)) + .filter((property): property is JsonSchema => property !== undefined), + ); +} + +export function arrayItemSchema(schema: JsonSchema, index: number): JsonSchema | undefined { + const candidates: JsonSchema[] = []; + for (const entry of collectAllOfSchemas(schema)) { + if (Array.isArray(entry.items)) { + const item = + entry.items[index] ?? + (entry.additionalItems && typeof entry.additionalItems === "object" + ? entry.additionalItems + : undefined); + if (item) { + candidates.push(item); + } + } else if (entry.items) { + candidates.push(entry.items); + } + } + return combinedSchema(candidates); +} + +export function arrayConstraintCandidates( + schema: JsonSchema, + seen = new Set(), +): unknown[][] { + if (seen.has(schema)) { + return []; + } + seen.add(schema); + const candidates: unknown[][] = []; + if (Array.isArray(schema.const)) { + candidates.push(schema.const); + } + for (const entry of schema.enum ?? []) { + if (Array.isArray(entry)) { + candidates.push(entry); + } + } + for (const entry of [...(schema.allOf ?? []), ...(schema.anyOf ?? []), ...(schema.oneOf ?? [])]) { + candidates.push(...arrayConstraintCandidates(entry, seen)); + } + seen.delete(schema); + return candidates; +} + +function arrayCandidateDistance(value: readonly unknown[], candidate: readonly unknown[]): number { + let distance = Math.abs(value.length - candidate.length); + const sharedLength = Math.min(value.length, candidate.length); + for (let index = 0; index < sharedLength; index += 1) { + if (!configValuesEqual(value[index], candidate[index])) { + distance += 1; + } + } + return distance; +} + +function arrayRepairIssueCount( + schema: JsonSchema, + value: readonly unknown[], + uniqueItems: boolean, +): number { + const { minItems, maxItems } = arrayInputConstraints(schema); + let issues = Math.max(0, minItems - value.length); + const constrainedDistances = arrayConstraintCandidates(schema) + .filter((candidate) => isSupportedConfigValueValid(schema, candidate)) + .map((candidate) => arrayCandidateDistance(value, candidate)); + if (constrainedDistances.length > 0) { + issues += Math.min(...constrainedDistances); + } + if (maxItems !== undefined) { + issues += Math.max(0, value.length - maxItems); + } + for (let index = 0; index < value.length; index += 1) { + const itemSchema = arrayItemSchema(schema, index); + if (itemSchema && !isSupportedConfigValueValid(itemSchema, value[index])) { + issues += 1; + } + if ( + uniqueItems && + value.slice(index + 1).some((candidate) => configValuesEqual(value[index], candidate)) + ) { + issues += 1; + } + } + return issues; +} + +export function canApplyArrayCandidate( + schema: JsonSchema, + current: readonly unknown[], + candidate: readonly unknown[], + uniqueItems: boolean, + allowEqualRepair: boolean, +): boolean { + if (isSupportedConfigValueValid(schema, candidate)) { + return true; + } + if (isSupportedConfigValueValid(schema, current)) { + return false; + } + const currentIssues = arrayRepairIssueCount(schema, current, uniqueItems); + const candidateIssues = arrayRepairIssueCount(schema, candidate, uniqueItems); + return allowEqualRepair ? candidateIssues <= currentIssues : candidateIssues < currentIssues; +} + +export function numericInputConstraints(schema: JsonSchema): NumericInputConstraints { + const schemas = collectAllOfSchemas(schema); + const numericTypes = new Set( + schemas.flatMap((entry) => { + const declaredTypes = Array.isArray(entry.type) ? entry.type : entry.type ? [entry.type] : []; + return declaredTypes.includes("number") + ? ["number"] + : declaredTypes.includes("integer") + ? ["integer"] + : []; + }), + ); + const type = numericTypes.has("integer") + ? "integer" + : numericTypes.has("number") + ? "number" + : schemaType(schema); + const multipleOf = combinedMultipleOf(schemas); + const numericStep = + type === "integer" + ? multipleOf && multipleOf > 0 + ? integerCompatibleStep(multipleOf) + : 1 + : multipleOf && multipleOf > 0 + ? multipleOf + : undefined; + const lowerBound = effectiveNumericBound(schemas, "lower"); + const upperBound = effectiveNumericBound(schemas, "upper"); + const rawMinimum = lowerBound.exclusive ? undefined : lowerBound.value; + const rawMaximum = upperBound.exclusive ? undefined : upperBound.value; + const exclusiveMinimum = lowerBound.exclusive ? lowerBound.value : undefined; + const exclusiveMaximum = upperBound.exclusive ? upperBound.value : undefined; + + let min = rawMinimum ?? exclusiveMinimum; + let max = rawMaximum ?? exclusiveMaximum; + if (numericStep) { + if (min !== undefined) { + min = alignToStep(min, numericStep, "ceil"); + } + if (max !== undefined) { + max = alignToStep(max, numericStep, "floor"); + } + if (exclusiveMinimum !== undefined) { + const aligned = alignToStep(exclusiveMinimum, numericStep, "ceil"); + const exclusiveAligned = + aligned <= exclusiveMinimum + ? normalizePrecision(aligned + numericStep, numericStep) + : aligned; + min = min === undefined ? exclusiveAligned : Math.max(min, exclusiveAligned); + } + if (exclusiveMaximum !== undefined) { + const aligned = alignToStep(exclusiveMaximum, numericStep, "floor"); + const exclusiveAligned = + aligned >= exclusiveMaximum + ? normalizePrecision(aligned - numericStep, numericStep) + : aligned; + max = max === undefined ? exclusiveAligned : Math.min(max, exclusiveAligned); + } + } + + return { + min, + max, + exclusiveMin: exclusiveMinimum, + exclusiveMax: exclusiveMaximum, + step: numericStep ?? "any", + }; +} + +function nextRepresentable(value: number, direction: 1 | -1): number { + if (!Number.isFinite(value)) { + return value; + } + if (value === 0) { + return direction > 0 ? Number.MIN_VALUE : -Number.MIN_VALUE; + } + const buffer = new ArrayBuffer(8); + const view = new DataView(buffer); + view.setFloat64(0, value); + const bits = view.getBigUint64(0); + const nextBits = value > 0 === direction > 0 ? bits + 1n : bits - 1n; + view.setBigUint64(0, nextBits); + return view.getFloat64(0); +} + +function nextUsableNumber(value: number, direction: 1 | -1, opposite?: number): number { + if (opposite !== undefined && Number.isFinite(opposite)) { + const midpoint = value + (opposite - value) / 2; + if ((direction > 0 && midpoint > value) || (direction < 0 && midpoint < value)) { + return midpoint; + } + } + const offset = Math.max(1, Math.abs(value)); + const candidate = value + direction * offset; + if (Number.isFinite(candidate) && candidate !== value) { + return candidate; + } + return nextRepresentable(value, direction); +} + +export function normalizeNumericValue(value: number, schema: JsonSchema): number { + const constraints = numericInputConstraints(schema); + let normalized = value; + if (typeof constraints.step === "number") { + normalized = alignToStep(normalized, constraints.step, "round"); + } + if (constraints.min !== undefined) { + normalized = Math.max(constraints.min, normalized); + } + if (constraints.max !== undefined) { + normalized = Math.min(constraints.max, normalized); + } + if (constraints.exclusiveMin !== undefined && normalized <= constraints.exclusiveMin) { + normalized = + typeof constraints.step === "number" + ? nextRepresentable(constraints.exclusiveMin, 1) + : nextRepresentable(constraints.exclusiveMin, 1); + } + if (constraints.exclusiveMax !== undefined && normalized >= constraints.exclusiveMax) { + normalized = + typeof constraints.step === "number" + ? nextRepresentable(constraints.exclusiveMax, -1) + : nextRepresentable(constraints.exclusiveMax, -1); + } + return normalizePrecision( + normalized, + typeof constraints.step === "number" ? constraints.step : undefined, + ); +} + +export const NO_SAFE_DEFAULT = Symbol("no-safe-config-default"); + +const MAX_AUTO_STRING_DEFAULT_LENGTH = 4096; +export const MAX_AUTO_ARRAY_DEFAULT_ITEMS = 100; + +function defaultNumericValue(schema: JsonSchema): number { + const constraints = numericInputConstraints(schema); + if (constraints.step === "any") { + if (constraints.exclusiveMin !== undefined && constraints.exclusiveMin >= 0) { + return nextUsableNumber(constraints.exclusiveMin, 1, constraints.max); + } + if (constraints.exclusiveMax !== undefined && constraints.exclusiveMax <= 0) { + return nextUsableNumber(constraints.exclusiveMax, -1, constraints.min); + } + } + return normalizeNumericValue(0, schema); +} + +function defaultStringValue(schema: JsonSchema): string | typeof NO_SAFE_DEFAULT { + const minLength = Math.max(0, schema.minLength ?? 0); + const maxLength = schema.maxLength ?? Math.max(minLength, 0); + if ( + !Number.isSafeInteger(minLength) || + minLength > MAX_AUTO_STRING_DEFAULT_LENGTH || + maxLength < minLength + ) { + return NO_SAFE_DEFAULT; + } + if (schema.pattern) { + try { + return minLength === 0 && new RegExp(schema.pattern, "u").test("") ? "" : NO_SAFE_DEFAULT; + } catch { + return NO_SAFE_DEFAULT; + } + } + if (minLength === 0) { + return ""; + } + return "x".repeat(minLength).slice(0, maxLength); +} + +function validatedDefaultCandidate(schema: JsonSchema, candidate: unknown): unknown { + if (candidate === NO_SAFE_DEFAULT || !isSupportedConfigValueValid(schema, candidate)) { + return NO_SAFE_DEFAULT; + } + if (!candidate || typeof candidate !== "object") { + return candidate; + } + try { + return structuredClone(candidate); + } catch { + return NO_SAFE_DEFAULT; + } +} + +export function defaultValue(schema?: JsonSchema, depth = 0): unknown { + if (!schema) { + return ""; + } + if (schema.default !== undefined) { + return validatedDefaultCandidate(schema, schema.default); + } + if (schema.const !== undefined) { + return validatedDefaultCandidate(schema, schema.const); + } + if (schema.enum && schema.enum.length > 0) { + for (const candidate of schema.enum) { + const validated = validatedDefaultCandidate(schema, candidate); + if (validated !== NO_SAFE_DEFAULT) { + return validated; + } + } + return NO_SAFE_DEFAULT; + } + if (depth >= 32) { + return NO_SAFE_DEFAULT; + } + for (const entry of schema.allOf ?? []) { + const candidate = defaultValue(entry, depth + 1); + const validated = validatedDefaultCandidate(schema, candidate); + if (validated !== NO_SAFE_DEFAULT) { + return validated; + } + } + const type = schemaType(schema); + switch (type) { + case "object": { + const value: Record = {}; + for (const key of schema.required ?? []) { + const propertySchema = ownPropertySchema(schema, key); + if (!propertySchema) { + return NO_SAFE_DEFAULT; + } + const propertyDefault = defaultValue(propertySchema, depth + 1); + if (propertyDefault === NO_SAFE_DEFAULT) { + return NO_SAFE_DEFAULT; + } + value[key] = propertyDefault; + } + return validatedDefaultCandidate(schema, value); + } + case "array": { + const itemCount = Math.max(0, schema.minItems ?? 0); + if (!Number.isSafeInteger(itemCount) || itemCount > MAX_AUTO_ARRAY_DEFAULT_ITEMS) { + return NO_SAFE_DEFAULT; + } + if (itemCount === 0) { + return validatedDefaultCandidate(schema, []); + } + if (Array.isArray(schema.items)) { + const value: unknown[] = []; + for (let index = 0; index < itemCount; index += 1) { + const itemSchema = + schema.items[index] ?? + (schema.additionalItems && typeof schema.additionalItems === "object" + ? schema.additionalItems + : undefined); + if (!itemSchema) { + return NO_SAFE_DEFAULT; + } + const itemDefault = defaultValue(itemSchema, depth + 1); + if (itemDefault === NO_SAFE_DEFAULT) { + return NO_SAFE_DEFAULT; + } + value.push(itemDefault); + } + return validatedDefaultCandidate(schema, value); + } + const itemsSchema = schema.items; + if (!itemsSchema) { + return NO_SAFE_DEFAULT; + } + const value: unknown[] = []; + for (let index = 0; index < itemCount; index += 1) { + const itemDefault = defaultValue(itemsSchema, depth + 1); + if (itemDefault === NO_SAFE_DEFAULT) { + return NO_SAFE_DEFAULT; + } + value.push(itemDefault); + } + return validatedDefaultCandidate(schema, value); + } + case "boolean": + return validatedDefaultCandidate(schema, false); + case "number": + case "integer": { + const value = defaultNumericValue(schema); + return validatedDefaultCandidate(schema, value); + } + case "string": + return validatedDefaultCandidate(schema, defaultStringValue(schema)); + case "null": + return validatedDefaultCandidate(schema, null); + default: + return validatedDefaultCandidate(schema, ""); + } +} diff --git a/ui/src/components/config-form.node.collection.ts b/ui/src/components/config-form.node.collection.ts index e9af068e41f2..b37f4b14ce18 100644 --- a/ui/src/components/config-form.node.collection.ts +++ b/ui/src/components/config-form.node.collection.ts @@ -2,6 +2,34 @@ import { html, nothing, type TemplateResult } from "lit"; import { icons } from "../components/icons.ts"; import { t } from "../i18n/index.ts"; +import { removePathValue, setPathValue } from "../lib/config-form-utils.ts"; +import { arrayAddCandidates } from "./config-form-array-candidates.ts"; +import { + appendArrayRowIdentities, + discardArrayRowIdentities, + preserveArrayRowIdentities, + rowIdentitiesForArray, +} from "./config-form-array-identity.ts"; +import { + ConfigFormCollectionDraft, + type ConfigFormCollectionDraftCommit, + type ConfigFormCollectionDraftProps, +} from "./config-form-collection-draft.ts"; +import { copyWithPathPatch } from "./config-form-copy-on-write.ts"; +import { + arrayInputConstraints, + arrayItemSchema, + canApplyArrayCandidate, + canApplyObjectCandidate, + configValuesEqual, + defaultValue, + isSupportedConfigValueValid, + NO_SAFE_DEFAULT, + objectAdditionalPropertiesSchema, + objectPropertyKeys, + objectPropertySchema, + requiredPropertyKeys, +} from "./config-form.constraints.ts"; import { getSensitiveRenderState, isAnySchema, @@ -18,13 +46,26 @@ import { matchesNodeSelf, resolveConfigFieldMeta as resolveFieldMeta, } from "./config-form.search.ts"; -import { defaultValue, hintForPath } from "./config-form.shared.ts"; +import { configFieldId, hintForPath, type JsonSchema } from "./config-form.shared.ts"; import { renderSettingsEmpty } from "./settings-ui.ts"; +const UNSET_ARRAY_SOURCE_IDENTITY = Symbol("unset-array-source"); +const UNSET_MAP_SOURCE_IDENTITY = Symbol("unset-map-source"); + +function openCollectionDraft(event: Event, draftId: string): void { + const block = (event.currentTarget as HTMLElement).closest(".cfg-block"); + const draft = Array.from(block?.children ?? []).find((child) => child.id === draftId); + const openDraft = (draft as Partial | undefined)?.openDraft; + if (typeof openDraft === "function") { + openDraft.call(draft); + } +} + export function renderJsonTextarea(params: ConfigNodeRenderParams): TemplateResult { const { schema, value, path, hints, disabled, onPatch } = params; const showLabel = params.showLabel ?? true; const { label, help, tags } = resolveFieldMeta(path, schema, hints); + const helpId = showLabel && help ? configFieldId(path, "description") : undefined; const fallback = jsonValue(value); const sensitiveState = getSensitiveRenderState({ path, @@ -37,15 +78,22 @@ export function renderJsonTextarea(params: ConfigNodeRenderParams): TemplateResu return renderFieldRow({ label, help, + helpId, tags, showLabel, stacked: true, control: renderJsonTextareaControl({ + schema, path, + ariaLabel: label, + descriptionId: helpId, + sourceValue: params.sourceIdentity ?? value, + rowIdentity: params.rowIdentity, fallback, rows: 3, sensitiveState, disabled, + isRequired: params.isRequired, onToggleSensitivePath: params.onToggleSensitivePath, onPatch, }), @@ -79,12 +127,17 @@ export function renderObject( const childSearchCriteria = selfMatched ? undefined : searchCriteria; const fallback = value ?? schema.default; + const objectSourceIdentity = fallback === undefined ? UNSET_MAP_SOURCE_IDENTITY : fallback; const objectValue = fallback && typeof fallback === "object" && !Array.isArray(fallback) ? (fallback as Record) : {}; - const properties = schema.properties ?? {}; - const entries = Object.entries(properties); + const entries = objectPropertyKeys(schema) + .map((key) => [key, objectPropertySchema(schema, key)] as const) + .filter((entry): entry is readonly [string, ConfigNodeRenderParams["schema"]] => + Boolean(entry[1]), + ); + const requiredKeys = requiredPropertyKeys(schema); // Sort by hint order const sorted = entries.toSorted((left, right) => { @@ -96,13 +149,44 @@ export function renderObject( return left[0].localeCompare(right[0]); }); - const reservedKeys = new Set(Object.keys(properties)); - const additionalProperties = schema.additionalProperties; + const reservedKeys = new Set(entries.map(([key]) => key)); + const additionalProperties = objectAdditionalPropertiesSchema(schema); const allowExtra = Boolean(additionalProperties) && typeof additionalProperties === "object"; + const patchObjectChild = (childPath: Array, childValue: unknown) => { + if ( + childPath.length < path.length || + !path.every((segment, index) => segment === childPath[index]) + ) { + return false; + } + let candidate: Record; + const relativePath = childPath.slice(path.length); + if (relativePath.length === 0) { + if (!childValue || typeof childValue !== "object" || Array.isArray(childValue)) { + return false; + } + candidate = childValue as Record; + } else { + try { + candidate = structuredClone(objectValue); + } catch { + return false; + } + if (childValue === undefined) { + removePathValue(candidate, relativePath); + } else { + setPathValue(candidate, relativePath, childValue); + } + } + if (!canApplyObjectCandidate(schema, objectValue, candidate)) { + return false; + } + return onPatch(childPath, childValue) !== false; + }; const fields = html` - ${sorted.map(([propertyKey, node]) => - renderNode({ + ${sorted.map(([propertyKey, node]) => { + return renderNode({ schema: node, value: objectValue[propertyKey], path: [...path, propertyKey], @@ -110,21 +194,27 @@ export function renderObject( rawAvailable, unsupported, disabled, + isRequired: requiredKeys.has(propertyKey), + sourceIdentity: objectValue[propertyKey], + controlIdentity: params.controlIdentity ?? objectValue, + rowIdentity: params.rowIdentity, searchCriteria: childSearchCriteria, revealSensitive, isSensitivePathRevealed, onToggleSensitivePath, - onPatch, - }), - )} + onPatch: patchObjectChild, + }); + })} ${allowExtra ? renderMapField( { ...params, schema: additionalProperties, value: objectValue, + sourceIdentity: objectSourceIdentity, reservedKeys, searchCriteria: childSearchCriteria, + onPatch: patchObjectChild, }, renderNode, ) @@ -179,7 +269,8 @@ export function renderArray( : false; const childSearchCriteria = selfMatched ? undefined : searchCriteria; - const itemsSchema = Array.isArray(schema.items) ? schema.items[0] : schema.items; + const tupleItems = Array.isArray(schema.items) ? schema.items : undefined; + const itemsSchema = Array.isArray(schema.items) ? (schema.items[0] ?? {}) : schema.items; if (!itemsSchema) { return renderFieldRow({ label, @@ -195,6 +286,84 @@ export function renderArray( : Array.isArray(schema.default) ? schema.default : []; + const arraySourceIdentity = Array.isArray(value) + ? value + : Array.isArray(schema.default) + ? schema.default + : UNSET_ARRAY_SOURCE_IDENTITY; + const rowIdentities = rowIdentitiesForArray(arrayValue); + const { + minItems: minimumItems, + maxItems: maximumItems, + uniqueItems, + } = arrayInputConstraints(schema); + const itemSchemaAt = (index: number): JsonSchema => + arrayItemSchema(schema, index) ?? (tupleItems ? {} : itemsSchema); + const { atomicCandidate, autoCandidate } = arrayAddCandidates({ + schema, + value: arrayValue, + minimumItems, + maximumItems, + uniqueItems, + isUnset: value === undefined, + isRequired: params.isRequired ?? false, + itemSchemaAt, + }); + const canAppend = maximumItems === undefined || arrayValue.length < maximumItems; + const requiresDraft = atomicCandidate === undefined && autoCandidate === undefined; + const nextItemSchema = itemSchemaAt(arrayValue.length); + const draftId = configFieldId(path, "array-draft"); + const draftProps: ConfigFormCollectionDraftProps = { + schema: nextItemSchema, + label, + disabled: disabled || !canAppend, + identity: draftId, + sourceIdentity: arraySourceIdentity, + existingValues: uniqueItems ? arrayValue : undefined, + validateValue: (candidate) => { + const nextValue = [...arrayValue, candidate]; + return ( + (maximumItems === undefined || nextValue.length <= maximumItems) && + (nextValue.length < minimumItems || isSupportedConfigValueValid(schema, nextValue)) + ); + }, + }; + const patchArrayItem = (childPath: Array, childValue: unknown) => { + if ( + childPath.length <= path.length || + !path.every((segment, index) => segment === childPath[index]) + ) { + return false; + } + const relativePath = childPath.slice(path.length); + const itemIndex = relativePath[0]; + if (typeof itemIndex !== "number" || itemIndex < 0 || itemIndex >= arrayValue.length) { + return false; + } + const nextValue = [...arrayValue]; + const itemPath = relativePath.slice(1); + if (itemPath.length === 0) { + if (childValue === undefined) { + return false; + } + nextValue[itemIndex] = childValue; + } else { + const nextItem = copyWithPathPatch(arrayValue[itemIndex], itemPath, childValue); + if (!nextItem.ok) { + return false; + } + nextValue[itemIndex] = nextItem.value; + } + if (canApplyArrayCandidate(schema, arrayValue, nextValue, uniqueItems, true)) { + preserveArrayRowIdentities(nextValue, rowIdentities); + const accepted = onPatch(path, nextValue) !== false; + if (!accepted) { + discardArrayRowIdentities(nextValue); + } + return accepted; + } + return false; + }; return html`
@@ -213,13 +382,57 @@ export function renderArray(
+ ) => { + const nextValue = [...arrayValue, event.detail.value]; + const canApply = + !( + uniqueItems && arrayValue.some((item) => configValuesEqual(item, event.detail.value)) + ) && + (maximumItems === undefined || arrayValue.length < maximumItems) && + isSupportedConfigValueValid(nextItemSchema, event.detail.value) && + (nextValue.length < minimumItems || isSupportedConfigValueValid(schema, nextValue)); + let accepted = false; + if (canApply) { + appendArrayRowIdentities(nextValue, rowIdentities, 1); + accepted = onPatch(path, nextValue) !== false; + if (!accepted) { + discardArrayRowIdentities(nextValue); + } + } + if (!accepted) { + event.preventDefault(); + } + }} + > ${arrayValue.length === 0 ? renderSettingsEmpty(t("configForm.noItems")) : html` @@ -237,11 +450,34 @@ export function renderArray( class="btn btn--icon" style="width:28px;height:28px;padding:0;" aria-label=${t("configForm.removeItem")} - ?disabled=${disabled} + ?disabled=${disabled || + arrayValue.length <= minimumItems || + !canApplyArrayCandidate( + schema, + arrayValue, + arrayValue.toSpliced(index, 1), + uniqueItems, + false, + )} @click=${() => { - const nextValue = [...arrayValue]; - nextValue.splice(index, 1); - onPatch(path, nextValue); + const nextValue = arrayValue.toSpliced(index, 1); + if ( + canApplyArrayCandidate( + schema, + arrayValue, + nextValue, + uniqueItems, + false, + ) + ) { + preserveArrayRowIdentities( + nextValue, + rowIdentities.toSpliced(index, 1), + ); + if (onPatch(path, nextValue) === false) { + discardArrayRowIdentities(nextValue); + } + } }} > ${icons.trash} @@ -250,19 +486,23 @@ export function renderArray( ${renderNode({ - schema: itemsSchema, + schema: itemSchemaAt(index), value: item, path: [...path, index], hints, rawAvailable, unsupported, disabled, + isRequired: true, + sourceIdentity: item, + controlIdentity: arrayValue, + rowIdentity: rowIdentities[index], searchCriteria: childSearchCriteria, showLabel: false, revealSensitive, isSensitivePathRevealed, onToggleSensitivePath, - onPatch, + onPatch: patchArrayItem, })} `, )} @@ -295,6 +535,16 @@ function renderMapField( onToggleSensitivePath, } = params; const anySchema = isAnySchema(schema); + const entryDefault = anySchema ? {} : defaultValue(schema); + const draftId = configFieldId(path, "map-draft"); + const draftProps: ConfigFormCollectionDraftProps = { + schema, + label: t("configForm.customEntries"), + disabled, + identity: draftId, + sourceIdentity: params.sourceIdentity ?? value, + existingKeys: [...new Set([...Object.keys(value), ...reservedKeys])], + }; const entries = Object.entries(value ?? {}).filter(([key]) => !reservedKeys.has(key)); const visibleEntries = searchCriteria && hasSearchCriteria(searchCriteria) @@ -319,8 +569,13 @@ function renderMapField( + syncScalarInputIdentity( + element, + controlIdentity, + sourceIdentity, + params.rowIdentity, + controlPathKey, + "number", + renderedValue, + revalidate, + ), + )} type="number" class="settings-input" aria-label=${label} - .value=${formatUnknownText(displayValue)} + aria-describedby=${helpId ?? nothing} + aria-invalid="false" + min=${constraints.min ?? nothing} + max=${constraints.max ?? nothing} + step=${constraints.step} + .value=${renderedValue} ?disabled=${disabled} @input=${(event: Event) => { - const raw = (event.target as HTMLInputElement).value; + const target = event.target as HTMLInputElement; + const raw = target.value; + if (raw === "") { + if (params.isRequired) { + setControlValidity(target, t("configForm.invalidNumber")); + } else { + setControlValidity(target, ""); + commitScalarValue(target, undefined); + } + return; + } const parsed = raw === "" ? undefined : Number(raw); - onPatch(path, parsed); + if ( + parsed !== undefined && + setControlValidity(target, numericConstraintMessage(parsed, schema)) + ) { + commitScalarValue(target, parsed); + } + }} + @change=${(event: Event) => { + const target = event.target as HTMLInputElement; + if (target.value === "") { + return; + } + const parsed = Number(target.value); + if (!Number.isFinite(parsed)) { + setControlValidity(target, t("configForm.invalidNumber")); + return; + } + const normalized = normalizeNumericValue(parsed, schema); + target.value = formatUnknownText(normalized); + if (setControlValidity(target, numericConstraintMessage(normalized, schema))) { + commitScalarValue(target, normalized); + } }} /> `; - return renderFieldRow({ label, help, tags, showLabel, control }); + return renderFieldRow({ label, help, helpId, tags, showLabel, control }); } export function renderSelect( @@ -194,23 +438,56 @@ export function renderSelect( const { schema, value, path, hints, disabled, options, onPatch } = params; const showLabel = params.showLabel ?? true; const { label, help, tags } = resolveFieldMeta(path, schema, hints); - const resolvedValue = value ?? schema.default; + const helpId = showLabel && help ? configFieldId(path, "description") : undefined; + const resolvedValue = value !== undefined ? value : schema.default; const currentIndex = options.findIndex( (option) => option === resolvedValue || String(option) === String(resolvedValue), ); const unset = "__unset__"; + const nullValue = "__null__"; + const canSelectNull = schema.nullable && schema.enumIncludesNull; + const selectedValue = + resolvedValue === null && canSelectNull + ? nullValue + : currentIndex >= 0 + ? String(currentIndex) + : unset; const control = html` `; - return renderFieldRow({ label, help, tags, showLabel, control }); + return renderFieldRow({ label, help, helpId, tags, showLabel, control }); } diff --git a/ui/src/components/config-form.node.shared.ts b/ui/src/components/config-form.node.shared.ts index 4646dfcfcf47..2b44e12f3e23 100644 --- a/ui/src/components/config-form.node.shared.ts +++ b/ui/src/components/config-form.node.shared.ts @@ -1,19 +1,34 @@ // Control UI helpers shared by config form node renderers. import { html, nothing, type TemplateResult } from "lit"; +import { ref } from "lit/directives/ref.js"; import type { ConfigUiHints } from "../api/types.ts"; import { icons } from "../components/icons.ts"; import "../components/tooltip.ts"; import { t } from "../i18n/index.ts"; import { formatUnknownText } from "../lib/format.ts"; +import { isSupportedConfigValueValid } from "./config-form.constraints.ts"; import type { ConfigSearchCriteria } from "./config-form.search.ts"; import { + configFieldId, hasSensitiveConfigData, redactedPlaceholder, type JsonSchema, } from "./config-form.shared.ts"; import { renderSettingsSegmented } from "./settings-ui.ts"; -const META_KEYS = new Set(["title", "description", "default", "nullable", "tags", "x-tags"]); +const META_KEYS = new Set([ + "title", + "description", + "default", + "nullable", + "enumIncludesNull", + "tags", + "x-tags", +]); +const jsonTextareaState = new WeakMap< + HTMLTextAreaElement, + { sourceValue: unknown; rowIdentity: unknown; fallback: string; pathKey: string } +>(); export type ConfigNodeRenderParams = { schema: JsonSchema; @@ -23,12 +38,17 @@ export type ConfigNodeRenderParams = { rawAvailable?: boolean; unsupported: Set; disabled: boolean; + isRequired?: boolean; + sourceIdentity?: unknown; + controlIdentity?: unknown; + rowIdentity?: unknown; + structuredDraftOwner?: boolean; showLabel?: boolean; searchCriteria?: ConfigSearchCriteria; revealSensitive?: boolean; isSensitivePathRevealed?: (path: Array) => boolean; onToggleSensitivePath?: (path: Array) => void; - onPatch: (path: Array, value: unknown) => void; + onPatch: (path: Array, value: unknown) => boolean | void; }; export type ConfigNodeRenderer = ( @@ -171,6 +191,7 @@ export function renderTags(tags: string[]): TemplateResult | typeof nothing { export function renderFieldRow(params: { label: unknown; help?: unknown; + helpId?: string; tags: string[]; showLabel: boolean; control: TemplateResult | typeof nothing; @@ -194,10 +215,14 @@ export function renderFieldRow(params: { ${params.showLabel ? html`${params.label}` : nothing} - ${help ? html`${help}` : nothing} + ${help + ? html`${help}` + : nothing} ${renderTags(params.tags)} ${params.error - ? html`${params.error}` + ? html`${params.error}` : nothing} ` @@ -237,21 +262,92 @@ export function renderSegmentedControl(params: { } export function renderJsonTextareaControl(params: { + schema: JsonSchema; path: Array; + ariaLabel: string; + descriptionId?: string; + sourceValue: unknown; + rowIdentity?: unknown; fallback: string; rows: number; sensitiveState: SensitiveRenderState; disabled: boolean; + isRequired?: boolean; onToggleSensitivePath?: (path: Array) => void; - onPatch: (path: Array, value: unknown) => void; + onPatch: (path: Array, value: unknown) => boolean | void; }): TemplateResult { const { path, fallback, sensitiveState, disabled, onPatch } = params; + const errorId = configFieldId(path, "json-error"); + const describedBy = [params.descriptionId, errorId].filter(Boolean).join(" "); + const setValidity = (target: HTMLTextAreaElement, message: string) => { + const error = target + .closest(".cfg-json-editor") + ?.querySelector(".cfg-field__error"); + target.setCustomValidity(message); + target.setAttribute("aria-invalid", String(Boolean(message))); + if (error) { + error.hidden = !message; + error.textContent = message; + } + }; + const updateValidity = (target: HTMLTextAreaElement) => { + let message = ""; + const raw = target.value.trim(); + if (!raw && params.isRequired) { + message = t("configForm.invalidJson"); + } else if (raw) { + try { + if (!isSupportedConfigValueValid(params.schema, JSON.parse(raw))) { + message = t("configForm.invalidJson"); + } + } catch { + message = t("configForm.invalidJson"); + } + } + setValidity(target, message); + return !message; + }; + const renderedFallback = sensitiveState.isRedacted ? "" : fallback; + const pathKey = JSON.stringify(path); + const commitJsonValue = (target: HTMLTextAreaElement, candidate: unknown) => { + if (onPatch(path, candidate) !== false) { + return true; + } + target.value = renderedFallback; + updateValidity(target); + return false; + }; const textareaControl = html` `; - return wrapSensitiveControl( - textareaControl, - renderSensitiveToggleButton({ - path, - state: sensitiveState, - disabled, - onToggleSensitivePath: params.onToggleSensitivePath, - }), - ); + return html` + + ${wrapSensitiveControl( + textareaControl, + renderSensitiveToggleButton({ + path, + state: sensitiveState, + disabled, + onToggleSensitivePath: params.onToggleSensitivePath, + }), + )} + + + `; } diff --git a/ui/src/components/config-form.node.ts b/ui/src/components/config-form.node.ts index b0cd3e96d187..83711d04fa4a 100644 --- a/ui/src/components/config-form.node.ts +++ b/ui/src/components/config-form.node.ts @@ -1,10 +1,16 @@ // Control UI view dispatches config form schema node rendering. import { html, nothing, type TemplateResult } from "lit"; import { t } from "../i18n/index.ts"; +import { + shouldStageStructuredDraft, + structuredDraftInitialValue, + type ConfigFormStructuredDraftProps, +} from "./config-form-structured-draft.ts"; import { renderArray, renderJsonTextarea, renderObject } from "./config-form.node.collection.ts"; import { renderNumberInput, renderSelect, renderTextInput } from "./config-form.node.scalar.ts"; import { renderFieldRow, + isAnySchema, renderSegmentedControl, renderTags, type ConfigNodeRenderParams, @@ -14,7 +20,7 @@ import { matchesNodeSearch, resolveConfigFieldMeta as resolveFieldMeta, } from "./config-form.search.ts"; -import { pathKey, schemaType } from "./config-form.shared.ts"; +import { configFieldId, pathKey, schemaType } from "./config-form.shared.ts"; import { renderSettingsToggle, renderSettingsToggleRow } from "./settings-ui.ts"; export function renderNode(params: ConfigNodeRenderParams): TemplateResult | typeof nothing { @@ -41,6 +47,22 @@ export function renderNode(params: ConfigNodeRenderParams): TemplateResult | typ ) { return nothing; } + const structuredDraftValue = structuredDraftInitialValue(params); + if (shouldStageStructuredDraft(params, structuredDraftValue)) { + const props: ConfigFormStructuredDraftProps = { + identity: configFieldId(path, "structured-draft"), + sourceIdentity: params.sourceIdentity ?? value, + initialValue: structuredDraftValue, + params, + renderNode, + }; + return html` + + `; + } // Handle anyOf/oneOf unions if (schema.anyOf || schema.oneOf) { @@ -208,6 +230,10 @@ export function renderNode(params: ConfigNodeRenderParams): TemplateResult | typ return renderTextInput({ ...params, inputType: "text" }); } + if (isAnySchema(schema)) { + return renderJsonTextarea(params); + } + // Fallback return renderFieldRow({ label, diff --git a/ui/src/components/config-form.search.ts b/ui/src/components/config-form.search.ts index 1bea55fc2349..95df93707e9d 100644 --- a/ui/src/components/config-form.search.ts +++ b/ui/src/components/config-form.search.ts @@ -67,7 +67,8 @@ export function resolveConfigFieldMeta( hints: ConfigUiHints, ): ConfigFieldMeta { const hint = hintForPath(path, hints); - const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1))); + const fallbackSegment = path.findLast((segment) => typeof segment === "string") ?? path.at(-1); + const label = hint?.label ?? schema.title ?? humanize(String(fallbackSegment)); const help = hint?.help ?? schema.description; const schemaTags = normalizeTags(schema["x-tags"] ?? schema.tags); const hintTags = normalizeTags(hint?.tags); diff --git a/ui/src/components/config-form.shared.ts b/ui/src/components/config-form.shared.ts index cb891df017d8..0fa4e9a2f514 100644 --- a/ui/src/components/config-form.shared.ts +++ b/ui/src/components/config-form.shared.ts @@ -12,12 +12,23 @@ export type JsonSchema = { properties?: Record; required?: string[]; items?: JsonSchema | JsonSchema[]; + additionalItems?: JsonSchema | boolean; additionalProperties?: JsonSchema | boolean; enum?: unknown[]; + enumIncludesNull?: boolean; const?: unknown; default?: unknown; + minimum?: number; + maximum?: number; + exclusiveMinimum?: number; + exclusiveMaximum?: number; + multipleOf?: number; minLength?: number; maxLength?: number; + pattern?: string; + minItems?: number; + maxItems?: number; + uniqueItems?: boolean; anyOf?: JsonSchema[]; oneOf?: JsonSchema[]; allOf?: JsonSchema[]; @@ -34,29 +45,22 @@ export function schemaType(schema: JsonSchema): string | undefined { return schema.type; } -export function defaultValue(schema?: JsonSchema): unknown { - if (!schema) { - return ""; - } - if (schema.default !== undefined) { - return schema.default; - } - const type = schemaType(schema); - switch (type) { - case "object": - return {}; - case "array": - return []; - case "boolean": - return false; - case "number": - case "integer": - return 0; - case "string": - return ""; - default: - return ""; - } +export function configFieldId(path: Array, suffix: string): string { + const key = + path.length === 0 + ? "root" + : path + .map((segment) => { + const value = String(segment); + let encoded = ""; + for (let index = 0; index < value.length; index += 1) { + encoded += value.charCodeAt(index).toString(16).padStart(4, "0"); + } + const type = typeof segment === "number" ? "n" : "s"; + return `${type}${value.length}-${encoded}`; + }) + .join("_"); + return `config-field-${key}-${suffix}`; } export function pathKey(path: Array): string { diff --git a/ui/src/components/config-form.validation.ts b/ui/src/components/config-form.validation.ts new file mode 100644 index 000000000000..0ff6f14dc1af --- /dev/null +++ b/ui/src/components/config-form.validation.ts @@ -0,0 +1,259 @@ +// Control UI helpers validate schema-backed values without arbitrary depth cutoffs. +import type { JsonSchema } from "./config-form.shared.ts"; + +type DecimalRational = { + numerator: bigint; + denominator: bigint; +}; + +export function decimalRational(value: number): DecimalRational | undefined { + if (!Number.isFinite(value)) { + return undefined; + } + const [coefficientText = "", exponentText] = String(value).toLowerCase().split("e"); + const negative = coefficientText.startsWith("-"); + const coefficient = negative ? coefficientText.slice(1) : coefficientText; + const [whole = "0", fraction = ""] = coefficient.split("."); + const exponent = Number(exponentText ?? 0); + const digits = BigInt(`${whole}${fraction}`); + const fractionalPlaces = fraction.length - exponent; + const numerator = fractionalPlaces < 0 ? digits * 10n ** BigInt(-fractionalPlaces) : digits; + return { + numerator: negative ? -numerator : numerator, + denominator: fractionalPlaces > 0 ? 10n ** BigInt(fractionalPlaces) : 1n, + }; +} + +function isNumericMultiple(value: number, multipleOf: number): boolean { + if (!Number.isFinite(value) || !Number.isFinite(multipleOf) || multipleOf <= 0) { + return false; + } + const valueRational = decimalRational(value); + const multipleRational = decimalRational(multipleOf); + if (!valueRational || !multipleRational) { + return false; + } + const dividend = valueRational.numerator * multipleRational.denominator; + const divisor = valueRational.denominator * multipleRational.numerator; + return divisor !== 0n && dividend % divisor === 0n; +} + +function configValuesEqualInternal(left: unknown, right: unknown): boolean { + if (left === right) { + return true; + } + if (!left || !right || typeof left !== "object" || typeof right !== "object") { + return false; + } + if (Array.isArray(left) || Array.isArray(right)) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((entry, index) => configValuesEqualInternal(entry, right[index])) + ); + } + const leftRecord = left as Record; + const rightRecord = right as Record; + const keys = Object.keys(leftRecord); + return ( + keys.length === Object.keys(rightRecord).length && + keys.every( + (key) => + Object.hasOwn(rightRecord, key) && + configValuesEqualInternal(leftRecord[key], rightRecord[key]), + ) + ); +} + +function isAcyclicValue( + value: unknown, + active = new WeakSet(), + complete = new WeakSet(), +): boolean { + if (!value || typeof value !== "object") { + return true; + } + if (complete.has(value)) { + return true; + } + if (active.has(value)) { + return false; + } + active.add(value); + const entries = Array.isArray(value) ? value : Object.values(value); + const acyclic = entries.every((entry) => isAcyclicValue(entry, active, complete)); + active.delete(value); + if (acyclic) { + complete.add(value); + } + return acyclic; +} + +export function configValuesEqual(left: unknown, right: unknown): boolean { + return isAcyclicValue(left) && isAcyclicValue(right) && configValuesEqualInternal(left, right); +} + +function matchesJsonSchemaType(type: string, value: unknown): boolean { + switch (type) { + case "string": + return typeof value === "string"; + case "number": + return typeof value === "number" && Number.isFinite(value); + case "integer": + return typeof value === "number" && Number.isInteger(value); + case "boolean": + return typeof value === "boolean"; + case "null": + return value === null; + case "array": + return Array.isArray(value); + case "object": + return Boolean(value) && typeof value === "object" && !Array.isArray(value); + default: + return false; + } +} + +export function ownPropertySchema(schema: JsonSchema, key: string): JsonSchema | undefined { + const properties = schema.properties; + return properties && Object.hasOwn(properties, key) ? properties[key] : undefined; +} + +function validateSupportedConfigValue( + schema: JsonSchema, + value: unknown, + active: Map>, +): boolean { + let activeValues = active.get(schema); + if (activeValues?.has(value)) { + return true; + } + if (!activeValues) { + activeValues = new Set(); + active.set(schema, activeValues); + } + activeValues.add(value); + try { + if ( + (schema.allOf && + !schema.allOf.every((entry) => validateSupportedConfigValue(entry, value, active))) || + (schema.anyOf && + !schema.anyOf.some((entry) => validateSupportedConfigValue(entry, value, active))) || + (schema.oneOf && + schema.oneOf.filter((entry) => validateSupportedConfigValue(entry, value, active)) + .length !== 1) + ) { + return false; + } + if (schema.const !== undefined && !configValuesEqual(schema.const, value)) { + return false; + } + if (schema.enum && !schema.enum.some((entry) => configValuesEqual(entry, value))) { + if (!(value === null && schema.nullable && schema.enumIncludesNull)) { + return false; + } + } + if (value === null && schema.nullable) { + return true; + } + const declaredTypes = + typeof schema.type === "string" + ? [schema.type] + : Array.isArray(schema.type) + ? schema.type + : []; + if ( + declaredTypes.length > 0 && + !declaredTypes.some((type) => matchesJsonSchemaType(type, value)) + ) { + return false; + } + if (typeof value === "string") { + const length = Array.from(value).length; + if ( + (schema.minLength !== undefined && length < schema.minLength) || + (schema.maxLength !== undefined && length > schema.maxLength) + ) { + return false; + } + if (schema.pattern) { + try { + if (!new RegExp(schema.pattern, "u").test(value)) { + return false; + } + } catch { + return false; + } + } + return true; + } + if (typeof value === "number") { + return ( + Number.isFinite(value) && + (schema.minimum === undefined || value >= schema.minimum) && + (schema.maximum === undefined || value <= schema.maximum) && + (schema.exclusiveMinimum === undefined || value > schema.exclusiveMinimum) && + (schema.exclusiveMaximum === undefined || value < schema.exclusiveMaximum) && + (schema.multipleOf === undefined || isNumericMultiple(value, schema.multipleOf)) + ); + } + if (Array.isArray(value)) { + if ( + (schema.minItems !== undefined && value.length < schema.minItems) || + (schema.maxItems !== undefined && value.length > schema.maxItems) || + (schema.uniqueItems === true && + value.some((item, index) => + value.slice(index + 1).some((candidate) => configValuesEqual(item, candidate)), + )) + ) { + return false; + } + const items = schema.items; + if (!Array.isArray(items)) { + return items + ? value.every((item) => validateSupportedConfigValue(items, item, active)) + : true; + } + return value.every((item, index) => { + const itemSchema = items[index]; + if (itemSchema) { + return validateSupportedConfigValue(itemSchema, item, active); + } + return schema.additionalItems && typeof schema.additionalItems === "object" + ? validateSupportedConfigValue(schema.additionalItems, item, active) + : schema.additionalItems !== false; + }); + } + if (value && typeof value === "object") { + const record = value as Record; + if ((schema.required ?? []).some((key) => !Object.hasOwn(record, key))) { + return false; + } + return Object.entries(record).every(([key, entryValue]) => { + const propertySchema = ownPropertySchema(schema, key); + if (propertySchema) { + return validateSupportedConfigValue(propertySchema, entryValue, active); + } + return schema.additionalProperties && typeof schema.additionalProperties === "object" + ? validateSupportedConfigValue(schema.additionalProperties, entryValue, active) + : schema.additionalProperties !== false; + }); + } + switch (typeof value) { + case "boolean": + return true; + default: + return value === null; + } + } finally { + activeValues.delete(value); + if (activeValues.size === 0) { + active.delete(schema); + } + } +} + +export function isSupportedConfigValueValid(schema: JsonSchema, value: unknown): boolean { + return validateSupportedConfigValue(schema, value, new Map()); +} diff --git a/ui/src/components/settings-ui.ts b/ui/src/components/settings-ui.ts index d6ef3c4c8659..49299799fd78 100644 --- a/ui/src/components/settings-ui.ts +++ b/ui/src/components/settings-ui.ts @@ -133,7 +133,7 @@ export function renderSettingsNavRow( * title is not associated with the input; prefer renderSettingsToggleRow. */ export function renderSettingsToggle(props: { checked: boolean; - onChange: (checked: boolean) => void; + onChange: (checked: boolean) => boolean | void; disabled?: boolean; ariaLabel: string; }): TemplateResult { @@ -144,7 +144,10 @@ export function renderSettingsToggle(props: { .checked=${live(props.checked)} ?disabled=${props.disabled ?? false} @change=${(event: Event) => { - props.onChange((event.currentTarget as HTMLElement & { checked: boolean }).checked); + const target = event.currentTarget as HTMLElement & { checked: boolean }; + if (props.onChange(target.checked) === false) { + target.checked = props.checked; + } }} > ${props.ariaLabel} @@ -159,7 +162,7 @@ export function renderSettingsToggleRow(props: { ariaLabel?: unknown; description?: unknown; checked: boolean; - onChange: (checked: boolean) => void; + onChange: (checked: boolean) => boolean | void; /** Runs synchronously during direct activation for effects gated on user activation. */ onAct?: (checked: boolean) => void; disabled?: boolean; @@ -205,7 +208,10 @@ export function renderSettingsToggleRow(props: { @click=${notifySwitchActivation} @keydown=${notifySwitchActivation} @change=${(event: Event) => { - props.onChange((event.currentTarget as HTMLElement & { checked: boolean }).checked); + const target = event.currentTarget as HTMLElement & { checked: boolean }; + if (props.onChange(target.checked) === false) { + target.checked = props.checked; + } }} > ${props.ariaLabel ?? props.title} diff --git a/ui/src/e2e/config-form-integrity.e2e.test.ts b/ui/src/e2e/config-form-integrity.e2e.test.ts new file mode 100644 index 000000000000..7ace3995946d --- /dev/null +++ b/ui/src/e2e/config-form-integrity.e2e.test.ts @@ -0,0 +1,190 @@ +// Control UI tests cover schema-backed form constraints, draft recovery, and accessible names. +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { chromium, type Browser } from "playwright"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + canRunPlaywrightChromium, + installMockGateway, + resolvePlaywrightChromiumExecutablePath, + startControlUiE2eServer, + type ControlUiE2eServer, +} from "../test-helpers/control-ui-e2e.ts"; + +const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); +const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); +const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1"; +const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip; + +const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"; +const proofVariant = process.env.OPENCLAW_UI_PROOF_VARIANT ?? "after"; +const uiProofArtifactDir = path.join( + process.cwd(), + ".artifacts", + "control-ui-e2e", + "config-form-integrity", + proofVariant, +); + +let browser: Browser; +let server: ControlUiE2eServer; + +function configFormIntegrityMocks() { + const config = { + laboratory: { + endpoint: "local-api", + metadata: { mode: "safe" }, + retryBudget: 4, + weights: [2], + codes: [], + }, + }; + return { + "config.get": { + appliedConfigHash: "config-form-integrity-e2e", + config, + configRevisionHash: "config-form-integrity-e2e", + hash: "config-form-integrity-e2e", + issues: [], + raw: JSON.stringify(config), + valid: true, + }, + "config.schema": { + generatedAt: "2026-07-29T00:00:00.000Z", + schema: { + type: "object", + properties: { + laboratory: { + type: "object", + title: "Form Integrity", + properties: { + endpoint: { + type: "string", + title: "Endpoint slug", + description: "Lowercase letters and hyphens only.", + minLength: 3, + maxLength: 16, + pattern: "[a-z-]+", + }, + retryBudget: { + type: "integer", + title: "Retry budget", + description: "Even values from two through eight.", + minimum: 2, + maximum: 8, + multipleOf: 2, + }, + weights: { + type: "array", + title: "Weights", + items: { type: "integer", minimum: 2, maximum: 8, multipleOf: 2 }, + }, + codes: { + type: "array", + title: "Codes", + items: { + type: "string", + minLength: 3, + pattern: "^[0-9]+$", + }, + }, + }, + additionalProperties: true, + }, + }, + }, + uiHints: {}, + version: "e2e", + }, + }; +} + +describeControlUiE2e("Control UI config form integrity mocked Gateway E2E", () => { + beforeAll(async () => { + if (!chromiumAvailable) { + throw new Error( + `Playwright Chromium is not installed or cannot start at ${chromiumExecutablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`, + ); + } + server = await startControlUiE2eServer(); + browser = await chromium.launch({ executablePath: chromiumExecutablePath }); + }); + + afterAll(async () => { + await browser?.close(); + await server?.close(); + }); + + it("keeps invalid drafts visible and exposes schema constraints to the browser", async () => { + const context = await browser.newContext({ + colorScheme: "dark", + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 1000, width: 1440 }, + }); + const page = await context.newPage(); + await installMockGateway(page, { methodResponses: configFormIntegrityMocks() }); + + try { + const response = await page.goto(`${server.baseUrl}settings/advanced?section=laboratory`); + expect(response?.status()).toBe(200); + + const endpoint = page.getByRole("textbox", { name: "Endpoint slug" }); + const retryBudget = page.getByRole("spinbutton", { name: "Retry budget" }); + const weights = page.locator(".cfg-array").filter({ hasText: "Weights" }); + const addWeight = weights.getByRole("button", { name: "Add" }); + await addWeight.click(); + + const metadataEditor = page.locator(".cfg-map textarea"); + await metadataEditor.fill('{"mode":'); + await metadataEditor.blur(); + + if (captureUiProofEnabled) { + await mkdir(uiProofArtifactDir, { recursive: true }); + await page.locator("#config-section-panel").screenshot({ + animations: "disabled", + path: path.join(uiProofArtifactDir, "01-invalid-json-draft.png"), + }); + } + + await expect.poll(() => endpoint.getAttribute("minlength")).toBeNull(); + await expect.poll(() => endpoint.getAttribute("maxlength")).toBeNull(); + await expect.poll(() => endpoint.getAttribute("pattern")).toBeNull(); + await expect.poll(() => endpoint.getAttribute("aria-describedby")).not.toBeNull(); + await expect.poll(() => retryBudget.getAttribute("min")).toBe("2"); + await expect.poll(() => retryBudget.getAttribute("max")).toBe("8"); + await expect.poll(() => retryBudget.getAttribute("step")).toBe("2"); + await expect + .poll(() => page.locator(".cfg-array input[type='number']").last().inputValue()) + .toBe("2"); + await expect.poll(() => metadataEditor.inputValue()).toBe('{"mode":'); + await expect.poll(() => metadataEditor.getAttribute("aria-invalid")).toBe("true"); + await expect.poll(() => page.getByRole("alert").textContent()).toContain("valid JSON"); + + const codes = page.locator(".cfg-array").filter({ hasText: "Codes" }); + await codes.getByRole("button", { name: "Add" }).click(); + const codeDraft = codes.locator(".cfg-collection-draft"); + await expect.poll(() => codeDraft.isVisible()).toBe(true); + const codeValue = codeDraft.getByRole("textbox", { name: "Add: Codes" }); + await codeValue.fill("abc"); + await codeDraft.getByRole("button", { name: "Add" }).click(); + await expect.poll(() => codeValue.getAttribute("aria-invalid")).toBe("true"); + await expect.poll(() => codes.locator("input[aria-label='Codes']").count()).toBe(0); + + if (captureUiProofEnabled) { + await page.locator("#config-section-panel").screenshot({ + animations: "disabled", + path: path.join(uiProofArtifactDir, "02-pattern-collection-draft.png"), + }); + } + + await codeValue.fill("123"); + await codeDraft.getByRole("button", { name: "Add" }).click(); + await expect + .poll(() => codes.locator("input[aria-label='Codes']").last().inputValue()) + .toBe("123"); + } finally { + await context.close(); + } + }); +}); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 234b95de8778..3d299704ca02 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -1086,7 +1086,12 @@ export const en: TranslationMap = { defaultValue: "Default: {value}", resetToDefault: "Reset to default", select: "Select...", + nullValue: "null", jsonValue: "JSON value", + invalidJson: "Enter valid JSON before leaving this field.", + invalidString: "Enter a value that matches this setting's constraints.", + invalidNumber: "Enter a value within the allowed range and step.", + draftRejected: "This setting could not be saved. Your draft is still here.", unsupportedArray: "Unsupported array schema. Use Raw mode.", itemCountOne: "{count} item", itemCount: "{count} items", diff --git a/ui/src/lib/config/index.test.ts b/ui/src/lib/config/index.test.ts index a192465f4464..7cff561bd552 100644 --- a/ui/src/lib/config/index.test.ts +++ b/ui/src/lib/config/index.test.ts @@ -166,7 +166,10 @@ describe("createRuntimeConfigCapability", () => { if (method === "config.get") { configGetCount += 1; return { - config: configGetCount === 1 ? { count: 1, enabled: false, tags: [1], label: "ok" } : {}, + config: + configGetCount === 1 + ? { count: 1, composedCount: 2, enabled: false, tags: [1], label: "ok" } + : {}, hash: configGetCount === 1 ? "hash-1" : "hash-2", valid: true, issues: [], @@ -178,6 +181,7 @@ describe("createRuntimeConfigCapability", () => { type: "object", properties: { count: { type: "number" }, + composedCount: { type: "number", allOf: [{ minimum: 2 }] }, enabled: { type: "boolean" }, tags: { type: "array", items: { type: "integer" } }, label: { type: "string", minLength: 1 }, @@ -195,6 +199,7 @@ describe("createRuntimeConfigCapability", () => { await Promise.all([runtimeConfig.ensureLoaded(), runtimeConfig.ensureSchemaLoaded()]); runtimeConfig.patchForm(["count"], "42.5"); + runtimeConfig.patchForm(["composedCount"], "8.5"); runtimeConfig.patchForm(["enabled"], "true"); runtimeConfig.patchForm(["tags"], ["7", ""]); runtimeConfig.patchForm(["label"], ""); @@ -204,7 +209,81 @@ describe("createRuntimeConfigCapability", () => { expect(submission?.params).toMatchObject({ baseHash: "hash-1" }); const raw = (submission?.params as { raw?: unknown } | undefined)?.raw; expect(typeof raw).toBe("string"); - expect(JSON.parse(raw as string)).toEqual({ count: 42.5, enabled: true, tags: [7] }); + expect(JSON.parse(raw as string)).toEqual({ + count: 42.5, + composedCount: 8.5, + enabled: true, + tags: [7], + }); + runtimeConfig.dispose(); + }); + + it("submits only decimal numeric spellings as numbers", async () => { + const submitted: Array<{ method: string; params: unknown }> = []; + const request = vi.fn(async (method: string, params?: unknown) => { + if (method === "config.get") { + return { + config: {}, + hash: "hash-1", + valid: true, + issues: [], + }; + } + if (method === "config.schema") { + return { + schema: { + type: "object", + properties: { + hex: { type: "number" }, + binary: { type: "integer" }, + explicitPlus: { type: "number" }, + separator: { type: "number" }, + nonFinite: { type: "number" }, + scientific: { type: "number" }, + decimal: { type: "number" }, + fractionalInteger: { type: "integer" }, + unionRadix: { anyOf: [{ type: "integer" }, { type: "string" }] }, + unionScientific: { anyOf: [{ type: "integer" }, { type: "string" }] }, + }, + }, + uiHints: {}, + }; + } + submitted.push({ method, params }); + return { hash: "hash-2" }; + }); + const client = { request } as unknown as GatewayBrowserClient; + const { gateway } = createGatewayHarness(client); + const runtimeConfig = createRuntimeConfigCapability(gateway); + + await Promise.all([runtimeConfig.ensureLoaded(), runtimeConfig.ensureSchemaLoaded()]); + runtimeConfig.patchForm(["hex"], "0x10"); + runtimeConfig.patchForm(["binary"], "0b1010"); + runtimeConfig.patchForm(["explicitPlus"], "+5"); + runtimeConfig.patchForm(["separator"], "1_000"); + runtimeConfig.patchForm(["nonFinite"], "Infinity"); + runtimeConfig.patchForm(["scientific"], "-2.5E-3"); + runtimeConfig.patchForm(["decimal"], ".5"); + runtimeConfig.patchForm(["fractionalInteger"], "42.5"); + runtimeConfig.patchForm(["unionRadix"], "0o17"); + runtimeConfig.patchForm(["unionScientific"], "1e5"); + + await expect(runtimeConfig.save()).resolves.toBe(true); + const submission = submitted.find((entry) => entry.method === "config.set"); + const raw = (submission?.params as { raw?: unknown } | undefined)?.raw; + expect(typeof raw).toBe("string"); + expect(JSON.parse(raw as string)).toEqual({ + hex: "0x10", + binary: "0b1010", + explicitPlus: "+5", + separator: "1_000", + nonFinite: "Infinity", + scientific: -0.0025, + decimal: 0.5, + fractionalInteger: "42.5", + unionRadix: "0o17", + unionScientific: 100_000, + }); runtimeConfig.dispose(); }); diff --git a/ui/src/lib/config/index.ts b/ui/src/lib/config/index.ts index dc247e670051..88179078bc5f 100644 --- a/ui/src/lib/config/index.ts +++ b/ui/src/lib/config/index.ts @@ -2,6 +2,7 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { ConfigSchemaResponse, ConfigSnapshot, ConfigUiHints } from "../../api/types.ts"; import type { ApplicationGatewayPhase } from "../../app/gateway.ts"; +import { coerceConfigFormNumberString } from "../../components/config-form.constraints.ts"; import { schemaType, type JsonSchema } from "../../components/config-form.shared.ts"; import { t } from "../../i18n/index.ts"; import { copyToClipboard } from "../clipboard.ts"; @@ -386,21 +387,6 @@ function asJsonSchema(value: unknown): JsonSchema | null { return value as JsonSchema; } -function coerceNumberString(value: string, integer: boolean): number | undefined | string { - const trimmed = value.trim(); - if (trimmed === "") { - return undefined; - } - const parsed = Number(trimmed); - if (!Number.isFinite(parsed)) { - return value; - } - if (integer && !Number.isInteger(parsed)) { - return value; - } - return parsed; -} - function coerceBooleanString(value: string): boolean | string { const trimmed = value.trim(); if (trimmed === "true") { @@ -418,8 +404,9 @@ function coerceFormValues(value: unknown, schema: JsonSchema): unknown { } if (schema.allOf && schema.allOf.length > 0) { - let next: unknown = value; - for (const segment of schema.allOf) { + const { allOf, ...baseSchema } = schema; + let next: unknown = coerceFormValues(value, baseSchema); + for (const segment of allOf) { next = coerceFormValues(next, segment); } return next; @@ -443,7 +430,7 @@ function coerceFormValues(value: unknown, schema: JsonSchema): unknown { for (const variant of variants) { const variantType = schemaType(variant); if (variantType === "number" || variantType === "integer") { - const coerced = coerceNumberString(value, variantType === "integer"); + const coerced = coerceConfigFormNumberString(value, variantType === "integer"); if (coerced === undefined || typeof coerced === "number") { return coerced; } @@ -470,7 +457,7 @@ function coerceFormValues(value: unknown, schema: JsonSchema): unknown { if (type === "number" || type === "integer") { if (typeof value === "string") { - const coerced = coerceNumberString(value, type === "integer"); + const coerced = coerceConfigFormNumberString(value, type === "integer"); if (coerced === undefined || typeof coerced === "number") { return coerced; } diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css index 05c515181d59..25fb0c73033f 100644 --- a/ui/src/styles/config.css +++ b/ui/src/styles/config.css @@ -614,6 +614,53 @@ color: var(--danger); } +.cfg-structured-draft { + display: contents; +} + +.cfg-structured-draft__error .settings-row__control { + width: 100%; +} + +.cfg-json-editor { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 6px; + min-width: 0; + width: 100%; +} + +.cfg-json-editor > .settings-input, +.cfg-json-editor > .settings-secret { + width: 100%; +} + +.cfg-array .settings-row--stacked .settings-row__control { + width: 100%; +} + +.cfg-array .settings-row--stacked .settings-row__control > input[type="number"] { + flex: 1 1 auto; + width: auto; +} + +.cfg-collection-draft__controls { + display: grid; + gap: 8px; + width: 100%; +} + +.cfg-collection-draft__actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.config-content :is(input, textarea, select)[aria-invalid="true"] { + border-color: var(--danger); +} + /* Redacted click-to-reveal inputs/textareas. */ .cfg-redacted { opacity: 0.7;