mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
55f1738d50
* fix(control-ui): stop config form save from corrupting 64-bit id strings Saving the schema-driven config form coerced every numeric-looking string to a JS number before submission. For union-typed fields such as tools.elevated.allowFrom.* (anyOf: string | number), string entries holding 64-bit ids (Discord/Telegram snowflakes) were rewritten through Number(), which rounds past 2^53: "1048113311314608148" -> 1048113311314608100. The corruption also hit untouched fields, because serialization coerces the whole form, so merely saving an unrelated setting silently broke elevated-approval allowlists (fail-closed: the real user id no longer matched). Two guards fix this: - coerceFormValues keeps a string that already satisfies a string variant of an anyOf/oneOf union instead of parsing it into another variant's number. - coerceConfigFormNumberString refuses lossy integer parses: plain integer text beyond Number.MAX_SAFE_INTEGER that does not round-trip through BigInt stays a string, so pure number/integer fields fail validation loudly instead of storing a corrupted id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(control-ui): harden 64-bit config id preservation * fix(control-ui): validate mixed-union scalar branches * test(control-ui): prove real gateway id preservation * test(control-ui): use communications route for config proof * test(control-ui): grant config proof admin scope * test(control-ui): reopen raw config for proof * fix(control-ui): preserve explicit union input types * test(control-ui): exercise union collection draft * ci: retry flaky control ui e2e * fix(control-ui): preserve mixed scalar branch types * ci: retry service worker e2e * fix(control-ui): preserve typeless string union branches * fix(control-ui): reject lossy decimal coercion * fix(control-ui): reject lossy pure numeric input * fix(control-ui): preserve exact numeric branch semantics * ci: retry checkout rate limit * ci(control-ui): capture real gateway proof * test(control-ui): frame config proof values * ci: retry checkout download * test(control-ui): prove Gateway-served production bundle * fix(control-ui): preserve exact incremental union edits * refactor(control-ui): isolate scalar edit session state * fix(control-ui): keep scalar edit branch type internal * fix(control-ui): avoid detached focus selector * fix(control-ui): round-trip exact numeric branches * refactor(control-ui): share exact scalar formatting --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
764 lines
26 KiB
TypeScript
764 lines
26 KiB
TypeScript
// 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;
|
|
}
|
|
|
|
type RenderArrayFixtureOptions = Omit<
|
|
Parameters<typeof renderArray>[0],
|
|
"disabled" | "hints" | "unsupported"
|
|
>;
|
|
|
|
function renderArrayFixture(container: HTMLElement, options: RenderArrayFixtureOptions): void {
|
|
render(
|
|
renderArray({ hints: {}, unsupported: new Set(), disabled: false, ...options }, renderNode),
|
|
container,
|
|
);
|
|
}
|
|
|
|
function findAddButton(container: Element): HTMLButtonElement | undefined {
|
|
return Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
|
|
(button) => button.textContent?.trim() === "Add",
|
|
);
|
|
}
|
|
|
|
describe("config form array integrity", () => {
|
|
it("applies safe whole-array composition candidates atomically", () => {
|
|
const onPatch = vi.fn();
|
|
const container = document.createElement("div");
|
|
renderArrayFixture(container, {
|
|
schema: {
|
|
type: "array",
|
|
items: { type: "string" },
|
|
allOf: [{ const: ["a", "b"] }],
|
|
},
|
|
value: undefined,
|
|
path: ["values"],
|
|
isRequired: true,
|
|
onPatch,
|
|
});
|
|
expectElement(findAddButton(container), "whole-array composition add").click();
|
|
expect(onPatch).toHaveBeenCalledWith(["values"], ["a", "b"]);
|
|
|
|
onPatch.mockClear();
|
|
renderArrayFixture(container, {
|
|
schema: {
|
|
type: "array",
|
|
items: { type: "string" },
|
|
enum: [[], ["a", "b"]],
|
|
},
|
|
value: [],
|
|
path: ["values"],
|
|
isRequired: true,
|
|
onPatch,
|
|
});
|
|
expectElement(findAddButton(container), "valid constrained array extension").click();
|
|
expect(onPatch).toHaveBeenCalledWith(["values"], ["a", "b"]);
|
|
|
|
onPatch.mockClear();
|
|
renderArrayFixture(container, {
|
|
schema: {
|
|
type: "array",
|
|
items: { type: "string", default: "item" },
|
|
default: ["a", "b"],
|
|
},
|
|
value: [],
|
|
path: ["values"],
|
|
isRequired: true,
|
|
onPatch,
|
|
});
|
|
expectElement(findAddButton(container), "explicit empty array add").click();
|
|
expect(onPatch).toHaveBeenCalledWith(["values"], ["item"]);
|
|
|
|
onPatch.mockClear();
|
|
renderArrayFixture(container, {
|
|
schema: {
|
|
type: "array",
|
|
items: { type: "string" },
|
|
const: ["a", "b"],
|
|
},
|
|
value: [],
|
|
path: ["values"],
|
|
isRequired: true,
|
|
onPatch,
|
|
});
|
|
expectElement(findAddButton(container), "explicit invalid constrained array add").click();
|
|
expect(onPatch).toHaveBeenCalledWith(["values"], ["a", "b"]);
|
|
|
|
onPatch.mockClear();
|
|
renderArrayFixture(container, {
|
|
schema: {
|
|
type: "array",
|
|
items: { type: "string" },
|
|
const: [],
|
|
maxItems: 0,
|
|
},
|
|
value: undefined,
|
|
path: ["values"],
|
|
isRequired: true,
|
|
onPatch,
|
|
});
|
|
const emptyConstAdd = expectElement(findAddButton(container), "empty const array add");
|
|
expect(emptyConstAdd.disabled).toBe(false);
|
|
emptyConstAdd.click();
|
|
expect(onPatch).toHaveBeenCalledWith(["values"], []);
|
|
|
|
onPatch.mockClear();
|
|
renderArrayFixture(container, {
|
|
schema: {
|
|
type: "array",
|
|
items: { type: "string" },
|
|
maxItems: 0,
|
|
},
|
|
value: undefined,
|
|
path: ["values"],
|
|
isRequired: true,
|
|
onPatch,
|
|
});
|
|
const requiredEmptyAdd = expectElement(
|
|
findAddButton(container),
|
|
"required empty-only array add",
|
|
);
|
|
expect(requiredEmptyAdd.disabled).toBe(false);
|
|
requiredEmptyAdd.click();
|
|
expect(onPatch).toHaveBeenCalledWith(["values"], []);
|
|
});
|
|
|
|
it("preserves unquoted strings and decodes quoted strings in string-number arrays", async () => {
|
|
const onPatch = vi.fn();
|
|
const container = document.createElement("div");
|
|
document.body.append(container);
|
|
const identifier = "1048113311314608148";
|
|
renderArrayFixture(container, {
|
|
schema: {
|
|
type: "array",
|
|
items: {
|
|
oneOf: [{ enum: [identifier] }, { type: "number" }],
|
|
},
|
|
},
|
|
value: [],
|
|
path: ["allowFrom"],
|
|
onPatch,
|
|
});
|
|
|
|
const draftHost = expectElement(
|
|
container.querySelector<ConfigFormCollectionDraft>("openclaw-config-form-collection-draft"),
|
|
"string-number array draft",
|
|
);
|
|
const addDraftValue = async (value: string) => {
|
|
expectElement(findAddButton(container), "string-number array add").click();
|
|
await draftHost.updateComplete;
|
|
const draftValue = expectElement(
|
|
draftHost.querySelector<HTMLInputElement | HTMLTextAreaElement>(
|
|
"[data-collection-draft-value]",
|
|
),
|
|
"string-number array draft value",
|
|
);
|
|
draftValue.value = value;
|
|
draftValue.dispatchEvent(new Event("input", { bubbles: true }));
|
|
await draftHost.updateComplete;
|
|
expectElement(findAddButton(draftHost), "string-number array draft commit").click();
|
|
await draftHost.updateComplete;
|
|
};
|
|
|
|
await addDraftValue(identifier);
|
|
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], [identifier]);
|
|
expect(onPatch.mock.calls[0]?.[1]?.[0]).not.toBe(Number(identifier));
|
|
onPatch.mockClear();
|
|
await addDraftValue(JSON.stringify(identifier));
|
|
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], [identifier]);
|
|
onPatch.mockClear();
|
|
await addDraftValue("1e5");
|
|
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], [100_000]);
|
|
container.remove();
|
|
});
|
|
|
|
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) => {
|
|
renderArrayFixture(container, {
|
|
schema,
|
|
value: undefined,
|
|
path: ["codes"],
|
|
onPatch,
|
|
});
|
|
};
|
|
|
|
renderSchema({
|
|
type: "array",
|
|
minItems: 101,
|
|
items: { type: "string" },
|
|
});
|
|
const largeMinimumAdd = expectElement(findAddButton(container), "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(findAddButton(container), "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(findAddButton(draftHost), "unique array draft commit").click();
|
|
expect(onPatch).toHaveBeenCalledWith(["codes"], ["first"]);
|
|
|
|
// An autosave ack clones the form: identity churn with identical content
|
|
// must not close an open draft mid-typing.
|
|
expectElement(findAddButton(container), "reopened unique array add").click();
|
|
await draftHost.updateComplete;
|
|
const typedDraft = expectElement(
|
|
draftHost.querySelector<HTMLInputElement>("[data-collection-draft-value]"),
|
|
"typed unique array draft",
|
|
);
|
|
typedDraft.value = "in-progress";
|
|
typedDraft.dispatchEvent(new Event("input", { bubbles: true }));
|
|
await draftHost.updateComplete;
|
|
renderSchema({
|
|
type: "array",
|
|
minItems: 2,
|
|
uniqueItems: true,
|
|
items: { type: "string" },
|
|
});
|
|
await draftHost.updateComplete;
|
|
const survivingDraft = draftHost.querySelector<HTMLInputElement>(
|
|
"[data-collection-draft-value]",
|
|
);
|
|
expect(survivingDraft?.value).toBe("in-progress");
|
|
|
|
onPatch.mockClear();
|
|
renderArrayFixture(container, {
|
|
schema: {
|
|
type: "array",
|
|
minItems: 2,
|
|
uniqueItems: true,
|
|
items: { type: "string" },
|
|
},
|
|
value: ["first"],
|
|
path: ["codes"],
|
|
onPatch,
|
|
});
|
|
const duplicateDraft = expectElement(
|
|
container.querySelector<ConfigFormCollectionDraft>("openclaw-config-form-collection-draft"),
|
|
"duplicate array draft",
|
|
);
|
|
await duplicateDraft.updateComplete;
|
|
expectElement(findAddButton(container), "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(findAddButton(duplicateDraft), "duplicate array draft commit").click();
|
|
await duplicateDraft.updateComplete;
|
|
expect(duplicateValue.getAttribute("aria-invalid")).toBe("true");
|
|
expect(onPatch).not.toHaveBeenCalled();
|
|
|
|
renderArrayFixture(container, {
|
|
schema: {
|
|
type: "array",
|
|
minItems: 2,
|
|
items: { type: "string" },
|
|
allOf: [{ items: { type: "string", pattern: "^[0-9]+$" } }],
|
|
},
|
|
value: undefined,
|
|
path: ["codes"],
|
|
onPatch,
|
|
});
|
|
const composedDraft = expectElement(
|
|
container.querySelector<ConfigFormCollectionDraft>("openclaw-config-form-collection-draft"),
|
|
"composed item draft",
|
|
);
|
|
await composedDraft.updateComplete;
|
|
expectElement(findAddButton(container), "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(
|
|
findAddButton(composedDraft),
|
|
"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");
|
|
renderArrayFixture(container, {
|
|
schema: {
|
|
type: "array",
|
|
uniqueItems: true,
|
|
items: { type: "string" },
|
|
},
|
|
value: ["alpha", "beta"],
|
|
path: ["codes"],
|
|
onPatch,
|
|
});
|
|
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");
|
|
renderArrayFixture(container, {
|
|
schema: {
|
|
type: "array",
|
|
uniqueItems: true,
|
|
items: { type: "boolean" },
|
|
},
|
|
value: [false, true],
|
|
path: ["flags"],
|
|
onPatch,
|
|
});
|
|
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[]) => {
|
|
renderArrayFixture(container, {
|
|
schema,
|
|
value,
|
|
path: ["codes"],
|
|
onPatch,
|
|
});
|
|
};
|
|
|
|
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");
|
|
renderArrayFixture(container, {
|
|
schema: {
|
|
type: "array",
|
|
items: [{ type: "string" }, { type: "number" }],
|
|
additionalItems: false,
|
|
},
|
|
value: ["identifier", 3],
|
|
path: ["tuple"],
|
|
onPatch,
|
|
});
|
|
|
|
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 = findAddButton(container);
|
|
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[]) => {
|
|
renderArrayFixture(container, {
|
|
schema,
|
|
value,
|
|
path: ["codes"],
|
|
onPatch,
|
|
});
|
|
};
|
|
|
|
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");
|
|
renderArrayFixture(container, {
|
|
schema: {
|
|
type: "array",
|
|
uniqueItems: true,
|
|
items: {
|
|
anyOf: [{ type: "object" }, { type: "array" }],
|
|
},
|
|
},
|
|
value: [{ id: "first" }, { id: "second" }],
|
|
path: ["entries"],
|
|
onPatch,
|
|
});
|
|
|
|
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 = () => {
|
|
renderArrayFixture(container, {
|
|
schema,
|
|
value: currentValue,
|
|
path: ["entries"],
|
|
onPatch: (_path, nextValue) => {
|
|
currentValue = nextValue as unknown[];
|
|
renderValue();
|
|
},
|
|
});
|
|
};
|
|
|
|
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 = () => {
|
|
renderArrayFixture(scalarContainer, {
|
|
schema: {
|
|
type: "array",
|
|
items: { type: "string", minLength: 2 },
|
|
},
|
|
value: scalarValue,
|
|
path: ["values"],
|
|
onPatch: (_path, nextValue) => {
|
|
scalarValue = nextValue as unknown[];
|
|
renderScalarValue();
|
|
},
|
|
});
|
|
};
|
|
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 = () => {
|
|
renderArrayFixture(jsonContainer, {
|
|
schema: { type: "array", items: {} },
|
|
value: jsonValue,
|
|
path: ["values"],
|
|
onPatch: (_path, nextValue) => {
|
|
jsonValue = nextValue as unknown[];
|
|
renderJsonValue();
|
|
},
|
|
});
|
|
};
|
|
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);
|
|
renderArrayFixture(container, {
|
|
schema: {
|
|
type: "array",
|
|
uniqueItems: true,
|
|
items: { type: ["string", "null"] },
|
|
},
|
|
value: [""],
|
|
path: ["values"],
|
|
onPatch,
|
|
});
|
|
const draftHost = expectElement(
|
|
container.querySelector<ConfigFormCollectionDraft>("openclaw-config-form-collection-draft"),
|
|
"nullable scalar collection draft",
|
|
);
|
|
expectElement(findAddButton(container), "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(findAddButton(draftHost), "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");
|
|
renderArrayFixture(container, {
|
|
schema: {
|
|
type: "array",
|
|
uniqueItems: true,
|
|
items: {
|
|
type: "array",
|
|
items: { type: "string" },
|
|
},
|
|
},
|
|
value: [["alpha"], ["beta"]],
|
|
path: ["groups"],
|
|
onPatch,
|
|
});
|
|
|
|
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 = () => {
|
|
renderArrayFixture(container, {
|
|
schema,
|
|
value: currentValue,
|
|
path: ["entries"],
|
|
onPatch: (_path, nextValue) => {
|
|
currentValue = nextValue as unknown[];
|
|
renderValue();
|
|
},
|
|
});
|
|
};
|
|
|
|
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");
|
|
renderArrayFixture(container, {
|
|
schema: {
|
|
type: "array",
|
|
items: [{ type: "string" }],
|
|
},
|
|
value: ["head", { enabled: true }],
|
|
path: ["tuple"],
|
|
onPatch,
|
|
});
|
|
|
|
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 = findAddButton(container);
|
|
expect(addButton?.disabled).toBe(false);
|
|
});
|
|
});
|