fix(ui): preserve schema-backed settings edits (#116282)

* fix(ui): harden schema-backed settings controls

Co-authored-by: wangmiao0668000666 <wang.miao86@xydigit.com>

* 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 <wang.miao86@xydigit.com>
This commit is contained in:
Vincent Koc
2026-07-30 23:08:53 +08:00
committed by GitHub
parent ca1328dddb
commit 183db47e97
30 changed files with 7195 additions and 139 deletions

View File

@@ -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 };
}

View File

@@ -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);
});
});

View File

@@ -0,0 +1,53 @@
// Control UI helpers preserve repeated-row draft ownership across array edits.
const arrayRowIdentities = new WeakMap<unknown[], readonly unknown[]>();
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<string, number>();
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]);
}

View File

@@ -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<T extends Element>(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<HTMLButtonElement>("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<HTMLButtonElement>("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<HTMLButtonElement>("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<HTMLButtonElement>("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<HTMLButtonElement>("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<HTMLButtonElement>("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<HTMLButtonElement>("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<ConfigFormCollectionDraft>("openclaw-config-form-collection-draft"),
"unique array draft",
);
await draftHost.updateComplete;
expectElement(
Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
(button) => button.textContent?.trim() === "Add",
),
"unique array add",
).click();
await draftHost.updateComplete;
const draftValue = expectElement(
draftHost.querySelector<HTMLInputElement>("[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<HTMLButtonElement>("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<ConfigFormCollectionDraft>("openclaw-config-form-collection-draft"),
"duplicate array draft",
);
await duplicateDraft.updateComplete;
expectElement(
Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
(button) => button.textContent?.trim() === "Add",
),
"duplicate array add",
).click();
await duplicateDraft.updateComplete;
const duplicateValue = expectElement(
duplicateDraft.querySelector<HTMLInputElement>("[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<HTMLButtonElement>("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<ConfigFormCollectionDraft>("openclaw-config-form-collection-draft"),
"composed item draft",
);
await composedDraft.updateComplete;
expectElement(
Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
(button) => button.textContent?.trim() === "Add",
),
"composed item add",
).click();
await composedDraft.updateComplete;
const composedValue = expectElement(
composedDraft.querySelector<HTMLInputElement>("[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<HTMLButtonElement>("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<HTMLInputElement>(".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<HTMLElement & { checked: boolean }>("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<HTMLInputElement>(".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<HTMLInputElement>(".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<HTMLButtonElement>("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<HTMLButtonElement>("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<HTMLButtonElement>("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<HTMLButtonElement>("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<HTMLTextAreaElement>("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<HTMLTextAreaElement>("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<HTMLTextAreaElement>("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<HTMLInputElement>("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<HTMLButtonElement>("button[aria-label='Remove item']"),
"first equal scalar row remove",
).click();
const remainingScalar = expectElement(
scalarContainer.querySelector<HTMLInputElement>("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<HTMLTextAreaElement>("textarea"),
"first equal JSON row",
);
jsonDraft.value = "{";
jsonDraft.dispatchEvent(new Event("input", { bubbles: true }));
expect(jsonDraft.getAttribute("aria-invalid")).toBe("true");
expectElement(
jsonContainer.querySelector<HTMLButtonElement>("button[aria-label='Remove item']"),
"first equal JSON row remove",
).click();
const remainingJson = expectElement(
jsonContainer.querySelector<HTMLTextAreaElement>("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<ConfigFormCollectionDraft>("openclaw-config-form-collection-draft"),
"nullable scalar collection draft",
);
expectElement(
Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
(button) => button.textContent?.trim() === "Add",
),
"nullable scalar array add",
).click();
await draftHost.updateComplete;
const nullToggle = expectElement(
draftHost.querySelector<HTMLInputElement>("[data-collection-draft-null]"),
"nullable scalar null toggle",
);
nullToggle.checked = true;
nullToggle.dispatchEvent(new Event("change", { bubbles: true }));
await draftHost.updateComplete;
expect(
draftHost.querySelector<HTMLInputElement>("[data-collection-draft-value]")?.disabled,
).toBe(true);
expectElement(
Array.from(draftHost.querySelectorAll<HTMLButtonElement>("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<HTMLInputElement>("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<HTMLInputElement>("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<HTMLButtonElement>("button[aria-label='Remove item']"),
"first object row remove",
);
firstRemove.click();
const shiftedInput = expectElement(
container.querySelector<HTMLInputElement>("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<HTMLTextAreaElement>("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<HTMLButtonElement>("button")).find(
(button) => button.textContent?.trim() === "Add",
);
expect(addButton?.disabled).toBe(false);
});
});

View File

@@ -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<this>): 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<HTMLElement>("[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<HTMLElement>(
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<ConfigFormCollectionDraftCommit>("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<HTMLInputElement>("[data-collection-draft-key]");
const valueInput = this.querySelector<HTMLInputElement | HTMLTextAreaElement>(
"[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`
<input
data-collection-draft-value
type=${valueType === "string" ? "text" : "number"}
class="settings-input"
aria-label=${valueLabel}
aria-describedby=${errorId}
aria-invalid=${this.invalidTarget === "value" ? "true" : "false"}
.value=${this.draftValue}
?disabled=${this.draftIsNull}
@input=${(event: Event) => {
this.draftValue = (event.currentTarget as HTMLInputElement).value;
this.clearError();
}}
/>
`
: html`
<textarea
data-collection-draft-value
class="settings-input"
aria-label=${valueLabel}
aria-describedby=${errorId}
aria-invalid=${this.invalidTarget === "value" ? "true" : "false"}
placeholder=${t("configForm.jsonValue")}
rows="2"
.value=${this.draftValue}
?disabled=${this.draftIsNull}
@input=${(event: Event) => {
this.draftValue = (event.currentTarget as HTMLTextAreaElement).value;
this.clearError();
}}
></textarea>
`;
return html`
<div class="settings-row settings-row--stacked cfg-collection-draft">
<div class="settings-row__control">
<div class="cfg-collection-draft__controls">
${props.existingKeys
? html`
<input
data-collection-draft-key
type="text"
class="settings-input"
aria-label=${t("configForm.key")}
aria-describedby=${errorId}
aria-invalid=${this.invalidTarget === "key" ? "true" : "false"}
placeholder=${t("configForm.key")}
.value=${this.draftKey}
@input=${(event: Event) => {
this.draftKey = (event.currentTarget as HTMLInputElement).value;
this.clearError();
}}
/>
`
: nothing}
${canUseNull
? html`
<label class="field checkbox">
<input
data-collection-draft-null
type="checkbox"
.checked=${this.draftIsNull}
@change=${(event: Event) => {
this.draftIsNull = (event.currentTarget as HTMLInputElement).checked;
this.clearError();
}}
/>
<span>${t("configForm.nullValue")}</span>
</label>
`
: nothing}
${valueControl}
<span id=${errorId} class="cfg-field__error" role="alert" ?hidden=${!this.error}
>${this.error}</span
>
<div class="cfg-collection-draft__actions">
<button type="button" class="btn btn--sm" @click=${() => this.commit()}>
${props.existingKeys ? t("configForm.addEntry") : t("configForm.add")}
</button>
<button type="button" class="btn btn--sm" @click=${() => this.closeDraft()}>
${t("common.cancel")}
</button>
</div>
</div>
</div>
</div>
`;
}
}
if (!customElements.get("openclaw-config-form-collection-draft")) {
customElements.define("openclaw-config-form-collection-draft", ConfigFormCollectionDraft);
}

View File

@@ -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<HTMLInputElement>("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<HTMLSelectElement>("select[aria-label='Mode']");
expect(select).not.toBeNull();
if (!select) {
return;
}
expect(select.querySelector<HTMLOptionElement>("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<HTMLTextAreaElement>("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<HTMLInputElement>(".cfg-array input"));
expect(inputs.map((input) => input.type)).toEqual(["text", "number"]);
const add = Array.from(container.querySelectorAll<HTMLButtonElement>("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<HTMLInputElement>("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<HTMLButtonElement>("button")).some(
(button) => button.textContent?.trim() === "Add Entry",
),
).toBe(true);
const custom = container.querySelector<HTMLInputElement>("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<HTMLButtonElement>("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<HTMLInputElement>("input[aria-label='Nested']");
const numeric = container.querySelector<HTMLInputElement>("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<HTMLButtonElement>("button")).find(
(button) => button.textContent?.trim() === "Add",
);
expect(add?.disabled).toBe(true);
const remove = container.querySelector<HTMLButtonElement>("button[aria-label='Remove item']");
expect(remove?.disabled).toBe(false);
remove?.click();
expect(onPatch).toHaveBeenCalledWith(["empty"], []);
});
});

View File

@@ -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<string, unknown>;
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<string, Record<string, unknown>>;
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<string, Record<string, unknown>>;
expect(Object.hasOwn(patched, "__proto__")).toBe(true);
expect(Object.getOwnPropertyDescriptor(patched, "__proto__")?.value).toEqual({
value: "safe",
});
expect(Object.getPrototypeOf(patched)).toBe(Object.prototype);
});
});

View File

@@ -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<string | number>,
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<string, unknown>) } : {};
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<string | number>,
replacement: unknown,
): PathPatchResult {
if (path.length === 0) {
return { ok: true, value: replacement };
}
return patchPathValue(current, path, 0, replacement);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<T extends Element>(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<string, unknown> | 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<HTMLElement>(".cfg-map"), "unset map");
const draftHost = expectElement(
map.querySelector<ConfigFormCollectionDraft>("openclaw-config-form-collection-draft"),
"unset map draft host",
);
expectElement(
Array.from(map.querySelectorAll<HTMLButtonElement>("button")).find(
(button) => button.textContent?.trim() === "Add Entry",
),
"unset map add button",
).click();
await draftHost.updateComplete;
const key = expectElement(
draftHost.querySelector<HTMLInputElement>("[data-collection-draft-key]"),
"unset map draft key",
);
const value = expectElement(
draftHost.querySelector<HTMLInputElement>("[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<HTMLInputElement>("[data-collection-draft-key]"),
"preserved unset map draft key",
).value,
).toBe("primary");
expect(
expectElement(
draftHost.querySelector<HTMLInputElement>("[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();
});
});

View File

@@ -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<T extends Element>(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<HTMLTextAreaElement>("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<HTMLInputElement>("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<HTMLTextAreaElement>("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 } }]);
});
});

View File

@@ -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<unknown>;
};
function expectElement<T extends Element>(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<HTMLInputElement>("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<HTMLSelectElement>("select[aria-label='Mode']"),
"mode select",
);
mode.value = "1";
mode.dispatchEvent(new Event("change", { bubbles: true }));
const rerenderedName = expectElement(
container.querySelector<HTMLInputElement>("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<HTMLElement>(".cfg-array"));
const secondGroup = expectElement(arrays[2], "second nested array");
const draft = expectElement(
secondGroup.querySelector<ConfigFormCollectionDraft>("openclaw-config-form-collection-draft"),
"second nested array draft",
);
await draft.updateComplete;
expectElement(
Array.from(secondGroup.querySelectorAll<HTMLButtonElement>("button")).find(
(button) => button.textContent?.trim() === "Add",
),
"second nested array add",
).click();
await draft.updateComplete;
const value = expectElement(
draft.querySelector<HTMLInputElement>("[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<HTMLButtonElement>("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<HTMLInputElement>("[data-collection-draft-value]"),
"retained nested array draft value",
);
expect(retainedValue.value).toBe("alpha");
expect(retainedValue.getAttribute("aria-invalid")).toBe("true");
expect(draft.querySelector<HTMLElement>("[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<HTMLElement>(".cfg-array"));
const secondGroup = expectElement(arrays[2], "second auto-default array");
const draft = expectElement(
secondGroup.querySelector<ConfigFormCollectionDraft>("openclaw-config-form-collection-draft"),
"second auto-default array draft",
);
await draft.updateComplete;
expectElement(
Array.from(secondGroup.querySelectorAll<HTMLButtonElement>("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<HTMLInputElement>("[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<HTMLButtonElement>("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<HTMLElement>(".cfg-map"));
const secondMap = expectElement(maps[1], "second auto-default map");
const draft = expectElement(
secondMap.querySelector<ConfigFormCollectionDraft>("openclaw-config-form-collection-draft"),
"second auto-default map draft",
);
await draft.updateComplete;
expectElement(
Array.from(secondMap.querySelectorAll<HTMLButtonElement>("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<HTMLInputElement>("[data-collection-draft-key]"),
"map fallback draft key",
);
const value = expectElement(
draft.querySelector<HTMLTextAreaElement>("[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<HTMLButtonElement>("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<HTMLInputElement>("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<string, unknown> = {};
const onPatch = vi.fn((path: Array<string | number>, 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<ConfigFormStructuredDraftElement>(
"openclaw-config-form-structured-draft",
),
"optional object draft",
);
await draft.updateComplete;
const host = expectElement(
draft.querySelector<HTMLInputElement>("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<HTMLInputElement>("input[aria-label='Host']"),
"preserved optional host",
).value,
).toBe("gateway.local");
const port = expectElement(
draft.querySelector<HTMLInputElement>("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<ConfigFormStructuredDraftElement>(
"openclaw-config-form-structured-draft",
),
"rejected optional object draft",
);
await draft.updateComplete;
const host = expectElement(
draft.querySelector<HTMLInputElement>("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<HTMLInputElement>("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<HTMLInputElement>("input[aria-label='Host']"),
"retained rejected host",
).value,
).toBe("gateway.local");
expect(
expectElement(
draft.querySelector<HTMLInputElement>("input[aria-label='Port']"),
"retained rejected port",
).value,
).toBe("18789");
expect(draft.querySelector<HTMLElement>("[role='alert']")?.textContent).toContain(
"draft is still here",
);
renderValue();
await draft.updateComplete;
expect(
expectElement(
draft.querySelector<HTMLInputElement>("input[aria-label='Host']"),
"rerendered rejected host",
).value,
).toBe("gateway.local");
expect(draft.querySelector<HTMLElement>("[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<string | number>, _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<HTMLButtonElement>("button")).find(
(button) => button.textContent?.trim() === "Add",
),
"large-minimum array add",
);
renderValue();
const draft = expectElement(
container.querySelector<ConfigFormStructuredDraftElement>(
"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<HTMLElement>("[role='alert']")?.textContent).toContain(
"draft is still here",
);
renderValue();
await draft.updateComplete;
expect(draft.textContent).toContain("101 items");
expect(draft.querySelector<HTMLElement>("[role='alert']")?.textContent).toContain(
"draft is still here",
);
container.remove();
});
});

View File

@@ -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<T extends Element>(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<HTMLInputElement>("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<HTMLSelectElement>("select"),
"required nullable enum",
);
const nullOption = expectElement(
nullableSelect.querySelector<HTMLOptionElement>("option[value='__null__']"),
"nullable enum null option",
);
expect(nullOption.disabled).toBe(false);
expect(
nullableSelect.querySelector<HTMLOptionElement>("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<HTMLSelectElement>("select"),
"required non-null enum",
);
expect(
requiredSelect.querySelector<HTMLOptionElement>("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<HTMLSelectElement>("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);
});
});

View File

@@ -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<string, unknown> | unknown[];
params: ConfigNodeRenderParams;
renderNode: ConfigNodeRenderer;
};
function cloneDraftValue(value: Record<string, unknown> | unknown[]) {
return structuredClone(value);
}
export function structuredDraftInitialValue(
params: ConfigNodeRenderParams,
): Record<string, unknown> | 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<string, unknown> | unknown[]);
}
return type === "object" ? {} : [];
}
export function shouldStageStructuredDraft(
params: ConfigNodeRenderParams,
initialValue: Record<string, unknown> | unknown[] | undefined,
): initialValue is Record<string, unknown> | 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<string, unknown> | unknown[] | undefined;
@state() private error = "";
protected override willUpdate(changedProperties: PropertyValues<this>): 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<string | number>, 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<string, unknown> | 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`
<div class="settings-row settings-row--stacked cfg-structured-draft__error">
<div class="settings-row__control">
<span id=${errorId} class="cfg-field__error" role="alert">${this.error}</span>
</div>
</div>
`
: nothing}
`;
}
}
if (!customElements.get("openclaw-config-form-structured-draft")) {
customElements.define("openclaw-config-form-structured-draft", ConfigFormStructuredDraft);
}

View File

@@ -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<JsonSchema>()): Set<string> {
if (seen.has(schema)) {
return new Set();
}
seen.add(schema);
const types = new Set<string>();
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>): 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<string>): 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<JsonSchema>()): 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<JsonSchema>();
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<JsonSchema>();
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: ["<root>"] };
@@ -47,41 +237,121 @@ export function analyzeConfigSchema(raw: unknown): ConfigSchemaAnalysis {
function normalizeSchemaNode(
schema: JsonSchema,
path: Array<string | number>,
compositionBranch = false,
inheritedCompositionType?: string,
inheritedCompositionAllowsNull?: boolean,
): ConfigSchemaAnalysis {
const unsupported = new Set<string>();
const normalized: JsonSchema = { ...schema };
const pathLabel = pathKey(path) || "<root>";
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<string, JsonSchema> = {};
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]) || "<root>");
}
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<string | number>,
): 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,

View File

@@ -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<string, unknown> = { 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<string, unknown> = {};
selfCycle.next = selfCycle;
const first: Record<string, unknown> = {};
const second: Record<string, unknown> = {};
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"]);
});
});

View File

@@ -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<JsonSchema>();
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<string> {
return new Set(collectAllOfSchemas(schema).flatMap((entry) => entry.required ?? []));
}
export function objectPropertyKeys(schema: JsonSchema): string[] {
const schemas = collectAllOfSchemas(schema);
const keys = new Set<string>();
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<string, unknown>): 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<string, unknown>,
candidate: Record<string, unknown>,
): 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<JsonSchema>(),
): 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<string, unknown> = {};
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, "");
}
}

