diff --git a/ui/src/components/config-form-collection-defaults.browser.test.ts b/ui/src/components/config-form-collection-defaults.browser.test.ts new file mode 100644 index 000000000000..73c2b97f853f --- /dev/null +++ b/ui/src/components/config-form-collection-defaults.browser.test.ts @@ -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(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("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("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("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("input"), "explicit array item").value = + "custom"; + resetButton(container).click(); + expect(onPatch).toHaveBeenCalledWith(["values"], undefined); + expect(container.textContent).toContain("1 item"); + expectElement(container.querySelector("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("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("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); + }); +}); diff --git a/ui/src/components/config-form-collection-draft.ts b/ui/src/components/config-form-collection-draft.ts index 07b7797d60a3..7eff85760841 100644 --- a/ui/src/components/config-form-collection-draft.ts +++ b/ui/src/components/config-form-collection-draft.ts @@ -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 | undefined)?.openDraft?.(); +} + export class ConfigFormCollectionDraft extends OpenClawLightDomElement { @property({ attribute: false }) props?: ConfigFormCollectionDraftProps; diff --git a/ui/src/components/config-form.node.collection.ts b/ui/src/components/config-form.node.collection.ts index 619ed4b87140..aae41ef5727f 100644 --- a/ui/src/components/config-form.node.collection.ts +++ b/ui/src/components/config-form.node.collection.ts @@ -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 | 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(
${label} ${help ? html`${help}` : nothing} + ${schema.default !== undefined + ? html`${renderSchemaDefaultDescription(schema, value)}` + : nothing} ${renderTags(tags)}
- ${icons.chevronDown} +
+ ${renderRestoreDefaultButton(params)} + ${icons.chevronDown} +
${fields}
@@ -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(
${showLabel ? html`${label}` : nothing} - ${help ? html`${help}` : nothing} + ${showLabel && help ? html`${help}` : nothing} + ${showLabel && schema.default !== undefined + ? html`${renderSchemaDefaultDescription(schema, value)}` + : nothing} ${renderTags(tags)}
@@ -381,6 +352,7 @@ export function renderArray( count: String(arrayValue.length), })} + ${renderRestoreDefaultButton(params)}
@@ -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); } }} > diff --git a/ui/src/components/config-form.node.json.ts b/ui/src/components/config-form.node.json.ts new file mode 100644 index 000000000000..9598be04121b --- /dev/null +++ b/ui/src/components/config-form.node.json.ts @@ -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, + }); +} diff --git a/ui/src/components/config-form.node.shared.ts b/ui/src/components/config-form.node.shared.ts index 2f90c5666f51..35415d4a7500 100644 --- a/ui/src/components/config-form.node.shared.ts +++ b/ui/src/components/config-form.node.shared.ts @@ -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" || diff --git a/ui/src/components/config-form.node.ts b/ui/src/components/config-form.node.ts index 42c97d0856af..c88ab6dfc280 100644 --- a/ui/src/components/config-form.node.ts +++ b/ui/src/components/config-form.node.ts @@ -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, diff --git a/ui/src/e2e/config-form-defaults.e2e.test.ts b/ui/src/e2e/config-form-defaults.e2e.test.ts index 60d995307793..8fc9c1962e4d 100644 --- a/ui/src/e2e/config-form-defaults.e2e.test.ts +++ b/ui/src/e2e/config-form-defaults.e2e.test.ts @@ -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 { return JSON.parse(String((params as Record).raw)) as Record; } +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"), }); diff --git a/ui/src/e2e/curated-settings-defaults.e2e.test.ts b/ui/src/e2e/curated-settings-defaults.e2e.test.ts index a8d221bd19fe..8099ab7b4691 100644 --- a/ui/src/e2e/curated-settings-defaults.e2e.test.ts +++ b/ui/src/e2e/curated-settings-defaults.e2e.test.ts @@ -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({ diff --git a/ui/src/e2e/memory-settings-defaults.e2e.test.ts b/ui/src/e2e/memory-settings-defaults.e2e.test.ts index c39e2c0fe20d..ed244f8cdc28 100644 --- a/ui/src/e2e/memory-settings-defaults.e2e.test.ts +++ b/ui/src/e2e/memory-settings-defaults.e2e.test.ts @@ -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");