mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 19:08:22 -06:00
fix(ui): surface failed widget access decisions (#125934)
This commit is contained in:
committed by
GitHub
parent
2ffbff0547
commit
8bd49e1f4d
@@ -772,20 +772,34 @@ describe("openclaw-board-view", () => {
|
||||
await vi.waitFor(() => expect(allow?.disabled).toBe(false));
|
||||
});
|
||||
|
||||
it("keeps approval controls after a failed decision and clears the error on refresh", async () => {
|
||||
const grant = vi.fn(async () => {
|
||||
throw new Error("approval service unavailable");
|
||||
it("toasts failed rejection while keeping controls and leaves successful decisions quiet", async () => {
|
||||
const toastHost = document.createElement("openclaw-toast-host");
|
||||
document.body.append(toastHost);
|
||||
const grant = vi.fn(async (_name: string, decision: "granted" | "rejected") => {
|
||||
if (decision === "rejected") {
|
||||
throw new Error("approval service unavailable");
|
||||
}
|
||||
});
|
||||
const source = snapshot({ widgets: [boardWidget({ grantState: "pending" })] });
|
||||
const view = await mount({ snapshot: source, callbacks: callbacks({ grant }) });
|
||||
view.querySelector<HTMLButtonElement>('[data-test-id="board-grant-allow"]')?.click();
|
||||
const allow = view.querySelector<HTMLButtonElement>('[data-test-id="board-grant-allow"]');
|
||||
const reject = view.querySelector<HTMLButtonElement>('[data-test-id="board-grant-reject"]');
|
||||
allow?.click();
|
||||
await vi.waitFor(() => expect(grant).toHaveBeenCalledWith("alpha", "granted"));
|
||||
await vi.waitFor(() => expect(reject?.disabled).toBe(false));
|
||||
expect(toastHost.querySelector(".app-toast")).toBeNull();
|
||||
|
||||
reject?.click();
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
view.querySelector('[data-test-id="board-widget-action-error"]')?.textContent,
|
||||
).toContain("approval service unavailable");
|
||||
expect(toastHost.querySelector(".app-toast__message")?.textContent).toContain(
|
||||
"Could not reject widget access. Try again.",
|
||||
);
|
||||
});
|
||||
expect(view.querySelector('[data-test-id="board-grant-allow"]')).not.toBeNull();
|
||||
expect(view.querySelector('[data-test-id="board-grant-reject"]')).not.toBeNull();
|
||||
expect(allow?.disabled).toBe(false);
|
||||
expect(reject?.disabled).toBe(false);
|
||||
|
||||
view.snapshot = structuredClone({ ...source, revision: source.revision + 1 });
|
||||
await settleCells(view);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type PluginBoardWidgetRenderer,
|
||||
} from "../../lib/board/widgets/index.ts";
|
||||
import { formatUiError } from "../../lib/format-error.ts";
|
||||
import { showToast } from "../../lib/toast.ts";
|
||||
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
|
||||
import { renderBoardMcpAppContent } from "./board-mcp-app-content.ts";
|
||||
import { BoardMcpAppLifecycle } from "./board-mcp-app-lifecycle.ts";
|
||||
@@ -153,7 +154,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private async runAction(action: () => Promise<void>): Promise<void> {
|
||||
private async runAction(action: () => Promise<void>, failureMessage?: string): Promise<void> {
|
||||
if (this.actionPending || this.busy) {
|
||||
return;
|
||||
}
|
||||
@@ -164,11 +165,25 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
await action();
|
||||
} catch (error) {
|
||||
this.actionError = formatUiError(error);
|
||||
if (failureMessage) {
|
||||
showToast({ message: failureMessage });
|
||||
}
|
||||
} finally {
|
||||
this.actionPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
private runGrantDecision(
|
||||
widget: BoardWidget,
|
||||
callbacks: BoardWidgetCellCallbacks,
|
||||
decision: BoardGrantDecision,
|
||||
): void {
|
||||
const failureMessage = t(
|
||||
decision === "granted" ? "board.widget.allowFailed" : "board.widget.rejectFailed",
|
||||
);
|
||||
void this.runAction(() => callbacks.grant(widget.name, decision), failureMessage);
|
||||
}
|
||||
|
||||
private handleMenuSelect(
|
||||
event: CustomEvent<{ item: { value?: string } }>,
|
||||
widget: BoardWidget,
|
||||
@@ -207,8 +222,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
? renderBoardWidgetPending({
|
||||
widget,
|
||||
disabled: this.busy || this.actionPending || !this.canGrant,
|
||||
onGrant: (decision) =>
|
||||
void this.runAction(() => callbacks.grant(widget.name, decision)),
|
||||
onGrant: (decision) => this.runGrantDecision(widget, callbacks, decision),
|
||||
...(this.actionError
|
||||
? { error: renderBoardWidgetActionError(this.actionError, true) }
|
||||
: {}),
|
||||
@@ -244,7 +258,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
return renderBoardWidgetPending({
|
||||
widget,
|
||||
disabled: this.busy || this.actionPending || !this.canGrant,
|
||||
onGrant: (decision) => void this.runAction(() => callbacks.grant(widget.name, decision)),
|
||||
onGrant: (decision) => this.runGrantDecision(widget, callbacks, decision),
|
||||
...(this.actionError
|
||||
? { error: renderBoardWidgetActionError(this.actionError, true) }
|
||||
: {}),
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
controlUiBundledSettingsStorageKey,
|
||||
installMockGateway,
|
||||
} from "../test-helpers/control-ui-e2e.ts";
|
||||
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
|
||||
|
||||
const suite = createControlUiE2eSuite({
|
||||
name: "Control UI dashboard grant failure",
|
||||
startServerBeforeBrowser: true,
|
||||
});
|
||||
const sessionKey = "agent:main:dashboard-grant-failure";
|
||||
const proofDir = path.resolve(process.cwd(), ".artifacts/control-ui-e2e/workboard-grant-failure");
|
||||
|
||||
suite.define(() => {
|
||||
it("keeps a network-capability decision retryable and toasts when Allow fails", async () => {
|
||||
const recordProof = process.env.OPENCLAW_UI_E2E_RECORD === "1";
|
||||
if (recordProof) {
|
||||
await mkdir(proofDir, { recursive: true });
|
||||
}
|
||||
const context = await suite.browser.newContext({
|
||||
viewport: { height: 900, width: 1280 },
|
||||
...(recordProof
|
||||
? { recordVideo: { dir: proofDir, size: { height: 900, width: 1280 } } }
|
||||
: {}),
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
sessionKey,
|
||||
featureMethods: ["board.get", "board.widget.grant", "chat.metadata", "chat.startup"],
|
||||
methodResponses: {
|
||||
"board.get": {
|
||||
sessionKey,
|
||||
revision: 1,
|
||||
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" }],
|
||||
widgets: [
|
||||
{
|
||||
name: "status",
|
||||
tabId: "main",
|
||||
title: "Status",
|
||||
contentKind: "html",
|
||||
sizeW: 6,
|
||||
sizeH: 4,
|
||||
position: 0,
|
||||
grantState: "pending",
|
||||
declared: { netOrigins: ["https://api.example.com"] },
|
||||
revision: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
"board.widget.grant": {
|
||||
__mockError: {
|
||||
code: "UNAVAILABLE",
|
||||
message: "internal capability service detail",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const settingsKey = controlUiBundledSettingsStorageKey(suite.server.baseUrl);
|
||||
await page.addInitScript(
|
||||
({ key, storageKey }) => {
|
||||
const settings = JSON.parse(localStorage.getItem(storageKey) ?? "{}") as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
settings.boardSessionViews = { [key]: { activeTabId: "main" } };
|
||||
localStorage.setItem(storageKey, JSON.stringify(settings));
|
||||
},
|
||||
{ key: sessionKey, storageKey: settingsKey },
|
||||
);
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}dashboard`);
|
||||
const pending = page.locator('[data-test-id="board-pending"]');
|
||||
const allow = pending.getByRole("button", { name: "Allow" });
|
||||
const reject = pending.getByRole("button", { name: "Reject" });
|
||||
await pending.waitFor();
|
||||
await gateway.deferNext("board.get", { sessionKey });
|
||||
await allow.click();
|
||||
|
||||
const request = await gateway.waitForRequest("board.widget.grant");
|
||||
expect(request.params).toEqual({
|
||||
sessionKey,
|
||||
name: "status",
|
||||
decision: "granted",
|
||||
revision: 1,
|
||||
});
|
||||
const toast = page.locator("openclaw-toast-host .app-toast");
|
||||
await toast.waitFor();
|
||||
expect(await toast.textContent()).toContain("Could not allow widget access. Try again.");
|
||||
expect(await toast.textContent()).not.toContain("internal capability service detail");
|
||||
await expect.poll(() => allow.isEnabled()).toBe(true);
|
||||
expect(await reject.isEnabled()).toBe(true);
|
||||
await pending.waitFor();
|
||||
await page.locator('[data-test-id="board-widget-action-error"]').waitFor();
|
||||
if (recordProof) {
|
||||
await page.screenshot({ path: path.join(proofDir, "grant-failed.png") });
|
||||
}
|
||||
} finally {
|
||||
const video = page.video();
|
||||
await context.close();
|
||||
if (recordProof && video) {
|
||||
await video.saveAs(path.join(proofDir, "workboard-grant-failure.webm"));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3778,7 +3778,9 @@ export const en: TranslationMap = {
|
||||
toolCapability: "Tool: {capability}",
|
||||
granted: "Granted",
|
||||
allow: "Allow",
|
||||
allowFailed: "Could not allow widget access. Try again.",
|
||||
reject: "Reject",
|
||||
rejectFailed: "Could not reject widget access. Try again.",
|
||||
rejected: "Access rejected",
|
||||
rejectedDetail: "This widget stays inactive until it is removed or replaced.",
|
||||
appLoading: "Restoring app…",
|
||||
|
||||
Reference in New Issue
Block a user