View File

@@ -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<ConfigFormCollectionDraft> | 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<string, unknown>)
: {};
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<string | number>, childValue: unknown) => {
if (
childPath.length < path.length ||
!path.every((segment, index) => segment === childPath[index])
) {
return false;
}
let candidate: Record<string, unknown>;
const relativePath = childPath.slice(path.length);
if (relativePath.length === 0) {
if (!childValue || typeof childValue !== "object" || Array.isArray(childValue)) {
return false;
}
candidate = childValue as Record<string, unknown>;
} 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<string | number>, 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`
<div class="cfg-block cfg-array">
@@ -213,13 +382,57 @@ export function renderArray(
<button
type="button"
class="btn btn--sm"
?disabled=${disabled}
@click=${() => onPatch(path, [...arrayValue, defaultValue(itemsSchema)])}
aria-controls=${draftId}
?disabled=${disabled || (!canAppend && atomicCandidate === undefined)}
@click=${(event: Event) => {
if (atomicCandidate) {
if (onPatch(path, atomicCandidate) === false) {
openCollectionDraft(event, draftId);
}
} else if (requiresDraft) {
openCollectionDraft(event, draftId);
} else if (autoCandidate) {
appendArrayRowIdentities(
autoCandidate,
rowIdentities,
autoCandidate.length - arrayValue.length,
);
if (onPatch(path, autoCandidate) === false) {
discardArrayRowIdentities(autoCandidate);
openCollectionDraft(event, draftId);
}
}
}}
>
${t("configForm.add")}
</button>
</div>
</div>
<openclaw-config-form-collection-draft
id=${draftId}
.props=${draftProps}
@config-collection-draft-commit=${(event: CustomEvent<ConfigFormCollectionDraftCommit>) => {
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();
}
}}
></openclaw-config-form-collection-draft>
${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(
</div>
</div>
${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(
<button
type="button"
class="btn btn--sm"
aria-controls=${draftId}
?disabled=${disabled}
@click=${() => {
@click=${(event: Event) => {
if (entryDefault === NO_SAFE_DEFAULT) {
openCollectionDraft(event, draftId);
return;
}
const nextValue = { ...value };
let index = 1;
let key = `custom-${index}`;
@@ -328,8 +583,10 @@ function renderMapField(
index += 1;
key = `custom-${index}`;
}
nextValue[key] = anySchema ? {} : defaultValue(schema);
onPatch(path, nextValue);
nextValue[key] = entryDefault;
if (onPatch(path, nextValue) === false) {
openCollectionDraft(event, draftId);
}
}}
>
${t("configForm.addEntry")}
@@ -337,6 +594,21 @@ function renderMapField(
</div>
</div>
<openclaw-config-form-collection-draft
id=${draftId}
.props=${draftProps}
@config-collection-draft-commit=${(event: CustomEvent<ConfigFormCollectionDraftCommit>) => {
const key = event.detail.key;
if (
!key ||
Object.hasOwn(value, key) ||
reservedKeys.has(key) ||
onPatch(path, { ...value, [key]: event.detail.value }) === false
) {
event.preventDefault();
}
}}
></openclaw-config-form-collection-draft>
${visibleEntries.length === 0
? renderSettingsEmpty(t("configForm.noCustomEntries"))
: html`
@@ -358,21 +630,26 @@ function renderMapField(
type="text"
class="settings-input"
placeholder=${t("configForm.key")}
aria-label=${t("configForm.key")}
aria-label=${`${t("configForm.key")}: ${key}`}
.value=${key}
?disabled=${disabled}
@change=${(event: Event) => {
const nextKey = (event.target as HTMLInputElement).value.trim();
const target = event.target as HTMLInputElement;
const nextKey = target.value.trim();
if (!nextKey || nextKey === key) {
target.value = key;
return;
}
const nextValue = { ...value };
if (nextKey in nextValue) {
target.value = key;
return;
}
nextValue[nextKey] = nextValue[key];
delete nextValue[key];
onPatch(path, nextValue);
if (onPatch(path, nextValue) === false) {
target.value = key;
}
}}
/>
</div>
@@ -402,11 +679,16 @@ function renderMapField(
showLabel: false,
stacked: true,
control: renderJsonTextareaControl({
schema,
path: valuePath,
ariaLabel: `${key}: ${t("configForm.jsonValue")}`,
sourceValue: entryValue,
rowIdentity: params.rowIdentity,
fallback,
rows: 2,
sensitiveState,
disabled,
isRequired: true,
onToggleSensitivePath,
onPatch,
}),
@@ -419,6 +701,10 @@ function renderMapField(
rawAvailable,
unsupported,
disabled,
isRequired: true,
sourceIdentity: entryValue,
controlIdentity: value,
rowIdentity: params.rowIdentity,
searchCriteria,
showLabel: false,
revealSensitive,

View File

@@ -1,8 +1,14 @@
// Control UI renderers for scalar config form nodes.
import { formatInternationalPhoneNumberForDisplay } from "@openclaw/normalization-core/phone-presentation";
import { html, nothing, type TemplateResult } from "lit";
import { ref } from "lit/directives/ref.js";
import { i18n, t } from "../i18n/index.ts";
import { formatUnknownText } from "../lib/format.ts";
import {
isSupportedConfigValueValid,
normalizeNumericValue,
numericInputConstraints,
} from "./config-form.constraints.ts";
import {
getSensitiveRenderState,
isSecretRefObject,
@@ -13,7 +19,79 @@ import {
type ConfigNodeRenderParams,
} from "./config-form.node.shared.ts";
import { resolveConfigFieldMeta as resolveFieldMeta } from "./config-form.search.ts";
import { hintForPath, redactedPlaceholder } from "./config-form.shared.ts";
import { configFieldId, hintForPath, redactedPlaceholder } from "./config-form.shared.ts";
const scalarInputState = new WeakMap<
HTMLInputElement,
{
controlIdentity: unknown;
sourceIdentity: unknown;
rowIdentity: unknown;
pathKey: string;
presentationIdentity: string;
renderedValue: string;
}
>();
function setControlValidity(target: HTMLInputElement, message: string): boolean {
target.setCustomValidity(message);
target.setAttribute("aria-invalid", String(Boolean(message)));
return !message;
}
function syncScalarInputIdentity(
element: Element | undefined,
controlIdentity: unknown,
sourceIdentity: unknown,
rowIdentity: unknown,
pathKey: string,
presentationIdentity: string,
renderedValue: string,
revalidate: (target: HTMLInputElement) => void,
): void {
if (!(element instanceof HTMLInputElement)) {
return;
}
const previous = scalarInputState.get(element);
if (previous) {
if (
!Object.is(previous.sourceIdentity, sourceIdentity) ||
!Object.is(previous.rowIdentity, rowIdentity) ||
previous.pathKey !== pathKey ||
previous.presentationIdentity !== presentationIdentity ||
previous.renderedValue !== renderedValue
) {
element.value = renderedValue;
setControlValidity(element, "");
} else if (!Object.is(previous.controlIdentity, controlIdentity)) {
revalidate(element);
}
}
scalarInputState.set(element, {
controlIdentity,
sourceIdentity,
rowIdentity,
pathKey,
presentationIdentity,
renderedValue,
});
}
function stringConstraintMessage(value: string, schema: ConfigNodeRenderParams["schema"]): string {
return isSupportedConfigValueValid(schema, value) ? "" : t("configForm.invalidString");
}
function shouldClearOptionalEmpty(
value: string,
schema: ConfigNodeRenderParams["schema"],
isRequired: boolean,
): boolean {
return value === "" && !isRequired && Boolean(stringConstraintMessage(value, schema));
}
function numericConstraintMessage(value: number, schema: ConfigNodeRenderParams["schema"]): string {
return isSupportedConfigValueValid(schema, value) ? "" : t("configForm.invalidNumber");
}
export function renderTextInput(
params: ConfigNodeRenderParams & { inputType: "text" | "number" },
@@ -22,6 +100,7 @@ export function renderTextInput(
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const helpId = showLabel && help ? configFieldId(path, "description") : undefined;
const sensitiveState = getSensitiveRenderState({
path,
value,
@@ -55,13 +134,67 @@ export function renderTextInput(
isPhonePresentation && !effectiveRedacted && typeof value === "string"
? formatInternationalPhoneNumberForDisplay(value, i18n.getLocale())
: undefined;
const controlIdentity = params.controlIdentity ?? params.sourceIdentity ?? value;
const sourceIdentity = params.sourceIdentity ?? value;
const controlPathKey = configFieldId(path, "scalar-identity");
const renderedValue = formatUnknownText(displayValue);
const presentationIdentity = [
effectiveRedacted ? "redacted" : "visible",
effectiveInputType,
isPhonePresentation ? "phone" : "plain",
isStructuredSecretRef ? (rawAvailable ? "secret-raw" : "secret-file") : "scalar",
].join(":");
const revalidate = (target: HTMLInputElement) => {
if (effectiveRedacted) {
setControlValidity(target, "");
return;
}
if (inputType === "number") {
const raw = target.value;
setControlValidity(
target,
raw.trim() === ""
? params.isRequired
? t("configForm.invalidNumber")
: ""
: numericConstraintMessage(Number(raw), schema),
);
return;
}
const raw = target.value;
const optionalEmpty = shouldClearOptionalEmpty(raw, schema, params.isRequired === true);
setControlValidity(target, optionalEmpty ? "" : stringConstraintMessage(raw, schema));
};
const commitScalarValue = (target: HTMLInputElement, candidate: unknown) => {
if (onPatch(path, candidate) !== false) {
return true;
}
target.value = renderedValue;
revalidate(target);
return false;
};
const inputControl = html`
<input
${ref((element) =>
syncScalarInputIdentity(
element,
controlIdentity,
sourceIdentity,
params.rowIdentity,
controlPathKey,
presentationIdentity,
renderedValue,
revalidate,
),
)}
type=${effectiveInputType}
class="settings-input${effectiveRedacted ? " cfg-redacted" : ""}"
aria-label=${label}
aria-describedby=${helpId ?? nothing}
aria-invalid="false"
placeholder=${placeholder}
.value=${formatUnknownText(displayValue)}
.value=${renderedValue}
?disabled=${disabled}
?readonly=${effectiveRedacted}
@click=${() => {
@@ -73,24 +206,58 @@ export function renderTextInput(
if (effectiveRedacted) {
return;
}
const raw = (event.target as HTMLInputElement).value;
const target = event.target as HTMLInputElement;
const raw = target.value;
if (inputType === "number") {
if (raw.trim() === "") {
onPatch(path, undefined);
if (params.isRequired) {
setControlValidity(target, t("configForm.invalidNumber"));
} else {
setControlValidity(target, "");
commitScalarValue(target, undefined);
}
return;
}
const parsed = Number(raw);
onPatch(path, Number.isNaN(parsed) ? raw : parsed);
if (setControlValidity(target, numericConstraintMessage(parsed, schema))) {
commitScalarValue(target, Number.isNaN(parsed) ? raw : parsed);
}
return;
}
onPatch(path, raw);
if (shouldClearOptionalEmpty(raw, schema, params.isRequired === true)) {
setControlValidity(target, "");
commitScalarValue(target, undefined);
} else if (setControlValidity(target, stringConstraintMessage(raw, schema))) {
commitScalarValue(target, raw);
}
}}
@change=${(event: Event) => {
if (inputType === "number" || effectiveRedacted) {
return;
}
const raw = (event.target as HTMLInputElement).value;
onPatch(path, raw.trim());
const target = event.target as HTMLInputElement;
const raw = target.value;
const rawMessage = stringConstraintMessage(raw, schema);
if (!rawMessage && !isPhonePresentation) {
setControlValidity(target, "");
commitScalarValue(target, raw);
return;
}
const normalized = raw.trim();
if (shouldClearOptionalEmpty(normalized, schema, params.isRequired === true)) {
target.value = normalized;
setControlValidity(target, "");
commitScalarValue(target, undefined);
return;
}
const normalizedMessage = stringConstraintMessage(normalized, schema);
if (normalizedMessage) {
setControlValidity(target, rawMessage);
return;
}
target.value = normalized;
setControlValidity(target, "");
commitScalarValue(target, normalized);
}}
/>
`;
@@ -133,51 +300,128 @@ export function renderTextInput(
: nothing}
`;
return renderFieldRow({ label, help, tags, showLabel, control });
return renderFieldRow({ label, help, helpId, tags, showLabel, control });
}
export function renderNumberInput(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 displayValue = value ?? schema.default ?? "";
const constraints = numericInputConstraints(schema);
const numericStep = typeof constraints.step === "number" ? constraints.step : 1;
const controlIdentity = params.controlIdentity ?? params.sourceIdentity ?? value;
const sourceIdentity = params.sourceIdentity ?? value;
const controlPathKey = configFieldId(path, "scalar-identity");
const renderedValue = formatUnknownText(displayValue);
const revalidate = (target: HTMLInputElement) => {
const raw = target.value;
setControlValidity(
target,
raw === ""
? params.isRequired
? t("configForm.invalidNumber")
: ""
: numericConstraintMessage(Number(raw), schema),
);
};
const commitScalarValue = (target: HTMLInputElement, candidate: unknown) => {
if (onPatch(path, candidate) !== false) {
return true;
}
target.value = renderedValue;
revalidate(target);
return false;
};
// Touch devices and some browsers hide native number spinners; keep explicit
// one-step adjust buttons so single-step edits stay possible without typing.
const step = (delta: number) => {
// adjust buttons so schema-sized edits stay possible without typing.
const step = (direction: -1 | 1) => {
if (disabled) {
return;
}
const current = Number(displayValue);
const base = Number.isFinite(current) ? current : 0;
onPatch(path, base + delta);
const base = Number.isFinite(current) ? current : normalizeNumericValue(0, schema);
const candidate = normalizeNumericValue(base + direction * numericStep, schema);
if (isSupportedConfigValueValid(schema, candidate)) {
onPatch(path, candidate);
}
};
const control = html`
<button
type="button"
class="btn btn--sm btn--icon"
aria-label=${`${label}: -1`}
aria-label=${`${label}: -${numericStep}`}
?disabled=${disabled}
@click=${() => step(-1)}
>
</button>
<input
${ref((element) =>
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);
}
}}
/>
<button
type="button"
class="btn btn--sm btn--icon"
aria-label=${`${label}: +1`}
aria-label=${`${label}: +${numericStep}`}
?disabled=${disabled}
@click=${() => step(1)}
>
@@ -185,7 +429,7 @@ export function renderNumberInput(params: ConfigNodeRenderParams): TemplateResul
</button>
`;
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`
<select
class="settings-select"
aria-label=${label}
aria-describedby=${helpId ?? nothing}
?disabled=${disabled}
.value=${currentIndex >= 0 ? String(currentIndex) : unset}
.value=${selectedValue}
@change=${(event: Event) => {
const selectedValue = (event.target as HTMLSelectElement).value;
onPatch(path, selectedValue === unset ? undefined : options[Number(selectedValue)]);
const target = event.target as HTMLSelectElement;
const nextSelection = target.value;
if (nextSelection === unset && params.isRequired) {
target.value = selectedValue;
return;
}
const candidate =
nextSelection === unset
? undefined
: nextSelection === nullValue
? null
: options[Number(nextSelection)];
if (onPatch(path, candidate) === false) {
target.value = selectedValue;
}
}}
>
<option value=${unset} ?selected=${currentIndex < 0}>${t("configForm.select")}</option>
<option value=${unset} ?selected=${selectedValue === unset} ?disabled=${params.isRequired}>
${t("configForm.select")}
</option>
${canSelectNull
? html`
<option value=${nullValue} ?selected=${selectedValue === nullValue}>
${t("configForm.nullValue")}
</option>
`
: nothing}
${options.map(
(option, index) => html`
<option value=${String(index)} ?selected=${index === currentIndex}>
@@ -221,5 +498,5 @@ export function renderSelect(
</select>
`;
return renderFieldRow({ label, help, tags, showLabel, control });
return renderFieldRow({ label, help, helpId, tags, showLabel, control });
}

View File

@@ -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<string>;
disabled: boolean;
isRequired?: boolean;
sourceIdentity?: unknown;
controlIdentity?: unknown;
rowIdentity?: unknown;
structuredDraftOwner?: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
onPatch: (path: Array<string | number>, 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`<span class="settings-row__title">${params.label}</span>`
: nothing}
${help ? html`<span class="settings-row__desc">${help}</span>` : nothing}
${help
? html`<span class="settings-row__desc" id=${params.helpId ?? nothing}
>${help}</span
>`
: nothing}
${renderTags(params.tags)}
${params.error
? html`<span class="cfg-field__error">${params.error}</span>`
? html`<span class="cfg-field__error" role="alert">${params.error}</span>`
: nothing}
</div>
`
@@ -237,21 +262,92 @@ export function renderSegmentedControl(params: {
}
export function renderJsonTextareaControl(params: {
schema: JsonSchema;
path: Array<string | number>;
ariaLabel: string;
descriptionId?: string;
sourceValue: unknown;
rowIdentity?: unknown;
fallback: string;
rows: number;
sensitiveState: SensitiveRenderState;
disabled: boolean;
isRequired?: boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
onPatch: (path: Array<string | number>, 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<HTMLElement>(".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`
<textarea
${ref((element) => {
if (!(element instanceof HTMLTextAreaElement)) {
return;
}
const previous = jsonTextareaState.get(element);
if (
previous &&
(!Object.is(previous.sourceValue, params.sourceValue) ||
!Object.is(previous.rowIdentity, params.rowIdentity) ||
previous.fallback !== renderedFallback ||
previous.pathKey !== pathKey)
) {
element.value = renderedFallback;
setValidity(element, "");
}
jsonTextareaState.set(element, {
sourceValue: params.sourceValue,
rowIdentity: params.rowIdentity,
fallback: renderedFallback,
pathKey,
});
})}
class="settings-input${sensitiveState.isRedacted ? " cfg-redacted" : ""}"
aria-label=${params.ariaLabel}
aria-describedby=${describedBy || nothing}
aria-invalid="false"
placeholder=${sensitiveState.isRedacted ? redactedPlaceholder() : t("configForm.jsonValue")}
rows=${params.rows}
.value=${sensitiveState.isRedacted ? "" : fallback}
.value=${renderedFallback}
?disabled=${disabled}
?readonly=${sensitiveState.isRedacted}
@click=${() => {
@@ -259,31 +355,44 @@ export function renderJsonTextareaControl(params: {
params.onToggleSensitivePath(path);
}
}}
@input=${(event: Event) => {
if (!sensitiveState.isRedacted) {
updateValidity(event.target as HTMLTextAreaElement);
}
}}
@change=${(event: Event) => {
if (sensitiveState.isRedacted) {
return;
}
const target = event.target as HTMLTextAreaElement;
if (!updateValidity(target)) {
return;
}
const raw = target.value.trim();
if (!raw) {
onPatch(path, undefined);
commitJsonValue(target, undefined);
return;
}
try {
onPatch(path, JSON.parse(raw));
commitJsonValue(target, JSON.parse(raw));
} catch {
target.value = fallback;
// Input validity is already surfaced inline; preserve the draft until it is valid JSON.
}
}}
></textarea>
`;
return wrapSensitiveControl(
textareaControl,
renderSensitiveToggleButton({
path,
state: sensitiveState,
disabled,
onToggleSensitivePath: params.onToggleSensitivePath,
}),
);
return html`
<span class="cfg-json-editor">
${wrapSensitiveControl(
textareaControl,
renderSensitiveToggleButton({
path,
state: sensitiveState,
disabled,
onToggleSensitivePath: params.onToggleSensitivePath,
}),
)}
<span id=${errorId} class="cfg-field__error" role="alert" hidden></span>
</span>
`;
}

View File

@@ -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`
<openclaw-config-form-structured-draft
class="cfg-structured-draft"
.props=${props}
></openclaw-config-form-structured-draft>
`;
}
// 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,

View File

@@ -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);

View File

@@ -12,12 +12,23 @@ export type JsonSchema = {
properties?: Record<string, JsonSchema>;
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<string | number>, 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 | number>): string {

View File

@@ -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<string, unknown>;
const rightRecord = right as Record<string, unknown>;
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<object>(),
complete = new WeakSet<object>(),
): 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<JsonSchema, Set<unknown>>,
): 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<string, unknown>;
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());
}

View File

@@ -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;
}
}}
>
<span class="settings-control__sr-label">${props.ariaLabel}</span>
@@ -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;
}
}}
>
<span class="settings-control__sr-label">${props.ariaLabel ?? props.title}</span>

View File

@@ -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();
}
});
});

View File

@@ -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",

View File

@@ -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();
});

View File

@@ -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;
}

View File

@@ -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;