mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
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:
committed by
GitHub
parent
959adf6f33
commit
3bb1433b78
@@ -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.");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user