diff --git a/ui/src/e2e/config-open-file-feedback.e2e.test.ts b/ui/src/e2e/config-open-file-feedback.e2e.test.ts new file mode 100644 index 000000000000..14765d548dce --- /dev/null +++ b/ui/src/e2e/config-open-file-feedback.e2e.test.ts @@ -0,0 +1,115 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import type { Page } from "playwright"; +import { expect, it } from "vitest"; +import { installMockGateway } from "../test-helpers/control-ui-e2e.ts"; +import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; + +const suite = createControlUiE2eSuite({ + name: "Control UI config open-file feedback mocked Gateway E2E", + startServerBeforeBrowser: true, +}); +const configPath = "/tmp/openclaw-config-open-feedback/openclaw.json"; +const captureProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"; +const proofPath = path.resolve(".artifacts/control-ui-e2e/config-open-file-feedback/after.png"); + +async function installClipboardProof(page: Page): Promise { + await page.addInitScript(() => { + const copied: string[] = []; + Object.defineProperty(globalThis, "configOpenFileCopied", { value: copied }); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText: async (text: string) => { + copied.push(text); + }, + }, + }); + }); +} + +async function openRawSettings(page: Page, response?: unknown) { + await installClipboardProof(page); + const config = { laboratory: { enabled: true } }; + const gateway = await installMockGateway(page, { + featureMethods: ["config.openFile"], + methodResponses: { + "config.get": { + config, + hash: "config-open-file-feedback", + issues: [], + path: configPath, + raw: JSON.stringify(config), + valid: true, + }, + "config.schema": { + schema: { + type: "object", + properties: { + laboratory: { + type: "object", + properties: { enabled: { type: "boolean" } }, + }, + }, + }, + uiHints: {}, + version: "config-open-file-feedback", + }, + ...(response === undefined ? {} : { "config.openFile": response }), + }, + operatorScopes: ["operator.read", "operator.admin"], + }); + const navigation = await page.goto(`${suite.server.baseUrl}settings/advanced?section=laboratory`); + expect(navigation?.status()).toBe(200); + await page.getByRole("button", { name: "Raw", exact: true }).click(); + return gateway; +} + +async function expectOpenFailure(page: Page, message: string): Promise { + const status = page.getByRole("status").filter({ hasText: message }); + await expect.poll(() => status.count()).toBe(1); + await expect.poll(() => status.textContent()).toContain("File path copied to clipboard"); + await expect.poll(() => status.textContent()).toContain(configPath); + await expect + .poll(() => page.evaluate(() => Reflect.get(globalThis, "configOpenFileCopied"))) + .toEqual([configPath]); + expect(await page.getByText("Save failed", { exact: true }).count()).toBe(0); + expect(await page.getByRole("button", { name: "Retry", exact: true }).count()).toBe(0); + expect(await page.locator(".settings-save-indicator").count()).toBe(0); +} + +suite.define(() => { + it("announces the path fallback when the host opener returns a failure", async () => { + await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => { + await openRawSettings(page, { + ok: false, + path: configPath, + error: "No desktop opener is available.", + }); + + await page.getByRole("button", { name: "Open", exact: true }).click(); + + await expectOpenFailure(page, "No desktop opener is available."); + if (captureProof) { + await mkdir(path.dirname(proofPath), { recursive: true }); + await page.screenshot({ animations: "disabled", fullPage: true, path: proofPath }); + } + }); + }); + + it("announces the path fallback when the open request rejects", async () => { + await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => { + const gateway = await openRawSettings(page); + await gateway.deferNext("config.openFile"); + + await page.getByRole("button", { name: "Open", exact: true }).click(); + await gateway.waitForRequest("config.openFile"); + await gateway.rejectDeferred("config.openFile", { + code: "UNAVAILABLE", + message: "No desktop opener is available.", + }); + + await expectOpenFailure(page, "No desktop opener is available."); + }); + }); +}); diff --git a/ui/src/lib/config/config-gateway-operations.ts b/ui/src/lib/config/config-gateway-operations.ts index a0b0a7b2e2be..4babd68d9bbd 100644 --- a/ui/src/lib/config/config-gateway-operations.ts +++ b/ui/src/lib/config/config-gateway-operations.ts @@ -5,6 +5,7 @@ import type { ConfigSchemaResponse, ConfigSnapshot } from "../../api/types.ts"; import { copyToClipboard } from "../clipboard.ts"; import { serializeConfigForm } from "../config-form-utils.ts"; import { formatUiError, formatUiExternalText } from "../format-error.ts"; +import { showToast } from "../toast.ts"; import { adoptConfigSetAck, applyConfigSnapshot, @@ -639,6 +640,21 @@ export async function openConfigFile(state: RuntimeConfigState): Promise { const isCurrent = () => isCurrentConfigConnection(state, client, connectionEpoch); state.lastError = null; state.chatError = null; + const publishFailure = async (error: string, path?: string | null) => { + if (!isCurrent()) { + return; + } + let message = error; + if (path) { + message += (await copyToClipboard(path)) + ? `\n\nFile path copied to clipboard: ${path}` + : `\n\nFile path: ${path}`; + } + if (isCurrent()) { + state.lastError = formatUiExternalText(message); + showToast({ message: state.lastError }); + } + }; try { const res = await client.request<{ ok: boolean; path?: string; error?: string }>( "config.openFile", @@ -648,30 +664,12 @@ export async function openConfigFile(state: RuntimeConfigState): Promise { return; } if (!res.ok) { - let errorMessage = formatUiExternalText(res.error, "Failed to open config file"); - const path = res.path || state.configSnapshot?.path; - if (path) { - if (await copyToClipboard(path)) { - errorMessage += `\n\nFile path copied to clipboard: ${path}`; - } else { - errorMessage += `\n\nFile path: ${path}`; - } - } - if (isCurrent()) { - state.lastError = formatUiExternalText(errorMessage); - } + await publishFailure( + formatUiExternalText(res.error, "Failed to open config file"), + res.path || state.configSnapshot?.path, + ); } } catch (err) { - if (!isCurrent()) { - return; - } - const errorMessage = formatUiError(err); - const path = state.configSnapshot?.path; - if (path) { - await copyToClipboard(path); - } - if (isCurrent()) { - state.lastError = errorMessage; - } + await publishFailure(formatUiError(err), state.configSnapshot?.path); } }