fix(ui): restore collection defaults

This commit is contained in:
Vincent Koc
2026-07-31 13:47:02 +08:00
parent 20086b84f6
commit ef32ee2f9d
9 changed files with 503 additions and 74 deletions
@@ -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" ||
+2 -1
View File
@@ -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,
+103 -6
View File
@@ -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");