mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 05:26:16 +00:00
fix(ui): show config open failure feedback (#87108)
Fixes #87020. Summary: - Surface config.openFile failures in the Control UI instead of silently doing nothing. - Return actionable gateway errors for headless opener failures, including the config path. - Add gateway and UI controller regression coverage for the failed-open path. Verification: - node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway-methods.config.ts src/gateway/server-methods/config.test.ts --reporter=dot - node scripts/run-vitest.mjs run --config test/vitest/vitest.ui.config.ts ui/src/ui/controllers/config.test.ts --reporter=dot - pnpm check:changed via Blacksmith Testbox tbx_01ksktydqx6mk3n20yevcbkwtn - autoreview --mode local Thanks @Linux2010. Co-authored-by: Linux2010 <35169750+Linux2010@users.noreply.github.com>
This commit is contained in:
@@ -100,7 +100,7 @@ describe("config.openFile", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a generic error and logs details when the opener fails", async () => {
|
||||
it("returns a detailed error and logs details when the opener fails", async () => {
|
||||
process.env.OPENCLAW_CONFIG_PATH = "/tmp/config.json";
|
||||
execFileMock.mockImplementation((...args: unknown[]) => {
|
||||
invokeExecFileCallback(
|
||||
@@ -120,7 +120,7 @@ describe("config.openFile", () => {
|
||||
{
|
||||
ok: false,
|
||||
path: "/tmp/config.json",
|
||||
error: "failed to open config file",
|
||||
error: "Failed to open config file: spawn xdg-open ENOENT",
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -128,6 +128,36 @@ describe("config.openFile", () => {
|
||||
"config.openFile failed path=/tmp/config.json: spawn xdg-open ENOENT",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns actionable headless environment error when xdg-open reports no method available", async () => {
|
||||
process.env.OPENCLAW_CONFIG_PATH = "/tmp/config.json";
|
||||
execFileMock.mockImplementation((...args: unknown[]) => {
|
||||
invokeExecFileCallback(
|
||||
args,
|
||||
new Error("xdg-open: no method available for opening '/tmp/config.json'"),
|
||||
);
|
||||
return {} as never;
|
||||
});
|
||||
|
||||
const { options, respond, logGateway } = createConfigHandlerHarness({
|
||||
method: "config.openFile",
|
||||
});
|
||||
await configHandlers["config.openFile"](options);
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
{
|
||||
ok: false,
|
||||
path: "/tmp/config.json",
|
||||
error:
|
||||
"Cannot open file in headless environment. File path: /tmp/config.json. This environment appears to lack a graphical or terminal browser handler.",
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
expect(logGateway.warn).toHaveBeenCalledWith(
|
||||
"config.openFile failed path=/tmp/config.json: xdg-open: no method available for opening '/tmp/config.json'",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("config schema response cache", () => {
|
||||
|
||||
@@ -702,12 +702,17 @@ export const configHandlers: GatewayRequestHandlers = {
|
||||
await execConfigOpenCommand(resolveConfigOpenCommand(configPath));
|
||||
respond(true, { ok: true, path: configPath }, undefined);
|
||||
} catch (error) {
|
||||
const errorMessage = formatConfigOpenError(error);
|
||||
const isHeadlessError = errorMessage.includes("xdg-open") && errorMessage.includes("no method available");
|
||||
const detailedError = isHeadlessError
|
||||
? `Cannot open file in headless environment. File path: ${configPath}. This environment appears to lack a graphical or terminal browser handler.`
|
||||
: `Failed to open config file: ${errorMessage}`;
|
||||
context?.logGateway?.warn(
|
||||
`config.openFile failed path=${sanitizeLookupPathForLog(configPath)}: ${formatConfigOpenError(error)}`,
|
||||
`config.openFile failed path=${sanitizeLookupPathForLog(configPath)}: ${errorMessage}`,
|
||||
);
|
||||
respond(
|
||||
true,
|
||||
{ ok: false, path: configPath, error: "failed to open config file" },
|
||||
{ ok: false, path: configPath, error: detailedError },
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
applyConfigSnapshot,
|
||||
applyConfig,
|
||||
ensureAgentConfigEntry,
|
||||
findAgentConfigEntryIndex,
|
||||
loadConfig,
|
||||
openConfigFile,
|
||||
resetConfigPendingChanges,
|
||||
runUpdate,
|
||||
saveConfig,
|
||||
@@ -64,6 +65,10 @@ function requireRequestCall(request: ReturnType<typeof vi.fn>, index = 0): unkno
|
||||
return call;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("applyConfigSnapshot", () => {
|
||||
it("does not clobber form edits while dirty", () => {
|
||||
const state = createState();
|
||||
@@ -271,6 +276,57 @@ describe("loadConfig", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("openConfigFile", () => {
|
||||
it("surfaces failed open responses and copies the returned config path", async () => {
|
||||
const request = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
path: "/tmp/openclaw.json",
|
||||
error: "Cannot open file in headless environment.",
|
||||
});
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
vi.stubGlobal("navigator", { clipboard: { writeText } });
|
||||
|
||||
const state = createState();
|
||||
state.connected = true;
|
||||
state.client = { request } as unknown as ConfigState["client"];
|
||||
state.lastError = "stale error";
|
||||
|
||||
await openConfigFile(state);
|
||||
|
||||
expect(request).toHaveBeenCalledWith("config.openFile", {});
|
||||
expect(writeText).toHaveBeenCalledWith("/tmp/openclaw.json");
|
||||
expect(state.lastError).toBe(
|
||||
"Cannot open file in headless environment.\n\nFile path copied to clipboard: /tmp/openclaw.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes the config path in the visible error when clipboard fallback fails", async () => {
|
||||
const request = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
error: "Failed to open config file",
|
||||
});
|
||||
const writeText = vi.fn().mockRejectedValue(new Error("clipboard denied"));
|
||||
vi.stubGlobal("navigator", { clipboard: { writeText } });
|
||||
|
||||
const state = createState();
|
||||
state.connected = true;
|
||||
state.client = { request } as unknown as ConfigState["client"];
|
||||
state.configSnapshot = {
|
||||
config: {},
|
||||
path: "/tmp/from-snapshot.json",
|
||||
valid: true,
|
||||
issues: [],
|
||||
};
|
||||
|
||||
await openConfigFile(state);
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith("/tmp/from-snapshot.json");
|
||||
expect(state.lastError).toBe(
|
||||
"Failed to open config file\n\nFile path: /tmp/from-snapshot.json",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateConfigFormValue", () => {
|
||||
it("seeds from snapshot when form is null", () => {
|
||||
const state = createState();
|
||||
|
||||
@@ -501,9 +501,26 @@ export async function openConfigFile(state: ConfigState): Promise<void> {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
state.lastError = null;
|
||||
try {
|
||||
await state.client.request("config.openFile", {});
|
||||
} catch {
|
||||
const res = await state.client.request<{ ok: boolean; path?: string; error?: string }>(
|
||||
"config.openFile",
|
||||
{},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const errorMessage = res.error || "Failed to open config file";
|
||||
state.lastError = errorMessage;
|
||||
const path = res.path || state.configSnapshot?.path;
|
||||
if (path) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(path);
|
||||
state.lastError += `\n\nFile path copied to clipboard: ${path}`;
|
||||
} catch {
|
||||
state.lastError += `\n\nFile path: ${path}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const path = state.configSnapshot?.path;
|
||||
if (path) {
|
||||
try {
|
||||
@@ -512,5 +529,6 @@ export async function openConfigFile(state: ConfigState): Promise<void> {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
state.lastError = String(err);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user