mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
fix(ui): restore collection defaults
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
// Control UI tests cover collection default provenance and restore behavior.
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderArray, renderObject } from "./config-form.node.collection.ts";
|
||||
import { renderJsonTextarea } from "./config-form.node.json.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;
|
||||
}
|
||||
|
||||
function resetButton(container: ParentNode): HTMLButtonElement {
|
||||
return expectElement(
|
||||
container.querySelector<HTMLButtonElement>("button[aria-label='Reset to default']"),
|
||||
"reset to default button",
|
||||
);
|
||||
}
|
||||
|
||||
describe("config form collection defaults", () => {
|
||||
it("shows and restores optional JSON defaults without authoring the inherited value", () => {
|
||||
const container = document.createElement("div");
|
||||
const onPatch = vi.fn();
|
||||
const schema = {
|
||||
anyOf: [{ type: "object" as const }, { type: "array" as const }],
|
||||
default: { mode: "balanced" },
|
||||
};
|
||||
|
||||
render(
|
||||
renderJsonTextarea({
|
||||
schema,
|
||||
value: { mode: "custom" },
|
||||
path: ["payload"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain('Default: {"mode":"balanced"}');
|
||||
expectElement(container.querySelector<HTMLTextAreaElement>("textarea"), "explicit JSON").value =
|
||||
'{\n "mode": "custom"\n}';
|
||||
resetButton(container).click();
|
||||
expect(onPatch).toHaveBeenCalledWith(["payload"], undefined);
|
||||
|
||||
onPatch.mockClear();
|
||||
render(
|
||||
renderJsonTextarea({
|
||||
schema,
|
||||
value: undefined,
|
||||
path: ["payload"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain('Using default: {"mode":"balanced"}');
|
||||
expectElement(
|
||||
container.querySelector<HTMLTextAreaElement>("textarea"),
|
||||
"inherited JSON",
|
||||
).value = '{\n "mode": "balanced"\n}';
|
||||
expect(container.querySelector("button[aria-label='Reset to default']")).toBeNull();
|
||||
expect(onPatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows optional array defaults and keeps rejected restores in the DOM", () => {
|
||||
const container = document.createElement("div");
|
||||
const onPatch = vi.fn(() => false);
|
||||
const schema = {
|
||||
type: "array" as const,
|
||||
items: { type: "string" as const },
|
||||
default: ["a", "b"],
|
||||
};
|
||||
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema,
|
||||
value: ["custom"],
|
||||
path: ["values"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain('Default: ["a","b"]');
|
||||
expect(container.textContent).toContain("1 item");
|
||||
expectElement(container.querySelector<HTMLInputElement>("input"), "explicit array item").value =
|
||||
"custom";
|
||||
resetButton(container).click();
|
||||
expect(onPatch).toHaveBeenCalledWith(["values"], undefined);
|
||||
expect(container.textContent).toContain("1 item");
|
||||
expectElement(container.querySelector<HTMLInputElement>("input"), "rejected array item").value =
|
||||
"custom";
|
||||
|
||||
onPatch.mockClear();
|
||||
render(
|
||||
renderArray(
|
||||
{
|
||||
schema,
|
||||
value: undefined,
|
||||
path: ["values"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain('Using default: ["a","b"]');
|
||||
expect(container.textContent).toContain("2 items");
|
||||
const inheritedInputs = Array.from(container.querySelectorAll<HTMLInputElement>("input"));
|
||||
expect(inheritedInputs.map((input) => input.value)).toEqual(["", ""]);
|
||||
expect(inheritedInputs.map((input) => input.placeholder)).toEqual(["Default: a", "Default: b"]);
|
||||
expect(container.querySelector("button[aria-label='Reset to default']")).toBeNull();
|
||||
expect(onPatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes an optional object as one value and keeps inherited children inherited", () => {
|
||||
const container = document.createElement("div");
|
||||
const onPatch = vi.fn();
|
||||
const onRemove = vi.fn();
|
||||
const schema = {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
profile: {
|
||||
type: "object" as const,
|
||||
title: "Profile",
|
||||
default: { enabled: true, mode: "balanced" },
|
||||
properties: {
|
||||
enabled: { type: "boolean" as const, title: "Enabled" },
|
||||
mode: { type: "string" as const, title: "Mode" },
|
||||
},
|
||||
},
|
||||
sibling: { type: "string" as const, title: "Sibling" },
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
renderObject(
|
||||
{
|
||||
schema,
|
||||
value: {
|
||||
profile: { enabled: false, mode: "custom" },
|
||||
sibling: "preserved",
|
||||
},
|
||||
path: ["settings"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch,
|
||||
onRemove,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const profile = expectElement(
|
||||
Array.from(container.querySelectorAll("details")).find((details) =>
|
||||
details.textContent?.includes("Profile"),
|
||||
),
|
||||
"explicit profile object",
|
||||
);
|
||||
expect(profile.textContent).toContain('Default: {"enabled":true,"mode":"balanced"}');
|
||||
resetButton(profile).click();
|
||||
expect(onRemove).toHaveBeenCalledWith(["settings", "profile"]);
|
||||
expect(onPatch).not.toHaveBeenCalled();
|
||||
|
||||
onRemove.mockClear();
|
||||
render(
|
||||
renderObject(
|
||||
{
|
||||
schema,
|
||||
value: { sibling: "preserved" },
|
||||
path: ["settings"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
onPatch,
|
||||
onRemove,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const inheritedProfile = expectElement(
|
||||
Array.from(container.querySelectorAll("details")).find((details) =>
|
||||
details.textContent?.includes("Profile"),
|
||||
),
|
||||
"inherited profile object",
|
||||
);
|
||||
expect(inheritedProfile.textContent).toContain(
|
||||
'Using default: {"enabled":true,"mode":"balanced"}',
|
||||
);
|
||||
const enabledRow = expectElement(
|
||||
Array.from(inheritedProfile.querySelectorAll(".settings-row")).find((row) =>
|
||||
row.textContent?.includes("Enabled"),
|
||||
),
|
||||
"inherited enabled row",
|
||||
);
|
||||
const modeRow = expectElement(
|
||||
Array.from(inheritedProfile.querySelectorAll(".settings-row")).find((row) =>
|
||||
row.textContent?.includes("Mode"),
|
||||
),
|
||||
"inherited mode row",
|
||||
);
|
||||
expect(enabledRow.textContent).toContain("Using default: true");
|
||||
expect(modeRow.textContent).toContain("Using default: balanced");
|
||||
expect(
|
||||
expectElement(modeRow.querySelector<HTMLInputElement>("input"), "inherited mode input")
|
||||
.placeholder,
|
||||
).toBe("Default: balanced");
|
||||
expect(inheritedProfile.querySelector("button[aria-label='Reset to default']")).toBeNull();
|
||||
expect(onRemove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deep-clones required collection defaults before restoring them", () => {
|
||||
const container = document.createElement("div");
|
||||
const onPatch = vi.fn();
|
||||
const schemaDefault = { nested: { enabled: true } };
|
||||
|
||||
render(
|
||||
renderObject(
|
||||
{
|
||||
schema: {
|
||||
type: "object",
|
||||
title: "Required object",
|
||||
default: schemaDefault,
|
||||
properties: {
|
||||
nested: {
|
||||
type: "object",
|
||||
properties: {
|
||||
enabled: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
value: { nested: { enabled: false } },
|
||||
path: ["settings", "requiredObject"],
|
||||
hints: {},
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
isRequired: true,
|
||||
onPatch,
|
||||
},
|
||||
renderNode,
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
resetButton(container).click();
|
||||
expect(onPatch).toHaveBeenCalledWith(["settings", "requiredObject"], schemaDefault);
|
||||
const restored = onPatch.mock.calls[0]?.[1] as typeof schemaDefault;
|
||||
expect(restored).not.toBe(schemaDefault);
|
||||
expect(restored.nested).not.toBe(schemaDefault.nested);
|
||||
restored.nested.enabled = false;
|
||||
expect(schemaDefault.nested.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,12 @@ export type ConfigFormCollectionDraftCommit = {
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
export function openConfigFormCollectionDraft(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);
|
||||
(draft as Partial<ConfigFormCollectionDraft> | undefined)?.openDraft?.();
|
||||
}
|
||||
|
||||
export class ConfigFormCollectionDraft extends OpenClawLightDomElement {
|
||||
@property({ attribute: false }) props?: ConfigFormCollectionDraftProps;
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
rowIdentitiesForArray,
|
||||
} from "./config-form-array-identity.ts";
|
||||
import {
|
||||
ConfigFormCollectionDraft,
|
||||
openConfigFormCollectionDraft,
|
||||
type ConfigFormCollectionDraftCommit,
|
||||
type ConfigFormCollectionDraftProps,
|
||||
} from "./config-form-collection-draft.ts";
|
||||
@@ -36,7 +36,10 @@ import {
|
||||
jsonValue,
|
||||
renderFieldRow,
|
||||
renderJsonTextareaControl,
|
||||
renderRestoreDefaultButton,
|
||||
renderSchemaDefaultDescription,
|
||||
renderTags,
|
||||
schemaWithDefault,
|
||||
type ConfigNodeRenderer,
|
||||
type ConfigNodeRenderParams,
|
||||
} from "./config-form.node.shared.ts";
|
||||
@@ -52,54 +55,6 @@ 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,
|
||||
value,
|
||||
hints,
|
||||
revealSensitive: params.revealSensitive ?? false,
|
||||
isSensitivePathRevealed: params.isSensitivePathRevealed,
|
||||
});
|
||||
|
||||
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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function renderObject(
|
||||
params: ConfigNodeRenderParams,
|
||||
renderNode: ConfigNodeRenderer,
|
||||
@@ -126,7 +81,8 @@ export function renderObject(
|
||||
: false;
|
||||
const childSearchCriteria = selfMatched ? undefined : searchCriteria;
|
||||
|
||||
const fallback = value ?? schema.default;
|
||||
const inherited = value === undefined && schema.default !== undefined;
|
||||
const fallback = inherited ? schema.default : value;
|
||||
const objectSourceIdentity = fallback === undefined ? UNSET_MAP_SOURCE_IDENTITY : fallback;
|
||||
const objectValue =
|
||||
fallback && typeof fallback === "object" && !Array.isArray(fallback)
|
||||
@@ -188,16 +144,17 @@ export function renderObject(
|
||||
|
||||
const fields = html`
|
||||
${sorted.map(([propertyKey, node]) => {
|
||||
const hasInheritedChild = inherited && Object.hasOwn(objectValue, propertyKey);
|
||||
return renderNode({
|
||||
schema: node,
|
||||
value: objectValue[propertyKey],
|
||||
schema: hasInheritedChild ? schemaWithDefault(node, objectValue[propertyKey]) : node,
|
||||
value: inherited ? undefined : objectValue[propertyKey],
|
||||
path: [...path, propertyKey],
|
||||
hints,
|
||||
rawAvailable,
|
||||
unsupported,
|
||||
disabled,
|
||||
isRequired: requiredKeys.has(propertyKey),
|
||||
sourceIdentity: objectValue[propertyKey],
|
||||
sourceIdentity: inherited ? undefined : objectValue[propertyKey],
|
||||
controlIdentity: params.controlIdentity ?? objectValue,
|
||||
rowIdentity: params.rowIdentity,
|
||||
searchCriteria: childSearchCriteria,
|
||||
@@ -236,9 +193,17 @@ export function renderObject(
|
||||
<div class="settings-row__text">
|
||||
<span class="settings-row__title">${label}</span>
|
||||
${help ? html`<span class="settings-row__desc">${help}</span>` : nothing}
|
||||
${schema.default !== undefined
|
||||
? html`<span class="settings-row__desc"
|
||||
>${renderSchemaDefaultDescription(schema, value)}</span
|
||||
>`
|
||||
: nothing}
|
||||
${renderTags(tags)}
|
||||
</div>
|
||||
<span class="settings-row__chevron cfg-object__chevron">${icons.chevronDown}</span>
|
||||
<div class="settings-row__control">
|
||||
${renderRestoreDefaultButton(params)}
|
||||
<span class="settings-row__chevron cfg-object__chevron">${icons.chevronDown}</span>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="settings-subrows">${fields}</div>
|
||||
</details>
|
||||
@@ -283,6 +248,7 @@ export function renderArray(
|
||||
});
|
||||
}
|
||||
|
||||
const inherited = value === undefined && Array.isArray(schema.default);
|
||||
const arrayValue = Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(schema.default)
|
||||
@@ -372,7 +338,12 @@ export function renderArray(
|
||||
<div class="settings-row">
|
||||
<div class="settings-row__text">
|
||||
${showLabel ? html`<span class="settings-row__title">${label}</span>` : nothing}
|
||||
${help ? html`<span class="settings-row__desc">${help}</span>` : nothing}
|
||||
${showLabel && help ? html`<span class="settings-row__desc">${help}</span>` : nothing}
|
||||
${showLabel && schema.default !== undefined
|
||||
? html`<span class="settings-row__desc"
|
||||
>${renderSchemaDefaultDescription(schema, value)}</span
|
||||
>`
|
||||
: nothing}
|
||||
${renderTags(tags)}
|
||||
</div>
|
||||
<div class="settings-row__control">
|
||||
@@ -381,6 +352,7 @@ export function renderArray(
|
||||
count: String(arrayValue.length),
|
||||
})}</span
|
||||
>
|
||||
${renderRestoreDefaultButton(params)}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--sm"
|
||||
@@ -389,10 +361,10 @@ export function renderArray(
|
||||
@click=${(event: Event) => {
|
||||
if (atomicCandidate) {
|
||||
if (onPatch(path, atomicCandidate) === false) {
|
||||
openCollectionDraft(event, draftId);
|
||||
openConfigFormCollectionDraft(event, draftId);
|
||||
}
|
||||
} else if (requiresDraft) {
|
||||
openCollectionDraft(event, draftId);
|
||||
openConfigFormCollectionDraft(event, draftId);
|
||||
} else if (autoCandidate) {
|
||||
appendArrayRowIdentities(
|
||||
autoCandidate,
|
||||
@@ -401,7 +373,7 @@ export function renderArray(
|
||||
);
|
||||
if (onPatch(path, autoCandidate) === false) {
|
||||
discardArrayRowIdentities(autoCandidate);
|
||||
openCollectionDraft(event, draftId);
|
||||
openConfigFormCollectionDraft(event, draftId);
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -439,8 +411,9 @@ export function renderArray(
|
||||
? renderSettingsEmpty(t("configForm.noItems"))
|
||||
: html`
|
||||
<div class="settings-subrows">
|
||||
${arrayValue.map(
|
||||
(item, index) => html`
|
||||
${arrayValue.map((item, index) => {
|
||||
const itemSchema = itemSchemaAt(index);
|
||||
return html`
|
||||
<div class="settings-row">
|
||||
<div class="settings-row__text">
|
||||
<span class="settings-row__title">#${index + 1}</span>
|
||||
@@ -488,15 +461,15 @@ export function renderArray(
|
||||
</div>
|
||||
</div>
|
||||
${renderNode({
|
||||
schema: itemSchemaAt(index),
|
||||
value: item,
|
||||
schema: inherited ? schemaWithDefault(itemSchema, item) : itemSchema,
|
||||
value: inherited ? undefined : item,
|
||||
path: [...path, index],
|
||||
hints,
|
||||
rawAvailable,
|
||||
unsupported,
|
||||
disabled,
|
||||
isRequired: true,
|
||||
sourceIdentity: item,
|
||||
sourceIdentity: inherited ? undefined : item,
|
||||
controlIdentity: arrayValue,
|
||||
rowIdentity: rowIdentities[index],
|
||||
searchCriteria: childSearchCriteria,
|
||||
@@ -506,8 +479,8 @@ export function renderArray(
|
||||
onToggleSensitivePath,
|
||||
onPatch: patchArrayItem,
|
||||
})}
|
||||
`,
|
||||
)}
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
@@ -575,7 +548,7 @@ function renderMapField(
|
||||
?disabled=${disabled}
|
||||
@click=${(event: Event) => {
|
||||
if (entryDefault === NO_SAFE_DEFAULT) {
|
||||
openCollectionDraft(event, draftId);
|
||||
openConfigFormCollectionDraft(event, draftId);
|
||||
return;
|
||||
}
|
||||
const nextValue = { ...value };
|
||||
@@ -587,7 +560,7 @@ function renderMapField(
|
||||
}
|
||||
nextValue[key] = entryDefault;
|
||||
if (onPatch(path, nextValue) === false) {
|
||||
openCollectionDraft(event, draftId);
|
||||
openConfigFormCollectionDraft(event, draftId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// Control UI renderer for JSON-backed config form nodes.
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import {
|
||||
getSensitiveRenderState,
|
||||
jsonValue,
|
||||
renderFieldRow,
|
||||
renderJsonTextareaControl,
|
||||
renderRestoreDefaultButton,
|
||||
renderSchemaDefaultDescription,
|
||||
type ConfigNodeRenderParams,
|
||||
} from "./config-form.node.shared.ts";
|
||||
import { resolveConfigFieldMeta as resolveFieldMeta } from "./config-form.search.ts";
|
||||
import { configFieldId } from "./config-form.shared.ts";
|
||||
|
||||
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 !== undefined ? value : schema.default);
|
||||
const sensitiveState = getSensitiveRenderState({
|
||||
path,
|
||||
value,
|
||||
hints,
|
||||
revealSensitive: params.revealSensitive ?? false,
|
||||
isSensitivePathRevealed: params.isSensitivePathRevealed,
|
||||
});
|
||||
const control = html`
|
||||
${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,
|
||||
})}
|
||||
${renderRestoreDefaultButton({
|
||||
...params,
|
||||
disabled: disabled || sensitiveState.isRedacted,
|
||||
})}
|
||||
`;
|
||||
|
||||
return renderFieldRow({
|
||||
label,
|
||||
help,
|
||||
helpId,
|
||||
defaultDescription: sensitiveState.isRedacted
|
||||
? nothing
|
||||
: renderSchemaDefaultDescription(schema, value),
|
||||
tags,
|
||||
showLabel,
|
||||
stacked: true,
|
||||
control,
|
||||
});
|
||||
}
|
||||
@@ -79,6 +79,10 @@ export function jsonValue(value: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function schemaWithDefault(schema: JsonSchema, value: unknown): JsonSchema {
|
||||
return { ...schema, default: value };
|
||||
}
|
||||
|
||||
function formatComparablePrimitive(value: unknown): string | null {
|
||||
if (
|
||||
typeof value === "string" ||
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
structuredDraftInitialValue,
|
||||
type ConfigFormStructuredDraftProps,
|
||||
} from "./config-form-structured-draft.ts";
|
||||
import { renderArray, renderJsonTextarea, renderObject } from "./config-form.node.collection.ts";
|
||||
import { renderArray, renderObject } from "./config-form.node.collection.ts";
|
||||
import { renderJsonTextarea } from "./config-form.node.json.ts";
|
||||
import { renderNumberInput, renderSelect, renderTextInput } from "./config-form.node.scalar.ts";
|
||||
import {
|
||||
renderFieldRow,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Control UI tests cover schema defaults and restoring inherited config values.
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { chromium, type Browser } from "playwright";
|
||||
import { chromium, type Browser, type Locator, type Page } from "playwright";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
canRunPlaywrightChromium,
|
||||
@@ -36,6 +36,12 @@ function requestRaw(request: MockGatewayRequest): Record<string, unknown> {
|
||||
return JSON.parse(String((params as Record<string, unknown>).raw)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function settingsRow(page: Page, title: string): Locator {
|
||||
return page.locator(".settings-row").filter({
|
||||
has: page.locator(".settings-row__title").getByText(title, { exact: true }),
|
||||
});
|
||||
}
|
||||
|
||||
describeControlUiE2e("Control UI config form defaults mocked Gateway E2E", () => {
|
||||
beforeAll(async () => {
|
||||
if (!chromiumAvailable) {
|
||||
@@ -65,7 +71,10 @@ describeControlUiE2e("Control UI config form defaults mocked Gateway E2E", () =>
|
||||
enabled: false,
|
||||
keep: "preserved",
|
||||
mode: "custom",
|
||||
payload: { mode: "custom" },
|
||||
profile: { enabled: false, mode: "custom" },
|
||||
retries: 9,
|
||||
tags: ["custom"],
|
||||
},
|
||||
};
|
||||
const gateway = await installMockGateway(page, {
|
||||
@@ -99,7 +108,27 @@ describeControlUiE2e("Control UI config form defaults mocked Gateway E2E", () =>
|
||||
default: "balanced",
|
||||
enum: ["balanced", "fast", "careful", "safe", "strict", "custom"],
|
||||
},
|
||||
payload: {
|
||||
title: "Payload",
|
||||
anyOf: [{ type: "object" }, { type: "array" }],
|
||||
default: { mode: "balanced" },
|
||||
},
|
||||
profile: {
|
||||
type: "object",
|
||||
title: "Profile",
|
||||
default: { enabled: true, mode: "balanced" },
|
||||
properties: {
|
||||
enabled: { type: "boolean", title: "Profile enabled" },
|
||||
mode: { type: "string", title: "Profile mode" },
|
||||
},
|
||||
},
|
||||
retries: { type: "integer", title: "Retries", default: 3 },
|
||||
tags: {
|
||||
type: "array",
|
||||
title: "Tags",
|
||||
items: { type: "string" },
|
||||
default: ["stable", "default"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -108,7 +137,10 @@ describeControlUiE2e("Control UI config form defaults mocked Gateway E2E", () =>
|
||||
"runtime.enabled": { advanced: false },
|
||||
"runtime.keep": { advanced: false },
|
||||
"runtime.mode": { advanced: false },
|
||||
"runtime.payload": { advanced: false },
|
||||
"runtime.profile": { advanced: false },
|
||||
"runtime.retries": { advanced: false },
|
||||
"runtime.tags": { advanced: false },
|
||||
},
|
||||
version: "e2e",
|
||||
},
|
||||
@@ -120,9 +152,16 @@ describeControlUiE2e("Control UI config form defaults mocked Gateway E2E", () =>
|
||||
expect(response?.status()).toBe(200);
|
||||
|
||||
const panel = page.locator("#config-section-panel");
|
||||
const enabledRow = panel.locator(".settings-row").filter({ hasText: "Enabled" });
|
||||
const modeRow = panel.locator(".settings-row").filter({ hasText: "Mode" });
|
||||
const retriesRow = panel.locator(".settings-row").filter({ hasText: "Retries" });
|
||||
const enabledRow = settingsRow(page, "Enabled");
|
||||
const modeRow = settingsRow(page, "Mode");
|
||||
const payloadRow = settingsRow(page, "Payload");
|
||||
const profileBlock = panel.locator("details").filter({
|
||||
has: page.locator(".cfg-object__summary .settings-row__title").getByText("Profile", {
|
||||
exact: true,
|
||||
}),
|
||||
});
|
||||
const retriesRow = settingsRow(page, "Retries");
|
||||
const tagsBlock = panel.locator(".cfg-array").filter({ hasText: "Tags" });
|
||||
|
||||
await expect.poll(() => enabledRow.textContent()).toContain("Default: true");
|
||||
await expect
|
||||
@@ -130,10 +169,15 @@ describeControlUiE2e("Control UI config form defaults mocked Gateway E2E", () =>
|
||||
.toContain("Controls runtime processing. Default: true");
|
||||
await expect.poll(() => modeRow.textContent()).toContain("Default: balanced");
|
||||
await expect.poll(() => modeRow.locator("select").inputValue()).not.toBe("__unset__");
|
||||
await expect.poll(() => payloadRow.textContent()).toContain('Default: {"mode":"balanced"}');
|
||||
await expect
|
||||
.poll(() => profileBlock.textContent())
|
||||
.toContain('Default: {"enabled":true,"mode":"balanced"}');
|
||||
await expect.poll(() => retriesRow.getByRole("spinbutton").inputValue()).toBe("9");
|
||||
await expect.poll(() => tagsBlock.textContent()).toContain('Default: ["stable","default"]');
|
||||
await expect
|
||||
.poll(() => panel.getByRole("button", { name: "Reset to default" }).count())
|
||||
.toBe(2);
|
||||
.toBe(5);
|
||||
|
||||
if (captureUiProofEnabled) {
|
||||
await mkdir(uiProofArtifactDir, { recursive: true });
|
||||
@@ -143,26 +187,79 @@ describeControlUiE2e("Control UI config form defaults mocked Gateway E2E", () =>
|
||||
});
|
||||
}
|
||||
|
||||
await gateway.deferNext("config.set");
|
||||
await enabledRow.getByRole("button", { name: "Reset to default" }).click();
|
||||
await modeRow.locator("select").selectOption("__unset__");
|
||||
await payloadRow.getByRole("button", { name: "Reset to default" }).click();
|
||||
await profileBlock.getByRole("button", { name: "Reset to default" }).click();
|
||||
await retriesRow.getByRole("button", { name: "Reset to default" }).click();
|
||||
await tagsBlock.getByRole("button", { name: "Reset to default" }).click();
|
||||
|
||||
// Form mutations schedule config.set automatically; form mode has no manual Save control.
|
||||
const saved = requestRaw(await gateway.waitForRequest("config.set"));
|
||||
expect(saved).toEqual({ runtime: { keep: "preserved" } });
|
||||
await expect
|
||||
.poll(() => page.locator("openclaw-settings-save-indicator").textContent())
|
||||
.toContain("Saving");
|
||||
|
||||
await expect.poll(() => enabledRow.textContent()).toContain("Using default: true");
|
||||
await expect.poll(() => modeRow.locator("select").inputValue()).toBe("__unset__");
|
||||
await expect
|
||||
.poll(() => payloadRow.textContent())
|
||||
.toContain('Using default: {"mode":"balanced"}');
|
||||
await expect
|
||||
.poll(() => profileBlock.textContent())
|
||||
.toContain('Using default: {"enabled":true,"mode":"balanced"}');
|
||||
await expect.poll(() => retriesRow.getByRole("spinbutton").inputValue()).toBe("");
|
||||
await expect
|
||||
.poll(() => retriesRow.getByRole("spinbutton").getAttribute("placeholder"))
|
||||
.toBe("Default: 3");
|
||||
await expect
|
||||
.poll(() => tagsBlock.textContent())
|
||||
.toContain('Using default: ["stable","default"]');
|
||||
await expect
|
||||
.poll(() => panel.getByRole("button", { name: "Reset to default" }).count())
|
||||
.toBe(0);
|
||||
|
||||
const configGetsBeforeReload = (await gateway.getRequests("config.get")).length;
|
||||
await gateway.resolveDeferred("config.set");
|
||||
await expect
|
||||
.poll(() => page.locator("openclaw-settings-save-indicator").textContent())
|
||||
.toContain("Saved");
|
||||
expect((await page.reload())?.status()).toBe(200);
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("config.get")).length)
|
||||
.toBe(configGetsBeforeReload + 1);
|
||||
|
||||
const reloadedPanel = page.locator("#config-section-panel");
|
||||
const reloadedEnabledRow = settingsRow(page, "Enabled");
|
||||
const reloadedModeRow = settingsRow(page, "Mode");
|
||||
const reloadedPayloadRow = settingsRow(page, "Payload");
|
||||
const reloadedProfileBlock = reloadedPanel.locator("details").filter({
|
||||
has: page
|
||||
.locator(".cfg-object__summary .settings-row__title")
|
||||
.getByText("Profile", { exact: true }),
|
||||
});
|
||||
const reloadedRetriesRow = settingsRow(page, "Retries");
|
||||
const reloadedTagsBlock = reloadedPanel.locator(".cfg-array").filter({ hasText: "Tags" });
|
||||
await expect.poll(() => reloadedEnabledRow.textContent()).toContain("Using default: true");
|
||||
await expect.poll(() => reloadedModeRow.locator("select").inputValue()).toBe("__unset__");
|
||||
await expect
|
||||
.poll(() => reloadedPayloadRow.textContent())
|
||||
.toContain('Using default: {"mode":"balanced"}');
|
||||
await expect
|
||||
.poll(() => reloadedProfileBlock.textContent())
|
||||
.toContain('Using default: {"enabled":true,"mode":"balanced"}');
|
||||
await expect.poll(() => reloadedRetriesRow.getByRole("spinbutton").inputValue()).toBe("");
|
||||
await expect
|
||||
.poll(() => reloadedTagsBlock.textContent())
|
||||
.toContain('Using default: ["stable","default"]');
|
||||
await expect
|
||||
.poll(() => reloadedPanel.getByRole("button", { name: "Reset to default" }).count())
|
||||
.toBe(0);
|
||||
|
||||
if (captureUiProofEnabled) {
|
||||
await panel.screenshot({
|
||||
await reloadedPanel.screenshot({
|
||||
animations: "disabled",
|
||||
path: path.join(uiProofArtifactDir, "02-inherited-defaults.png"),
|
||||
});
|
||||
|
||||
@@ -195,6 +195,9 @@ describeControlUiE2e("Control UI curated settings defaults mocked Gateway E2E",
|
||||
return !hasOwnPath(raw, ["browser", "enabled"]) && !hasOwnPath(raw, ["tools", "profile"]);
|
||||
})
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(() => page.locator("openclaw-settings-save-indicator").textContent())
|
||||
.toContain("Saved");
|
||||
|
||||
if (captureUiProofEnabled) {
|
||||
await page
|
||||
@@ -248,6 +251,9 @@ describeControlUiE2e("Control UI curated settings defaults mocked Gateway E2E",
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(() => page.locator("openclaw-settings-save-indicator").textContent())
|
||||
.toContain("Saved");
|
||||
|
||||
if (captureUiProofEnabled) {
|
||||
await page.locator("#settings-model-behavior").screenshot({
|
||||
|
||||
@@ -164,6 +164,9 @@ describeControlUiE2e("Control UI Memory defaults mocked Gateway E2E", () => {
|
||||
"plugins.entries.memory-core.config.dreaming.verboseLogging",
|
||||
true,
|
||||
);
|
||||
await expect
|
||||
.poll(() => page.locator("openclaw-settings-save-indicator").textContent())
|
||||
.toContain("Saved");
|
||||
|
||||
await page.reload();
|
||||
const reloadedEngineRow = settingsRow(page, "Memory engine");
|
||||
|
||||
Reference in New Issue
Block a user