fix(ui): surface config open-file failures (#127498)

Amp-Thread-ID: https://ampcode.com/threads/T-01a021f4-b547-7788-a916-d4a94cbd3e3b

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Peter Steinberger
2026-08-21 13:37:31 -07:00
committed by GitHub
parent 959adf6f33
commit 3bb1433b78
2 changed files with 136 additions and 23 deletions
@@ -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<void> {
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<void> {
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.");
});
});
});
+21 -23
View File
@@ -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<void> {
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<void> {
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);
}
}